feat(bridge): wake idle sessions via tmux or zellij (#122) - #149
Open
evansenter wants to merge 2 commits into
Open
feat(bridge): wake idle sessions via tmux or zellij (#122)#149evansenter wants to merge 2 commits into
evansenter wants to merge 2 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
com.evansenter.agent-event-bus-bridge.plistpinnedAGENT_EVENT_BUS_BRIDGE_BACKEND=spool, and only the tmux backend injects.wake/panes.json— every reference in this repo is a reader, a test fixture, or prose, and dotfiles had none at all.$TMUX_PANEis unset, no tmux server runs, andhooks/tmux-status.shisn't even wired intosettings.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_PANEis 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/#141were all scaffolding for a mechanism that had never been exercised.Value shape
panes.jsonvalues 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 namedhas: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-charstypes without submitting and zellij has no combined form, so the carriage return is a secondwrite 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_TIMEOUTa total budget spent against a monotonic deadline rather than a per-call bound. A per-call bound would putspool_deadline + injectionat 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>.busymarker written by session hooks.The argument is that gating costs zero coverage: while a session is busy, injection is redundant (
drain-directed-events.shalready 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:
capture-pane/dump-screen) was considered and rejected as version-fragile guessing about host behaviour.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/clearandwake-state, rather than shell in the hook:flock(1)(util-linux's is Linux-only). Themkdir-lock alternative needs stale-holder recovery whose failure mode — deadlock on a crashed holder — is worse than the race.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 tospool-unmappedand 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, noZELLIJ*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 intocatin the same pane arrives byte-exact three times out of three. Sowrite-charsis 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:
make install-bridgepicking up the new backend, and the supervised bridge reading a realpanes.json.Pairs with evansenter/dotfiles#338, which installs the writer.
🤖 Generated with Claude Code