# Delivery-into-session bridge — swarph daemon (design)
Goal: Stop drain-only cells from being silently DM-blind. When the swarph daemon drains a mesh DM, deliver it INTO the cell's live agent session (the resident model's context) — not just to a log no in-session model reads — so the model actually acts on it per CLAUDE.md DM SEMANTICS.
Origin: workstation-lc's find — its daemon drains + JSONL-logs DMs, but the running agent session never sees them, so nothing acts. lab-ovh papers over this with a Claude Code Monitor tail (harness-specific); every other daemon-class cell (workstation-lc, droplet, razorpeter, gpu-wsl, gemini-researcher) has no such bridge. This is the next pipeline item after the whatweknow read-side trial.
Architecture: swarph daemon (swarph-cli) already drains DMs from the mesh gateway to a transactional cursor + inbox.log, surface-only — _route_to_handler (commands/daemon.py) is a v0.5.0 stub that just prints "surfacing only." This feature wires that stub into the unbuilt REPL-drain path: after a DM is drained, the daemon injects it into its own cell's agent pane via the tmux/psmux multiplexer (swarph_cli/multiplexer.py), gated by a positive-idle probe ported from lab-orchestrator's proven workers/cell_wake.py. Push, not pull — harness-agnostic (claude/codex/grok) and cross-platform (Linux tmux + Windows psmux), which is why it covers workstation-lc.
Tech Stack: Python 3 (swarph-cli's floor), stdlib + the existing multiplexer/tmux-binary shell-out. No new runtime deps. Lives in swarph_cli/ behind the existing swarph daemon --auto-act opt-in.
Global Constraints
- Never crash the daemon. The daemon's loud-on-down liveness (
ps aux | grep '[s]warph daemon') is load-bearing; the bridge's failures are caught + logged, the daemon keeps draining. A bridge exception must never take the drain loop down. - Idle-probe is fail-safe toward "busy." Never inject into an ambiguous/busy/modal pane — defer. This is the modal-stall safety property (never send-keys into a numbered menu; text could read as a selection). Positive-idle confirmation only (footer sentinel present AND no busy-markers), exactly as
cell_wake._probe_idle. - Never lose a DM. A drained-but-undelivered DM stays queued (persisted beside the cursor) and is retried; the gateway cursor still advances (drain ≠ delivery — the two states are tracked separately) so a re-drain doesn't double-fetch, but an undelivered DM is never dropped.
- Quiet unless actionable. Actionable (wake on next idle) =
question,unblock, and anyanswercarrying a non-nullthread_id(a targeted reply — probably to this cell's own question). Ride-along (delivered in the next batch, never triggers its own wake) =fyi,status, andanswerwiththread_id=null(the ~80/day broadcast chatter, e.g. EOD fan-outs). The wake flag is computable from the message alone — no cell-side thread tracking in v1. - Opt-in. Active delivery ships behind the existing
--auto-actflag; surface-only stays the default for un-opted cells (no behavior change unless a cell is provisioned for delivery). - Cross-platform. All pane I/O goes through the multiplexer's
tmuxbinary (tmux on POSIX, psmux on Windows) — no OS-specific pane code.
Components
Each is a small, independently testable unit.
1. session_target — resolve the cell's agent pane
resolve_session_pane(self_name) -> str | None. Finds the tmux/psmux session/pane hosting THIS cell's resident agent (the session swarph spawn launched). Uses the multiplexer's detection + a naming convention (the cell's own session name). Returns the pane target string, or None when there is no resident session (a truly headless drain-only cell) → the daemon stays surface-only for that cell and logs no resident pane once. No resident pane is a valid state, not an error.
2. idle_probe — the safety gate
probe_pane(pane) -> "idle" | "busy" | "modal". Ports cell_wake's positive-idle detection: capture-pane -p the target, require the idle footer sentinel ("? for shortcuts") AND none of the busy-markers; a numbered menu / dialog → modal; anything else → busy. Fail-safe: any capture error or ambiguity → busy (defer, never inject).
3. pane_inject — deliver into the pane
inject(pane, text) -> bool. send-keys the rendered delivery block into the pane, then submit (Enter). Cross-platform via the multiplexer tmux binary. Returns success; any failure → False (caught by the caller, DM stays queued). Uses literal/-l send-keys semantics so DM content can't be misread as tmux key names.
4. delivery_queue — pending-DM state
Per-cell queue of drained-but-not-yet-delivered DMs, persisted as a single-row JSON file beside the daemon cursor (write-and-rename atomic, same discipline as the cursor) so it survives a daemon restart. Each entry: {id, from, kind, thread_id, content, wake: bool, deferred_ticks: int}. wake is computed at enqueue by the actionable rule (see Global Constraints). Dedups by message id (a re-drain never re-queues a delivered id).
5. stall_alert — surface a stuck cell
When a cell never goes idle, DMs defer. After deferred_ticks crosses a threshold, send ONE commander DM via the mesh gateway, with exponential backoff (alert at 6, 12, 24, 48… — doubling, ~log2 total not linear), resetting the counter on successful delivery. Reuses the exact pattern from the science-claude 63h-stall / 145-DM-flood fix (feedback_modal_stalls_cell_wake) so a stall surfaces itself instead of rotting — and never floods.
6. Drain-loop wiring
Replace the_route_to_handler stub in commands/daemon.py. On each drained DM: queue.enqueue(dm). Each tick, after draining: attempt delivery of the queue (below). Guarded so no bridge exception escapes into the drain loop.
Data Flow
`
gateway DM → daemon drain (cursor advances) → queue.enqueue(dm, wake = kind ∈ {question, unblock} OR (kind==answer AND thread_id != null))
→ each tick, attempt_delivery():
resolve pane
none → surface-only (log once), leave queued as informational (no wake ever) — headless cell
pane → probe_pane
idle → render batch → pane_inject(send-keys + submit)
ok → dequeue delivered ids, reset stall counter
fail → leave queued, log, retry next tick
busy → defer (stay queued), deferred_ticks++ → stall_alert at 6,12,24…
modal → dismiss-safe-modal (cell_wake._try_dismiss_safe_modal), re-probe once; else defer
`
Batch semantics: on an idle wake, deliver ALL currently-queued DMs in one injected block (actionable + any fyi/status riding along), so low-priority chatter never triggers its own wake but is never lost.
Injected block the resident model reads:
`
📨 mesh delivery (2 new):
· from=droplet kind=question: `
Error Handling
- Pane capture / send-keys failure → caught, logged, DM stays queued, retry next tick. Daemon unaffected.
- No resident pane → surface-only for that cell, logged once (not per-tick spam). Not an error.
- Modal on the pane → attempt the scoped safe-modal dismiss + one re-probe; still not idle → defer (never force).
- Queue-file corruption / unreadable → treat as empty, log, continue draining (fail-safe; a lost queue file means at worst a re-surface, and the gateway still holds unread state).
- Stall-alert gateway POST failure → logged, counter unchanged (retries at the next threshold); never blocks delivery attempts.
- Any uncaught bridge exception → caught at the drain-loop boundary, logged loudly to stderr; the daemon keeps running (liveness intact).
Testing
idle_probe: modal-menu fixture →busy/modal(never idle); clean footer fixture →idle; capture error →busy(fail-safe).pane_inject: builds the correctsend-keys -l … Enterargv (mock thetmuxbinary); returnsFalseon a non-zero binary exit.delivery_queue: enqueue/dequeue, persists across reload (write-and-rename), dedups by id,wakeflag set from kind.stall_alert: fires at 6/12/24 (backoff sequence, not linear), resets on delivery, single alert per threshold (no flood).- kind policy:
question/unblock→wake=true;answerwith non-nullthread_id→wake=true;answerwiththread_id=null,fyi,status→wake=falsebut still delivered in the next batch (ride-along, never dropped). - Integration: spin a real throwaway tmux session, enqueue a DM, run one delivery tick → assert the injected block lands in the pane (
capture-pane); a "busy" fixture pane → asserts defer + stall counter increments + backoff alert. - No-pane path:
resolve_session_pane→None→ surface-only, no inject attempted, one log line.
Build Order
1. idle_probe (port + adapt cell_wake._probe_idle / safe-modal-dismiss into swarph-cli) + tests.
2. pane_inject (multiplexer send-keys) + tests.
3. session_target resolver + tests.
4. delivery_queue (persisted, dedup, wake-flag) + tests.
5. stall_alert (backoff + reset) + tests.
6. Wire the drain loop (_route_to_handler → enqueue + attempt_delivery), guarded; integration test end-to-end on a real tmux session; version bump.
Plan ends at green tests + the bridge wired behind --auto-act + an end-to-end delivery proven on a real pane. Validation rollout (post-merge, not part of the build): workstation-lc (the finder) + lab-ovh first, then droplet / razorpeter / gpu-wsl / gemini-researcher.
Open / Risks
- Pane-resolution convention — the one real correctness risk (mirrors whatweknow's path-format risk): the daemon must reliably find ITS cell's agent pane across cells that may name sessions differently. A build step verifies resolution against a real live session before wiring. If a cell's session naming is non-standard, it falls back to surface-only (safe) rather than injecting into the wrong pane.
- Busy-cell latency — a genuinely hot cell delivers late (on next idle); accepted, and the backoff-alert bounds how long a stall stays silent. Force-deliver-at-turn-end (Stop hook) was considered and rejected for v1 (Claude-Code-specific; nudges the next turn).
- Injection nudges a turn — waking an idle cell submits a turn; that's the intended behavior for actionable kinds. fyi/status never wake on their own (ride-along only), bounding the churn.
- Cross-agent panes — codex/grok panes have different idle sentinels than claude; v1 targets the claude membrane (the daemon-class cells above are claude); the probe's sentinel set is a per-agent config point for later.