Summary
The Spacedock-repo half of "multi-session control" — completing the Bridge↔Spacedock seam so captain intent (conn/tell) routes to a specific workflow's FO (or broadcasts) and Bridge can show per-workflow FO liveness when several spacedock claude sessions run concurrently from one repo root.
The Bridge half is already merged (it writes a target on each inbox record, reads per-workflow heartbeat files, and shows a per-workflow roster). This issue is the Spacedock side: teach the bridge-inbox drain mod to honor target with a per-workflow cursor, and write the per-workflow FO heartbeat Bridge reads.
Mirrors Linear DRC-3732 (and pairs with PR #435 / the bridge-seam-inbox-events branch).
Contract Bridge already emits/expects (must match exactly)
- Inbox
«FO-cwd»/_bridge/inbox.jsonl (append-only, Bridge writes): {"ts","kind":"tell"|"conn","text","granted"?,"target":"<workflow-slug>"|"all"}. target absent ⇒ all.
- Per-workflow cursor
«FO-cwd»/_bridge/.inbox-cursor.<slug> — each FO advances only its own.
- Heartbeat
«FO-cwd»/_bridge/fo.<slug>.json (FO writes, Bridge reads): {"session_id","ts":"<rfc3339>","state":"idle"}. Bridge keys liveness on ts freshness (age ∈ [0, 30m]; a future-dated ts is rejected → honest not-attached) and defaults empty state to idle.
- Routing key
<slug> = the FO's --workflow-dir basename.
Changes (in docs/dev/_mods/bridge-inbox.md)
- Update the inbox-record schema block to include
target (currently omitted).
- Target-filtered drain: read records since
_bridge/.inbox-cursor.<myslug>; act on only those where target == myslug || target == "all" (absent ⇒ all); advance the per-workflow cursor. Malformed/unknown records are skipped but still advance the cursor — never block the loop. Concurrent FOs never race (each owns its cursor).
- Cursor migration (one-time): on first run, if
.inbox-cursor.<myslug> is absent but the old shared .inbox-cursor exists, seed the per-slug cursor from it — do not start at 0, or the FO re-drains the whole inbox and replays old conn grants.
- FO heartbeat: on boot and each idle tick, write
_bridge/fo.<myslug>.json with a present-time RFC3339 UTC ts, state:"idle" (the mod runs at startup/idle boundaries — it cannot honestly assert working), and session_id from $CLAUDE_CODE_SESSION_ID. Observe-only; never block the loop.
- Slug safety:
myslug = basename {workflow_dir}; refuse a slug that is not a plain filename component (contains /, .., whitespace, or empty) before constructing any _bridge/*.<myslug> path.
The mod stays workflow-scoped (copied manually into other workflows' _mods/, same as today); promoting it to a built-in is a later follow-up.
Acceptance criteria
- FO for X drains
target:"X" and target:"all"/absent, but NOT target:"Y".
- Two concurrent FOs never clobber — each advances only
.inbox-cursor.<its-slug>.
- First run seeds
.inbox-cursor.<slug> from the old shared cursor when present (no re-drain).
- The FO writes/refreshes
fo.<slug>.json (present-time ts, state:"idle") on boot + each idle tick.
- A malformed record or unsafe slug is skipped/refused without blocking the loop.
Honest-degradation note
A session idle >30m stops ticking, so its heartbeat goes stale and Bridge shows not-attached though the process is alive. Intended (Bridge degrades rather than fabricate freshness), not a bug.
Summary
The Spacedock-repo half of "multi-session control" — completing the Bridge↔Spacedock seam so captain intent (conn/tell) routes to a specific workflow's FO (or broadcasts) and Bridge can show per-workflow FO liveness when several
spacedock claudesessions run concurrently from one repo root.The Bridge half is already merged (it writes a
targeton each inbox record, reads per-workflow heartbeat files, and shows a per-workflow roster). This issue is the Spacedock side: teach thebridge-inboxdrain mod to honortargetwith a per-workflow cursor, and write the per-workflow FO heartbeat Bridge reads.Mirrors Linear DRC-3732 (and pairs with PR #435 / the
bridge-seam-inbox-eventsbranch).Contract Bridge already emits/expects (must match exactly)
«FO-cwd»/_bridge/inbox.jsonl(append-only, Bridge writes):{"ts","kind":"tell"|"conn","text","granted"?,"target":"<workflow-slug>"|"all"}.targetabsent ⇒all.«FO-cwd»/_bridge/.inbox-cursor.<slug>— each FO advances only its own.«FO-cwd»/_bridge/fo.<slug>.json(FO writes, Bridge reads):{"session_id","ts":"<rfc3339>","state":"idle"}. Bridge keys liveness ontsfreshness (age ∈ [0, 30m]; a future-datedtsis rejected → honest not-attached) and defaults emptystatetoidle.<slug>= the FO's--workflow-dirbasename.Changes (in
docs/dev/_mods/bridge-inbox.md)target(currently omitted)._bridge/.inbox-cursor.<myslug>; act on only those wheretarget == myslug || target == "all"(absent ⇒all); advance the per-workflow cursor. Malformed/unknown records are skipped but still advance the cursor — never block the loop. Concurrent FOs never race (each owns its cursor)..inbox-cursor.<myslug>is absent but the old shared.inbox-cursorexists, seed the per-slug cursor from it — do not start at 0, or the FO re-drains the whole inbox and replays oldconngrants._bridge/fo.<myslug>.jsonwith a present-time RFC3339 UTCts,state:"idle"(the mod runs at startup/idle boundaries — it cannot honestly assertworking), andsession_idfrom$CLAUDE_CODE_SESSION_ID. Observe-only; never block the loop.myslug = basename {workflow_dir}; refuse a slug that is not a plain filename component (contains/,.., whitespace, or empty) before constructing any_bridge/*.<myslug>path.The mod stays workflow-scoped (copied manually into other workflows'
_mods/, same as today); promoting it to a built-in is a later follow-up.Acceptance criteria
target:"X"andtarget:"all"/absent, but NOTtarget:"Y"..inbox-cursor.<its-slug>..inbox-cursor.<slug>from the old shared cursor when present (no re-drain).fo.<slug>.json(present-timets,state:"idle") on boot + each idle tick.Honest-degradation note
A session idle >30m stops ticking, so its heartbeat goes stale and Bridge shows not-attached though the process is alive. Intended (Bridge degrades rather than fabricate freshness), not a bug.