Skip to content

Pivot Parallax to a trigger layer over Hermes agents - #36

Merged
maxigimenez merged 18 commits into
mainfrom
pivot/hermes-orchestrator
Sep 2, 2026
Merged

Pivot Parallax to a trigger layer over Hermes agents#36
maxigimenez merged 18 commits into
mainfrom
pivot/hermes-orchestrator

Conversation

@maxigimenez

@maxigimenez maxigimenez commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Parallax stops running agents and starts deciding which agent runs, when, and with what context. Hermes owns everything from the moment a run starts — the filesystem, git, worktrees, credentials, and the agent's own GitHub identity.

That boundary is the decision everything else follows from. The runner needs no local clone of any repository, runs no git commands, and opens no pull requests.

Routing is the new core abstraction

trigger → match → target → execution → outcome, stored as data so a new workflow is a row rather than a code change:

{
  "name": "Product review on feasibility label",
  "trigger": { "type": "ticket", "provider": "linear", "projectId": "taplands" },
  "match":   { "labels": { "any": ["feasibility"] } },
  "target":  { "agentRef": { "profile": "product" } },
  "execution": { "prompt": "Assess {{ticket.ref}}: {{ticket.title}}", "timeoutSeconds": 1800 },
  "outcome": { "postComment": { "target": "ticket" },
               "labels": { "add": ["reviewed"], "remove": ["feasibility"] } }
}

The prompt lives on the route as free text, so rewording what an agent is asked to do never needs a release. GET /v1/route-templates serves complete, ready-to-create routes for every supported case, each checked in CI against the API's own validator.

Two invariants the dispatcher enforces, both learned from Hermes' own constraints:

  • One run per agent. Hermes corrupts a profile's memory if two agents drive it concurrently, so a route targeting a busy agent defers without claiming its dedupe key — the trigger stays live for the next cycle.
  • Fire once per change. Every dispatch claims sha1(route, ref, revision) in a SQLite ledger before any work starts, and reserved parallax: labels stop a route re-firing on the agent's own work.

packages/cloud-api — the control plane

Railway-deployed, holding config, the agent registry, and run history, with per-org Slack notifications.

The runner reaches it by long-polling, so the Mac Mini works behind NAT with no tunnel and no inbound connection. Two API key scopes (prx_rnr_ / prx_usr_) are separated from day one, and presenting one where the other is required is a 401.

Notifications are cloud-side rather than runner-side for one reason: only the cloud can report a runner going stale when the machine drops off the network.

packages/cloud-dashboard — the UI

React over the user API, built on @16-bits-design/ui. Watch runs, create and edit routes from templates, manage projects, keys and Slack. Sign-in is a user key verified against GET /v1/me before it is stored.

It is a pure client: no server-side session, no database, and everything it can do is something a prx_usr_ key can do. The API URL is read at runtime from PARALLAX_API_URL, so moving the control plane is a variable change rather than a rebuild.

Deployed as a second Railway service. Both services are declared in .railway/railway.ts — Railway's Infrastructure as Code, which replaced the per-service railway.json files after Config as Code was deprecated.

The one rule in the Hermes integration

The SSE stream is progress; the poll is truth. Hermes expires run event buffers after five minutes, so a long run's stream ends while the run continues. Completion is decided exclusively by polling GET /v1/runs/{id}; stream failures degrade logging, never the run.

Removed

The claude-code/codex/gemini adapters, the 7-state plan-approval machine (Hermes has its own approval primitive), the local React dashboard and its socket layer, the Slack socket-mode bot, all worktree and git machinery, and the marketing site. Nothing here had users.

Also fixed on the way through: syncMainBranch used to git checkout main in your actual working clone, task_logs.taskExternalId stored the internal id rather than the external one, and the database opened a SQLite file purely as an import side effect.

Bugs found by running it rather than reading it

  • Every runner read as stale. last_seen_at was written only by hello, which a runner sends once at startup, so the timestamp was the process start time against a 90-second threshold. The runner now heartbeats each cycle with Hermes reachability, in-flight runs and uptime.
  • @fastify/cors defaults to GET,HEAD,POST, so a browser preflight refused every DELETE and PUT. Every delete button in the dashboard was broken while curl, which sends no preflight, worked perfectly.
  • run_events.ts is a bigint, which node-postgres returns as a string, and new Date(string) is Invalid Date — every event timestamp rendered as a dash.
  • fillRouteTemplate substituted raw values into serialized JSON, so a label or prompt containing a quote produced a document that no longer parsed.
  • The library's bare-element selectors out-specify a consumer class, so every breadcrumb and idle nav link rendered primary orange and every section heading rendered at 24px. Filed upstream as 16-bits-design#10.

Verification

  • 393 tests across five packages; lint clean; all five typecheck.
  • test/hermes/fake-hermes-server.ts can misbehave on demand, so the adapter's timeout, cancellation, approval, and degradation paths are testable without a real Hermes.
  • Both images are built for linux/amd64 in CI. The dashboard image is started and checked for its runtime API URL, SPA fallback and health endpoint.
  • The dashboard's screen tests run against payloads recorded from a live cloud-api over real Postgres — hand-written fixtures agree with the source by construction and would have missed the bigint bug entirely.
  • Every flow was driven in a real browser: creating and editing routes, deleting, minting keys, and the responsive layout at 380px.
  • Running on cerebro against a live Hermes, and deployed on Railway.

Docs

getting-started.md · api.md · routes.md · dashboard.md · deploy-cloud.md · releasing.md · CLAUDE.md

🤖 Generated with Claude Code

Parallax stops running agents and starts deciding which agent runs, when,
and with what context. Hermes owns everything from the moment a run starts:
the filesystem, git, worktrees, credentials, and the agent's own GitHub
identity. The runner needs no local clone and runs no git commands.

Routing is the new core abstraction: trigger -> match -> target ->
execution -> outcome, stored as data so a new workflow is a row rather than
a code change. Two invariants the dispatcher enforces:

- One run per agent. Hermes corrupts a profile's memory if two agents drive
  it concurrently, so a route targeting a busy agent defers *without*
  claiming its dedupe key, leaving the trigger live for the next cycle.
- Fire once per change. Every dispatch claims sha1(route, ref, revision) in
  a SQLite ledger before any work starts; the revision is the ticket's
  updatedAt, so an unchanged ticket is a no-op and a real edit re-fires.

Adds packages/cloud: a Railway-deployed control plane holding config, the
agent registry, and run history, with per-org Slack notifications. The
runner reaches it by long-polling, so the Mac Mini works behind NAT with no
inbound connection. Two API key scopes are separated from day one so the
future dashboard slots in without an auth redesign.

Hermes integration turns on one rule: the SSE stream is progress, the poll
is truth. Hermes expires run event buffers after five minutes, so a long
run's stream ends while the run continues; completion is decided only by
polling GET /v1/runs/{id}, and stream failures degrade logging rather than
the run.

Retires the claude-code/codex/gemini adapters, the plan-approval state
machine (Hermes has its own approval primitive), the local React dashboard
and its socket layer, the Slack socket-mode bot, all worktree and git
machinery, and the marketing site. Nothing here had users.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
parallax Error Error Sep 1, 2026 6:38pm UTC

Projects moved to the cloud API but the runner was never given a way to
fetch them — it read `config.projects` from local config, which `parallax
init` leaves empty. A correctly configured install therefore polled nothing:
zero projects, zero trigger events, zero runs, and no indication why.

Adds GET /v1/runner/projects, a fetch and on-disk cache mirroring the route
path, and `parallax projects` / `parallax reload` to inspect and refresh
without a restart.

Also makes routing decisions observable. Only `unknown-agent` and
`agent-busy` carried a detail that reached the log; `no-route` and
`duplicate` returned silently, so "I labelled a ticket and nothing
happened" was indistinguishable from "the runner never saw the ticket" —
exactly the case this bug produced. The dispatcher now reports its decision
through a callback the moment routing resolves, ahead of the agent run, and
each poll cycle logs one summary:

  poll: 12 event(s) (taplands 12) · dispatched 1 · skipped 11 (no-route 10, duplicate 1)

Reporting before the run matters: a run can take half an hour, so anything
aggregating routing outcomes cannot wait on the promise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tsc emits 0644, and `npm install -g ./packages/cli` symlinks the `parallax`
bin straight at the compiled file rather than copying it — so the global
command was not executable and zsh refused it outright.

Setting the bit at build time fixes both the local-path install and a
published tarball.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The MirrorOutbox was constructed and flushed every cycle, but nothing ever
enqueued into it. Cloud run history stayed empty and Slack never fired,
since notifications are triggered by the mirror endpoints.

RunLifecycle now owns run creation as well as transitions — so no code path
can create a run the cloud never hears about — and reports through an
observer the runner wires to the outbox. It passes the stored record rather
than the caller's argument, because summary and endedAt only exist after the
write.

Creation and change are separate signals: the cloud treats the first report
as the run starting and later ones as updates, and only the first should
announce "started". A finished run ships its transcript once, on settling,
rather than streaming every event for output nobody reads until it is over.

Verified end to end against a webhook sink: a started and a completed
notification arrive with agent, route, linked ticket, duration and summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four pieces of operator feedback.

`parallax init` now reads the Hermes install rather than interviewing you
about it. It enumerates ~/.hermes/profiles/, picks each profile's
API_SERVER_KEY out of its own .env, and asks which to add. Filesystem layout
is a documented contract; `hermes profile list` output formatting is not, so
this reads directories rather than parsing CLI output. It also removes the
likeliest setup mistake — pasting the default profile's key against a named
profile, which Hermes only rejects later, at dispatch.

Routes carry their own prompt text. `execution.promptTemplate` named a
template compiled into the runner, so rewording what an agent was asked to
do meant shipping a release. Prompts are now free text with {{placeholders}},
and the built-in templates survive only as a catalog the dashboard can
prefill from, served at GET /v1/prompt-templates. Migration 002 rewrites
existing routes in place from the template they referenced, so none loses
its behaviour. An unrecognized placeholder is left visible and warned about
rather than blanked: a typo silently becoming an empty string produces a
confidently wrong run.

Projects and routes are re-read every poll cycle, so adding either in the
cloud takes effect without a restart; additions and removals are logged.
`parallax restart` exists for the changes that do need a new process.

Slack notifications can carry an agent avatar, rendered inside the message
as a Block Kit accessory. Deliberately not `username`/`icon_url`: the
webhook's identity belongs to the Slack app, not to whichever agent ran.

Also fixes a notification bug found on the way: a failed delivery claimed
its (run, event) row and then blocked every retry, so one transient Slack
outage suppressed that notification permanently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Installing globally under a version manager scopes the command to whichever
Node was active at install time. Switch versions and it either vanishes from
PATH or runs under an interpreter that cannot load node:sqlite, failing deep
in the database layer with an error saying nothing about Node versions.

The CLI now checks the one capability that matters — can this interpreter
load node:sqlite — and re-executes itself under one that can, searching the
interpreter it last used, then nvm, fnm, volta, asdf and Homebrew. The
choice is remembered in ~/.parallax/node-runtime.json. Checking capability
rather than a version number stays correct when node:sqlite stops being
experimental, or lands in a runtime reporting a version we do not know.

The floor drops from 23.7 to 22.5, which is Node 22 LTS: 22.x needs
--experimental-sqlite, later versions accept and ignore it, so one
invocation covers every supported runtime.

The runner and the launchd agent are started with an absolute interpreter
path, so a version switch months later cannot break a daemon that already
works. `parallax runner status` reports when that interpreter has been
removed; preflight now reports the capability and which binary will be used
rather than comparing version strings.

Two things this turned up. The probe used a bare `require` in an ESM module,
so it threw ReferenceError and reported every runtime as incapable —
including the one it had just re-executed into. And it accepted any
candidate that exited 0, so /bin/echo passed; it now must print a nonce read
from the environment, which nothing can produce by echoing its arguments
back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pr_event` was declared in the type spine and emitted by nothing, and pull
requests were only looked at when a reviewer had been requested — so a route
keyed on a label or an assignee never saw them. Pull requests are now
collected unconditionally, carrying assignees, draft state and base branch,
and emitted as both `pr_event` and, when review is genuinely awaited,
`pr_review_requested`.

Routes can match on what CHANGED, not only on current state: labelsAdded,
labelsRemoved, assigneesAdded, backed by an observations table holding the
last-seen shape of each item. Transition clauses never match on first sight.
Without that, creating a route would fire it across every pull request that
already carries the label — a new route starts quiet and acts on what
happens next.

The loop guard is the substance of this change. An agent acting on a pull
request changes it, and a route that re-fires on change would retrigger
itself on its own work. Two independent mechanisms:

- guard.refire defaults to `once`, which drops the revision from the dedupe
  key entirely, so a route fires for an item exactly one time whatever
  happens to it afterwards. This holds even where labels cannot be written.
- guard.markers writes parallax:in-progress around the run and swaps it for
  parallax:done or parallax:failed. No route ever matches something carrying
  in-progress — unconditionally, even for a route that disabled markers,
  because starting a second agent on work already in flight is never wanted.
  Removing a terminal marker by hand is how a human re-arms a route, and how
  a failed item is retried.

`per-change` without markers is rejected at the API: it is the one
combination that can loop. Marker labels are created when the repository or
team does not have them, since gh fails outright on an unknown label and the
guard would otherwise be silently ineffective.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The review cycle — request review, agent reviews, author replies and
re-requests, agent reviews again — did not work. `once` fired one round and
stopped, and `per-change` on a pending review request would re-fire on every
subsequent change to the pull request.

`reviewersAdded` was already computed by the observation store and thrown
away. Exposing it as a match clause is the precise primitive: it fires on the
*act* of requesting review rather than while a request is outstanding, so
each re-request starts a round and nothing else does. An agent posting
comments adds no reviewer, so a route keyed on it cannot retrigger itself
whatever the agent does — which matters, because whether the agent submits a
formal review or a bare comment is a SOUL.md decision Parallax cannot see.

The bundled pr-review prompt now tells the agent to read the diff and the
thread with gh, rather than Parallax fetching and inlining them. Parallax
says which pull request and what to do; the agent already has the tools to
get its own context. Adds {{repo.slug}} so those commands are
copy-pasteable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Documents every supported case, and does it twice: once as prose in
docs/routes.md, and once as machine-readable templates the dashboard can
offer directly.

ROUTE_CATALOG holds seven complete routes — ticket assess, triage on label
added, implement, the multi-round PR review cycle, PR assigned, PR labelled,
PR unblocked — each carrying <PLACEHOLDER> tokens a user fills in. Those are
deliberately unlike the {{curly}} prompt variables: one is answered at
creation by a human, the other at dispatch by the runner. Served from
GET /v1/route-templates alongside the existing prompt and reserved-label
catalogs.

Route validation moves into @parallax/common so the API, the catalog and its
tests all agree on what is acceptable, and gains two rules worth having: a
githubLogin target on a ticket trigger, and pull-request-only match clauses
on one, are both rejected rather than silently never firing. Every catalog
entry is checked in CI against that validator and against the prompt
renderer, so a template cannot ship in a shape the API would reject or with
a placeholder nothing can fill.

Also adds a test that imports the BUILT package. Every other test aliases
@parallax/common to src/, which resolves module cycles differently from the
real ESM graph — a circular import between route-validation and index passed
the entire suite and only failed when the container started. That is now
caught in CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It aborted on a blind 30ms timer, racing the run creation it depends on.
Under parallel load the abort could land before Hermes had accepted the run,
in which case there is no run id to stop and the assertion on stopCalls
failed. It waits for the creation to land instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Makes room for packages/cloud-dashboard as a sibling rather than something
that reads as subordinate to "cloud".

`api` alone would have been shorter but ambiguous: the runner also serves an
API, on the machine next to Hermes. `cloud-api` says which one, which matters
in a repo that has both.

Git tracks this as a rename, so history follows the files. The published
package name changes to @parallax/cloud-api and the image WORKDIR to
/app/packages/cloud-api — the Railway start and pre-deploy commands are
relative to it and are unaffected, but the service needs a redeploy to pick
up the new layout.

Verified by building the amd64 image and running migrations, org bootstrap,
/health and /v1/route-templates against a real Postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
maxigimenez and others added 7 commits September 1, 2026 19:49
Lint was failing on prettier formatting in the test file added last, plus
two dead imports left from earlier edits. My own check reported clean
because `pnpm lint >/dev/null && echo clean` prints nothing on failure —
CI now enforces it rather than my reading of a shell one-liner.

Replaces test-lint.yml and release.yml with three workflows named for what
they do.

ci.yml runs lint, typecheck and tests on Node 22.11 and 24. That matrix is
load-bearing rather than decorative: 22 needs --experimental-sqlite for
node:sqlite and 24 ignores it, so running both is what makes the supported
range a claim we check. It builds before testing because one suite imports
the built package, and builds the image for linux/amd64 — the architecture
Railway deploys on — asserting both container entry points resolve.

deploy-cloud-api.yml deploys from the checkout with a Railway project token,
serialized so two migration runs cannot overlap, and waits on /health. It is
path-filtered rather than firing on every merge: redeploying the control
plane interrupts a runner's long poll for no reason.

publish-cli.yml verifies before shipping, which the previous workflow did
not. It checks the two things that only fail on a user's machine: that the
compiled entry point is executable, since tsc emits 0644 and a global
install symlinks straight at it, and that both internal packages are
actually in the tarball, since they are bundled rather than fetched.

Verified locally by running what the workflows run: the suite on Node 22.11
with the flag, the amd64 image and its entry-point checks, and a full pack
round-trip installed into a clean prefix.

Note for whoever publishes next: npm trusted publishing matches on the
workflow filename, and release.yml is now publish-cli.yml. That entry needs
updating on npmjs.com or the publish is rejected with a permissions error
that says nothing about the rename. docs/releasing.md calls this out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two failures on the PR, both mine.

publish-cli.yml would not parse: `run: echo "Dry run: packed and verified"`
is an unquoted scalar containing ": ", which YAML reads as a mapping key.
GitHub surfaces that as a run with no jobs, triggered by an event the
workflow does not even declare, and an error that appears in no job log. CI
now parses every workflow file so the next one fails somewhere legible.

The typecheck ran before the build. cloud-api resolves @parallax/common
through its built .d.ts rather than a path alias — deliberately, so its own
build does not pull common's source into its output — which means a
typecheck against a clean tree cannot find it. It passed locally only
because dist was already there from earlier work. Building first fixes it
and is required regardless, since one suite imports the built package.

Also removes a duplicated paragraph in the release docs and corrects the
workflow named under "Publishing the CLI".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven screens against the existing database and API: overview, runs, run
detail, routes, agents, projects, access keys and settings. Nothing new is
stored — every request the dashboard makes is one an operator could make with
curl and a prx_usr_ key.

Sign-in is a user key kept in localStorage. It is verified against a new
GET /v1/me before being stored, so a bad key fails at the form rather than on
every screen after it, and a stored key is re-verified on load because it may
have been revoked since. That endpoint is the one API gap: every other route
answers "is this key valid" but none names the organization, and probing an
arbitrary one would make an unrelated failure look like a rejected key. The
localStorage tradeoff is bounded and documented; the upgrade is a server-side
session, and it belongs with real accounts.

The API URL is resolved at runtime from PARALLAX_API_URL, served as /env.js by
a dependency-free static server, rather than inlined by Vite — otherwise moving
the control plane means rebuilding the image. That server also does the SPA
fallback and the health check, and gets the caching right: hashed assets
immutable, index.html never.

Deploys to Railway as a second service from Dockerfile.dashboard, pointed at
railway.dashboard.json through the service's config-file setting. Leave that
default and the dashboard silently deploys the API image, so it is called out
in three places.

Built on @16-bits-design/ui with --bits-* tokens throughout and no hardcoded
colours. Six components the library does not ship yet are implemented locally
and filed upstream as maxigimenez/16-bits-design#1-6: Table, Alert, Segmented,
Spinner, EmptyState and Code.

Two bugs found by running it rather than reading it:

- fillRouteTemplate substituted raw values into serialized JSON, so a route
  whose label or prompt contained a quote produced a document that no longer
  parsed. Fixed here and in @parallax/common, which had the same flaw; the
  dashboard is just where arbitrary typed text reaches it.
- run_events.ts is a bigint, which node-postgres returns as a string, and
  new Date(string) is Invalid Date — every event timestamp rendered as a dash.
  The screen tests run against payloads recorded from a live API precisely
  because hand-written fixtures would have agreed with the source and missed it.

CI gains a dashboard image job that starts the container and checks the three
things that only fail at runtime. The Node floor moves 22.11 -> 22.12 because
Vite 8 requires it; that constrains where the repo is built, not where the
runner runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nxaXsY4omeX4bRrTojhpR
Config as Code is deprecated. Railway stopped letting a service opt in on
2026-08-28 and retires the mechanism on 2026-12-01, and the API now refuses
the mutation outright:

  Config as Code (railway.json / railway.toml) is deprecated.
  Use Infrastructure as Code (.railway/railway.ts) instead.

So the plan of pointing the dashboard service at its own railway.json was
never going to work — that service was created after the cutoff and can never
opt in. It was reading the root railway.json, which is the control plane's,
which is why `railway up --service dashboard` built and deployed the API image.

Both services are now declared in .railway/railway.ts, with the two
railway.*.json files deleted. That is better than what it replaces rather than
merely equivalent: with no root railway.json there is no default for a new
service to inherit, so a service silently adopting another's builder is not
fixed but unrepresentable, and both services' builders sit next to each other
in one reviewable file.

`railway config plan` reports 0 to add, 4 to change, 0 to destroy — every
change a null becoming the value the deleted JSON already specified. The apply
itself is left to a human; `config apply` reconciles configuration and `up`
deploys source, and the docs now say so in three places, because running only
`up` after editing this file leaves a service building whatever it was last
told to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nxaXsY4omeX4bRrTojhpR
Almost all of them were one root cause, and it was mine. The library styles
bare elements — .bits-theme a, h1, h2, p, code — at specificity (0,1,1). A
single application class on such an element is (0,1,0) and loses silently: no
error, no warning, just the library's defaults.

Measured, not inferred:

  breadcrumb parent  wanted #8f8a7c   rendered #ff7a1a   .bits-theme a
  breadcrumb title   wanted 13px      rendered 24px      .bits-theme h1
  nav item, idle     wanted #a9a394   rendered #ff7a1a   .bits-theme a
  section heading    wanted 11px      rendered 18px      .bits-theme h2

Which is why every sidebar item and breadcrumb was orange. It survived my own
screenshots because an all-orange nav reads as a styling choice rather than a
bug. Rules landing on those elements are now scoped under .px-root, reaching
(0,2,0) without !important.

Two further deviations, both mine and both unrelated to specificity:

- The sidebar header showed the product wordmark. The design puts an org
  control there; the wordmark belongs to the login screen. It is now a mark,
  the org name, the key it is signed in with, and a menu. One key resolves to
  one organization, so the menu carries that org and signing out rather than a
  switcher list that could never have a second entry. Sign-out moves out of the
  footer, which drops a grey square that read as a status indicator for a thing
  with no status.
- Nav hover used --bits-raised and brightened the label. The design uses
  --bits-muted and changes background only, keeping colour to mean "you are
  here".

Idle nav labels use --bits-text-soft rather than the design's #a9a394, which
has no token — hardcoding it would break Ocean and any custom theme.

test/specificity.test.ts reads the library's stylesheet to learn which elements
it styles bare, scans the JSX for px- classes on them, and fails if a rule is
unscoped. My first version passed while the bug was reintroduced, because it
only checked that some scoped rule existed; the hardened version checks the
selectors, and immediately found two unscoped rules I had missed in the
narrow-screen media query. Verified by reintroducing the h1 bug and watching it
fail.

Filed upstream: maxigimenez/16-bits-design#10 proposes :where() on the
bare-element rules, which would remove the hazard for every consumer, and #11
reports the missing token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nxaXsY4omeX4bRrTojhpR
Four things asked for, plus two bugs found while testing them in a browser.

1. Runner health. "last seen 67h ago" was not a missing feature, it was a bug:
   last_seen_at was written only by POST /v1/runner/hello, which a runner sends
   once at startup, so the timestamp was the process start time and the 90s
   stale threshold made every runner that had been up for more than 90 seconds
   read as stale. The indicator was wrong in exactly the case it exists for.

   The runner now heartbeats once per poll cycle with what it started at,
   whether Hermes answers, how many runs are in flight, and the last cycle
   error. last_seen_at is also touched by any authenticated runner request, so
   a runner too old to heartbeat still reads alive on its long poll alone.
   hermes_ok is nullable and rendered as "not reported" rather than as a
   failure, because a runner that did not say is not a runner that said no.

2. Creating moved to its own page on every screen that has it — /routes/new,
   /projects/new, /keys/new — reached from a button top right. The header's
   action slot is now always rendered, empty or not: a slot that appears only
   on some pages shifts the header and everything under it by a button's
   height. Measured constant at 74px across all seven screens.

3. Routes are editable: GET and PUT /v1/routes/:id, and /routes/:id/edit. PUT
   replaces the whole rule and revalidates, since a route's parts constrain
   each other and a partial merge could walk one into a state the validator
   rejects as a whole. The form owns seven fields and writes everything else —
   match, guard, outcome — back untouched, shown read-only so it is not a
   surprise. Renaming a route must never drop its loop guard.

4. The route form uses dropdowns for project and agent, and lists the prompt
   variables underneath, click-to-insert. Free text where the API knows the
   answer produces a route that is valid and can never match; a typo'd project
   id fires nothing, silently, forever. Selecting the agent is also what fills
   a githubLogin target so the two cannot drift.

Two bugs, both only visible in a browser:

- @fastify/cors defaults methods to GET,HEAD,POST, so a preflight refused
  every DELETE and PUT. Every delete button in the dashboard I shipped was
  broken, and the Slack save with it, while curl worked perfectly because it
  sends no preflight. Found by driving the edit form with a real browser.
  cors.test.ts asserts the preflight, and fails against the old default.
- .px-cell is a flex column, so a Badge stretched to the full cell width.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nxaXsY4omeX4bRrTojhpR
npm has parallax-cli at 0.1.0. This release replaces what the CLI does
outright — the adapter layer is gone, the config schema is v2, the SQLite
database is new — so pre-1.0 that is a minor bump. 0.2.0 also aligns the two
packages already claiming it while common, orchestrator and the cli were still
at 0.1.0.

version:set moves the cli's internal pins alongside the versions, which it must:
@parallax/common and @parallax/orchestrator are unpublished and linked by exact
version, so a stale pin falls through to the npm registry and fails to resolve
on a user's machine. Verified by packing and installing the tarball rather than
trusting it — parallax-cli-0.2.0.tgz reports 0.2.0, ships both bundled packages
with 38 compiled files, keeps the exec bit on the entry point, and runs after a
clean npm install outside the workspace.

Four scripts wrap the Railway CLI, because `apply` and `up` are different verbs
and the difference is not obvious from either name:

  pnpm railway:plan               preview; changes nothing
  pnpm railway:apply              reconcile the project with .railway/railway.ts
  pnpm railway:deploy:api         ship source to the control plane
  pnpm railway:deploy:dashboard   ship source to the dashboard

Dropped restartPolicyType from the IaC file. It is applied and live as
ON_FAILURE on both services, but it is also Railway's default and the plan
reader reports it as unset, so declaring it made every plan show two phantom
changes. A plan that never reads clean is one nobody reads, which costs more
than restating a default is worth. restartPolicyMaxRetries stays, because 5 is
not the default. `pnpm railway:plan` now reports the project up to date.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nxaXsY4omeX4bRrTojhpR
@maxigimenez
maxigimenez merged commit b36105d into main Sep 2, 2026
5 checks passed
@maxigimenez
maxigimenez deleted the pivot/hermes-orchestrator branch September 2, 2026 10:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant