graph¶
The live session-monitor network — edge A→B iff A subscribes to B (spex watch stream or spex wait one-shot) — over one shared poll + edge lifecycle.
raw source¶
Sessions form a directed monitor network — one of the two ties the graph draws (the talking tie is
comms-edge's). A monitor edge means exactly one thing: A→B iff agent A is right now running spex
watch/spex wait on B. The monitor network is derived from live watches, never persisted — no
subscription store, no datastore, no file; a monitor edge exists only while that watch runs.
expanded spec¶
When a spex watch starts it registers with the backend — reporting its own session id
(ownSessionId — the harness env var, e.g. CLAUDE_CODE_SESSION_ID; no worktree fallback) as the watcher plus its target selectors —
heartbeats while it runs, and deregisters on exit; a missed heartbeat drops the registration as a
backstop. Registrations are in-memory in the server (its single owner); the watch reports over HTTP
(POST …/graph/watch register+heartbeat, …/unwatch). A restart starts empty and live watches re-register
on their next beat. Best-effort on the watch side — a down backend delays only the edge, never the
event stream.
GET /api/sessions/graph returns { nodes, edges }: live sessions as nodes. This node owns the monitor
edges, computed at read time from the registrations (the persisted comms edges on the same payload
are comms-edge's). Each watcher's selectors are resolved live with the same matcher spex
ls/watch use, so a global watcher links to every session (incl. ones launched after the watch
started) and a node/branch selector picks up future matches too. Self-edges, edges touching a non-live
session, and duplicate A→B all drop out. This stays isolated from the board assembler — nothing here
touches buildBoard or the spec tree; the dashboard's session-graph is its observational surface.
spex watch — the lifecycle event stream¶
spex watch [SEL…] is also the event source for Claude Code's Monitor tool (watchSessions), emitting
the complete session lifecycle, not only actionable transitions. A session's first sighting emits a
launched event (once per id, never re-fired, so working/idle toggles don't flap); then each actionable
transition (review / done / close-pending / offline / error / asking) and the removal (closed). closed fires the moment a session's id is absent from the board, a definitive
removal: the board lists every worktree that exists (a flaky detail read degrades a row, never drops it; a
failed enumeration skips the poll — see worktree-resilience), so absence means the directory is actually
gone — no flicker debounce needed. Presence is tracked across all statuses (the
--status filter governs only which transitions are emitted, never presence), so a status leaving the
filter is never misread as a removal. The board it polls is an injected source (the backend client),
so a watch streams whatever backend SPEXCODE_API_URL names — even a remote one — and a backend-down
poll warns once and keeps the stream alive (never a phantom mass-closed). The net feed launched →
[actionable transitions] → closed is a true "subscribe to all session changes" stream — each watch process
one subscriber, the selector its subscription.
Two consumption policies, one subscription: watch (stream) and wait (one-shot)¶
watch and wait are the SAME subscription — poll the board source, draw the watcher→targets edge —
under two consumption policies; only how they consume transitions differs:
spex watch [SEL…]— stream forever, for a human monitoring the board. Emits every actionable transition; never exits (so a turn must never block on it).spex wait <id>— take-one-and-exit, an agent's event-loop primitive. Polls until<id>is actionable, prints that status, and exits — an agent backgrounds it and the harness re-invokes when the command exits, so the exit IS the wake-up. (Emit is silent: a backgrounded wait wants one clean status line, not the stream.)
Edge-drawing belongs to the subscription, not to watch (withWatchEdge in cli.ts): BOTH commands
report the watcher→targets edge (register + TTL heartbeat) for as long as they run and clear it on exit.
So a supervisor backgrounding spex wait <worker> is visible on the graph for the whole wait — N waits
draw N independent edges — and each clears the instant its wait resolves (supervision ended). Edge writes
are best-effort: the edge is cosmetic, so an unreachable backend never fails the wait (and a killed
process's edge expires by TTL), even though the poll itself does need the backend.
wait is guaranteed to terminate — the one invariant that matters for an event loop. A --timeout
(default 1200s) sets a deadline checked every poll, before every sleep, even after a thrown poll, so a
worker stuck in any non-actionable state (working, parked, idle, queued, starting) can never
hang the caller — it exits non-zero at the deadline. Actionable = WATCH_ACTIONABLE (which excludes
self-resuming parked, so a parked worker correctly does not end the wait), plus idle when --idle is
given. A vanished/closed target exits at once; a backend-down poll fails loud, never a false timeout.