spec-cli¶
The server + CLI — reads .spec and git, serves the API, and houses the source-of-truth guards.
raw source¶
One of three SpexCode packages (with spec-dashboard and spec-yatsu). It is the server + CLI: read the
.spec tree and its git history, serve them over an API, ship the spex CLI, and house the
source-of-truth guards (git-as-database, the worktree linker, the guards, the linter) here — under
the CLI where they belong, not under the dashboard. Hono + tsx, no build step.
expanded spec¶
spec-cli is the backend. It owns the read path (turn .spec + git into JSON) and the write path
(the spex CLI driving worktrees/sessions); the dashboard is a thin HTTP caller. index.ts is the
HTTP entrypoint — a Hono app that wires the loaders and the session state machine to routes — and is
the file this node governs (the deeper mechanism lives in its source-of-truth subtree; the yatsu
eval endpoints' contract belongs to spec-yatsu, so their churn — the eval-blob comment reframed to
serve a transcript or image, not just pixels — is that subtree's evolution, not spec-cli's drift).
The serve script (the npm run api entry) hot-reloads the backend on changes to any source tree the
child actually imports — its own spec-cli/src/** plus the sibling packages it loads at runtime
(spec-forge, spec-yatsu) — never on .spec/**/spec.md or spec-dashboard edits, which it reads via fs
or never imports (the frontend is a separate vite server with its own HMR). Watching only its own dir was a
real gap: a merge touching spec-forge reached disk while the running child kept the stale code, so a fix
could ship to main yet stay invisible on the live dashboard. The reload must be zero-downtime: port 8787
never has a gap. A tsx watch restart left a
~1-2s window where every API call was refused (a node merge touching backend code took the dashboard
down); that window must not exist.
The mechanism is a tiny supervisor (serve runs supervise.ts) that owns the public port as a
raw-TCP proxy and runs the real Hono server as a child on a private port. On a source change it boots a
fresh child, waits for GET /health (a cheap, git-free readiness probe), atomically flips the proxy to
it, then gracefully drains the old child — which stops accepting new connections but finishes
in-flight requests before exiting. The public socket never closes, so the flip is invisible. (SO_REUSEPORT
is the obvious alternative but is unsupported on this platform, hence the proxy.) An unhealthy new child
is discarded and the current one kept, so a broken edit degrades to "still serving old code", never a gap.
Live ws/pty bridges drop and reconnect; detached tmux sessions survive untouched. (Under spex serve
--public the supervisor's raw proxy retreats to a loopback port and the password-gated public-mode
gateway takes the public port — loopback stays the trusted face local agents reach; the gateway is the
internet face. Default serve is unchanged: the proxy itself owns the public port.) The dashboard also
retries a transient failure with bounded backoff, so a poll landing on the flip is masked. Because the
child binds a private port that changes on every reload, the supervisor hands it a fixed
SPEXCODE_API_URL at the public port; every session the child launches inherits it, so a launched
agent's own spex calls reach the stable public endpoint instead of chasing a retired child's port.
Owning the public port is the contract: if I cannot bind it, I have failed. Keeping-serving is for
transient throws once the port is held — never for failing to acquire it. So a bind failure (port in
use, or permission denied) is the one throw the supervisor must not swallow: a hard, loud, non-zero exit
naming the busy port and the repair, never a portless process kept "alive" on a random child port. The same
rule is shared with public-mode's gateway behind spex dashboard, so a busy port fails identically
on both surfaces — not a silent zombie under serve and a crash under dashboard. One shared bind helper
both call (not a branch inside the keep-alive guard) reaps the booted child first, so no zombie survives.
Last-resort resilience: both supervisor and child install process guards at startup — an unforeseen async throw (a worktree vanishing mid-read during a worker self-merge, say) is logged and the process KEEPS SERVING rather than exiting and dropping the public port (and the tmux session) with it.
Read routes: /api/board (the assembled board — merged tree + per-worktree overlay + session list, the
dashboard's single source, identical to spex board). The dashboard polls it on a short interval, so the
route is a conditional-request endpoint: it ETags the body so a poll that finds nothing changed costs a
bodyless 304, not the whole transfer — a standard HTTP capability, not a special case (the board is still
rebuilt each request; the cost saved is the wire, not the git read). /api/specs (live via loadSpecs),
/api/specs/:id/history + /api/specs/:id/diff/:hash (a node's timeline and any version's spec.md
line-diff), /api/edit (a node's in-flight working-tree delta vs its fork point, reviewable from the
board — incl. a brand-new, still-untracked node as an all-additions diff, so a just-created uncommitted
node shows its body not nothing), /api/layout (the resolved
portable-layout), and /api/config + /api/slash-commands (the
/ dropdown — config-root plugins declaring surface: command, plus the Claude-Code command union).
Write/runtime routes are thin callers of the sessions state machine — no session logic lives here:
/api/sessions list + spawn; per-session resume/review/close, plus reads review (the merge
bundle), capture (the live pane as text), and prompt. merge is a dispatch to the session's own
agent, not a server merge — it returns {dispatched} and never touches main's tree. The ❯ box
(keys) dispatches a whole prompt over the rendezvous control socket, fail-loud (an unconfirmed prompt is
502, never a silent 200); rawkey keeps tmux send-keys for nav; socket streams pane bytes.
/api/sessions/graph edges are DERIVED from live spex watch monitors (watch/unwatch register +
heartbeat), not a stored subscription. /api/uploads writes a pasted file to this (worker) machine's
/tmp and returns its path. At boot the server also runs superviseQueue() to launch queued sessions.