Skip to content

feat(bridge): wake idle sessions via tmux or zellij (#122) - #149

Open
evansenter wants to merge 2 commits into
mainfrom
feat/mux-wake-backend
Open

feat(bridge): wake idle sessions via tmux or zellij (#122)#149
evansenter wants to merge 2 commits into
mainfrom
feat/mux-wake-backend

Conversation

@evansenter

@evansenter evansenter commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Closes the last gap in RFC #122: the bridge can now actually wake an idle session.

Why nothing woke before

Three independent reasons, recorded in full on #122. Each was sufficient on its own:

  1. com.evansenter.agent-event-bus-bridge.plist pinned AGENT_EVENT_BUS_BRIDGE_BACKEND=spool, and only the tmux backend injects.
  2. Nothing anywhere wrote wake/panes.json — every reference in this repo is a reader, a test fixture, or prose, and dotfiles had none at all.
  3. The bus host runs zellij, not tmux. $TMUX_PANE is unset, no tmux server runs, and hooks/tmux-status.sh isn't even wired into settings.json.

(3) is what makes a literal reading of the brief insufficient, and it's the interesting one. A writer built exactly to the documented contract — which correctly says to omit the entry when $TMUX_PANE is unset — would have omitted every entry on this host: a syntactically perfect, permanently empty mapping. It would have failed silently, because an omitted entry is the documented quiet-absent path that deliberately doesn't warn.

So #135/#136/#139/#141 were all scaffolding for a mechanism that had never been exercised.

Value shape

panes.json values become objects:

{
  "9f3c…": {"mux": "tmux",   "pane": "%3"},
  "1be4…": {"mux": "zellij", "pane": "0", "session": "tenacious-lemur"}
}

A delimited string ("zellij:<session>:<pane>") was rejected on evidence: zellij session names are auto-generated per session and may contain : — verified by creating and successfully targeting a session literally named has:colon — so the encoding can't be split unambiguously. A bare string value still means tmux.

zellij needs two calls, which changes a timeout

write-chars types without submitting and zellij has no combined form, so the carriage return is a second write 13. Dropping it would leave the prompt sitting in the input box — a wake that wakes nobody while the action, the log line, and the bus response all report success.

That made MUX_TIMEOUT a total budget spent against a monotonic deadline rather than a per-call bound. A per-call bound would put spool_deadline + injection at 6s against the bus's 5s webhook timeout — precisely the timeout-plus-retry (and duplicate spool line) the constant exists to prevent. The existing invariant test keeps passing unchanged.

The startup preflight now requires some supported multiplexer rather than a specific one: which one a delivery needs is a property of that session's entry, not of the daemon, so a tmux-only host isn't refused for lacking zellij.

Idle detection — RFC open question 1

Injection happens only between turns, keyed on a <sid>.busy marker written by session hooks.

The argument is that gating costs zero coverage: while a session is busy, injection is redundant (drain-directed-events.sh already surfaces directed events at the next Stop) and the busy window is exactly when a permission dialog can be on screen for injected text plus a newline to answer something nobody saw. So the gate removes the dangerous case and loses nothing.

No TTL on the marker, deliberately. It outlives its session only when SessionEnd never ran (hard kill, crash, reboot) — and then the session is gone, so declining to inject is correct, not a bug; the alternative is typing into whatever now owns the pane. A resumed session clears it at SessionStart; a timed-out Stop hook's orphan is cleared by the next turn's Stop. Both self-heal, so a TTL would only open a mid-turn injection window on long turns.

Not solved, and documented rather than papered over:

  • A human mid-typing at an idle prompt gets their draft submitted with the wake text appended. Unsolvable without TUI introspection. Pane scraping (capture-pane/dump-screen) was considered and rejected as version-fragile guessing about host behaviour.
  • The Stop-block continuation window reads idle while working. Benign — the TUI queues typed text.
  • A mux-less host cannot wake an idle session at all.

No spool-drain hook

Skeptical as instructed, and it doesn't survive. The gate makes it airtight: a busy session's events are surfaced from the bus at Stop, an idle session's are injected — neither path has a hole a drain would fill, and the spool is a subset of what the bus already holds.

The "portable fallback" framing fails too: a Stop hook cannot fire on an already-idle session, so on a mux-less host nothing hook-shaped can wake anything, and a drain doesn't change that. That's a real gap; it just isn't one a drain closes.

Why the writer is in the CLI

agent-event-bus-cli panes set/clear and wake-state, rather than shell in the hook:

  1. macOS ships no flock(1) (util-linux's is Linux-only). The mkdir-lock alternative needs stale-holder recovery whose failure mode — deadlock on a crashed holder — is worse than the race.
  2. The contract is specified here and read by this repo's bridge. Both sides now run the same wake.parse_target, so a drift fails a test here instead of silently producing wakes that never happen — which is the only symptom a broken writer has.

Plist default

Flips to the injecting backend. The consent gate is panes.json, not this setting: with no writer installed, every delivery still resolves to spool-unmapped and the unit behaves exactly as before. So the default costs an operator who hasn't opted in nothing, while removing a second install-time step that's easy to forget and silent when missed — the exact condition that left this inert across four merged PRs.

Verified here

Writer → file → bridge reader → argv → real zellij pane, end to end on the bus host: the entry round-trips and the prompt lands and submits. Injection also works from a launchd-like environment (env -i, no ZELLIJ* vars, no TTY), and addresses a non-focused pane in a background session across tabs.

The dry run also surfaced a fidelity finding worth recording: injecting into an interactive zsh pane drops exactly one space per wake (Check theevent bus), reproducibly — but the same string into cat in the same pane arrives byte-exact three times out of three. So write-chars is faithful and the lost keystroke is the shell's line editor. That relocates the risk: a garbled wake in a shell pane is the stale-mapping symptom, not a bridge fault.

718 tests pass (+67), lint and format clean.

Not verified from here

Per the verification-boundaries rule in #148:

  • An actual Claude Code session waking. Needs a live session in a mapped pane.
  • make install-bridge picking up the new backend, and the supervised bridge reading a real panes.json.
  • Injection fidelity into Claude Code's TUI specifically — it is not zsh ZLE, so the space-drop above says nothing either way about it. Worth a look on the first real wake; the failure would be cosmetic.

Pairs with evansenter/dotfiles#338, which installs the writer.

🤖 Generated with Claude Code

evansenter and others added 2 commits August 10, 2026 19:25
Records why the wake path is inert (plist pinned to spool, no panes.json
writer, and the bus host running zellij rather than tmux), and the
decisions that follow: object-shaped panes.json values, a zellij
injection path, the turn-state idle gate, and the CLI-side writer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RFC #122's wake path has never run end-to-end. Three things kept it inert,
each sufficient alone: the plist pinned the spool backend, nothing wrote
wake/panes.json, and the bus host runs zellij rather than tmux - so a writer
built exactly to the documented contract would have correctly OMITTED every
entry and produced a permanently empty mapping. Recorded on the issue.

The mapping value becomes an object ({"mux", "pane", "session"}) so it can
name a multiplexer. A delimited string was rejected on evidence: zellij
accepts ':' in session names (verified against a live session named
"has:colon"), so "zellij:<session>:<pane>" cannot be split unambiguously. A
bare string still means tmux.

zellij needs two calls - write-chars types without submitting, so the
carriage return is a separate `write 13`. That makes MUX_TIMEOUT a total
budget spent against a monotonic deadline rather than a per-call bound; a
per-call bound would put spool_deadline + injection at 6s, over the bus's 5s
webhook timeout, which is the timeout-plus-duplicate-spool this constant
exists to prevent. The preflight now requires SOME supported mux, not all of
them - which one a delivery needs is a property of the entry, not the daemon.

Idle gate (RFC open question 1): injection happens only between turns, keyed
on a <sid>.busy marker. It costs no coverage - mid-turn a Stop hook already
surfaces directed events - and it keeps keystrokes out of the one window
where a permission dialog can consume them. No TTL, deliberately: a marker
outlives its session only when SessionEnd never ran, and then declining to
inject is correct rather than a bug. The residual failure modes (a human
mid-typing, the Stop-block continuation window, a mux-less host) are
documented rather than papered over.

The writer lives in the CLI (`panes set/clear`, `wake-state`) rather than in
the shell hook, for two reasons: macOS ships no flock(1), and putting writer
and reader in one repo behind one validator (wake.py's parse_target) means a
drift between them fails a test here instead of silently producing wakes that
never happen.

The plist flips to the injecting backend. The consent gate is panes.json, not
that setting - with no writer installed every delivery still resolves to
spool-unmapped, so the default costs an operator who has not opted in nothing
while removing a second install-time step that is silent when missed.

Dry-run verified on the bus host, writer through to a real pane: the entry
round-trips and the prompt lands and submits. It also surfaced a fidelity
finding - injecting into an interactive zsh drops exactly one space per wake,
while the same string into `cat` arrives byte-exact, so write-chars is
faithful and the loss is the shell's line editor. Documented; the equivalent
question for Claude Code's TUI is unverified from here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant