Pivot Parallax to a trigger layer over Hermes agents - #36
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-templatesserves 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:
sha1(route, ref, revision)in a SQLite ledger before any work starts, and reservedparallax:labels stop a route re-firing on the agent's own work.packages/cloud-api— the control planeRailway-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 UIReact 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/mebefore 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 fromPARALLAX_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-servicerailway.jsonfiles 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/geminiadapters, 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:
syncMainBranchused togit checkout mainin your actual working clone,task_logs.taskExternalIdstored 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
last_seen_atwas written only byhello, 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/corsdefaults toGET,HEAD,POST, so a browser preflight refused everyDELETEandPUT. Every delete button in the dashboard was broken while curl, which sends no preflight, worked perfectly.run_events.tsis a bigint, which node-postgres returns as a string, andnew Date(string)isInvalid Date— every event timestamp rendered as a dash.fillRouteTemplatesubstituted raw values into serialized JSON, so a label or prompt containing a quote produced a document that no longer parsed.Verification
test/hermes/fake-hermes-server.tscan misbehave on demand, so the adapter's timeout, cancellation, approval, and degradation paths are testable without a real Hermes.linux/amd64in CI. The dashboard image is started and checked for its runtime API URL, SPA fallback and health endpoint.cloud-apiover real Postgres — hand-written fixtures agree with the source by construction and would have missed the bigint bug entirely.cerebroagainst 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