Brainstorm: tmux-backed observable/interactive harness sessions
Root artifact for issue #32. The issue is explicitly exploratory ("what will be the architecture? what's the role of tmux? what are the alternatives?"), so the loop starts here. Iterate with feedback; once locked, derive
requirements.md.
Problem / opportunity
When the webhook receiver auto-executes a work item (issue-15), it spawns the harness as a headless subprocess — claude -p <prompt> --output-format json (or cursor-agent -p …) via Dispatcher._spawn_for → HarnessAdapter.spawn. The session runs to completion invisibly; the only trace is the receiver's log line and whatever the agent pushes to GitHub. A human who wants to watch the agent work, or step in and steer it mid-run, has no way to do so — the process owns no terminal.
The ask: spawn the harness inside something attachable (tmux being the candidate) so anyone with access to the host — locally, over SSH, or through a web terminal — can observe and interact with the live session.
Context & constraints
- Current spawn/resume model (issue-15, decision-016): one shot, non-interactive.
spawn=claude -p <prompt> --output-format json; the JSON output yields thesession_idstored in the registry (.the-loop/sessions/<slug>.json). Each later webhook event is a new subprocess:claude -p <event-prompt> --resume <session-id>. Between events, no process exists — a "session" is registry metadata + harness conversation state on disk. - Per-session FIFO dispatch: the dispatcher already serializes events per work item (one worker thread per session), so "one thing talks to the session at a time" is an established invariant a design must preserve or consciously relax.
- Zero-runtime-dependency guarantee (decision-005/016): the CLI subprocess-drives the official harness CLIs; no SDK, no daemons beyond the receiver itself. tmux and ttyd are native host binaries — a Python wheel cannot carry or
pip installthem — so, like theclaude/cursor-agentCLIs today, they are host tools. Installability requirement (owner, PR #33 review, 2026-07-17): installing the-loop must ensure these dependencies are satisfied — at minimum verified with actionable, per-platform install guidance when the modes that need them are enabled. - Both harnesses matter: whatever the mechanism, it should treat
claudeandcursor-agentuniformly through theHarnessAdaptercontract (R4 of issue-15). - Access control is environmental, not a the-loop feature (owner assumption, PR #33 review, 2026-07-17): an attachable session is a keyboard into an agent with repo write access, but the-loop builds no auth of its own. On a local laptop nothing is needed (bind to localhost); on a remote host the network layer is assumed to protect access — VPN, private network, or the hosting provider's controls. The design's obligation is only to bind sensibly by default (e.g. any web terminal listens on 127.0.0.1 or the VPN interface unless explicitly told otherwise).
The core tension: headless dispatch vs. interactive terminal
The current model and the wish are at odds in one specific place:
- Today's resume path needs a non-interactive process it can feed a prompt and collect JSON from.
- A human needs an interactive TUI (
claudefull-screen mode) attached to a PTY. - The same harness conversation cannot safely be driven by both at once — an interactive
claude --resume <id>and a concurrentclaude -p --resume <id>race on the same conversation state, and the dispatcher's one-at-a-time invariant is exactly the thing that would break.
Every option below is a different resolution of this tension.
Ideas & options
Option A — tmux hosts the interactive session; events are injected via send-keys ✅ leaning
The dispatcher spawns the harness interactively inside a named tmux session (tmux new-session -d -s loop/<work-item-slug> -- claude …) instead of a one-shot -p process. The tmux session is the running agent; webhook events are delivered by typing into it programmatically:
tmux load-buffer -b loop-evt <prompt-file>
tmux paste-buffer -p -b loop-evt -t loop/<slug> # -p = bracketed paste
tmux send-keys -t loop/<slug> EnterHumans attach with tmux attach -t loop/<slug> (read-write) or tmux attach -r (read-only) and see exactly what the agent sees — including events arriving as pasted prompts — and can type their own steering messages between turns.
- Pros: full observe and interact, both harness TUIs work unchanged, session survives independent of the receiver process (tmux server owns it), discovery is
tmux ls, works for all three access modes (below). - Cons / risks:
send-keysis lossy by design: if the TUI is mid-turn, showing a permission dialog, or the human is mid-keystroke, injected text can interleave or be swallowed. Mitigations exist (bracketed paste; only inject when the pane's foreground command is idle via#{pane_current_command}; queue in the dispatcher — which already serializes), but "reliably typing into a TUI" is inherently best-effort.- Session-id capture changes: interactive mode prints no JSON. Need
$CLAUDE_SESSION_IDvia a SessionStart hook,--session-id <uuid>(pre-chosen id), or parsing; per-harness capability audit required (cursor-agent equivalents). - Completion detection changes: today
subprocess.runreturning = turn done. An interactive session never exits; the dispatcher loses its natural "resume finished" signal (affectsdispatchTimeoutSeconds, semaphore release). Options: fire-and-forget withpane_current_commandpolling, harness Stop-hooks touching a sentinel file, or dropping per-event completion tracking in tmux mode.
Option B — keep headless dispatch; tmux is a viewer (observe-only)
Spawn/resume stay exactly as today, but each dispatch runs inside a tmux window (tmux new-window -t loop/<slug> -- claude -p … --output-format stream-json --verbose) so the streaming output is watchable live, and the window's scrollback is the session history. Humans observe in real time; to interact they open their own claude --resume <session-id> between events (the registry has the id), or just comment on GitHub — which is already the loop's steering channel.
- Pros: near-zero change to dispatch semantics (JSON capture, completion, timeouts all keep working); no injection fragility; the "interact" story reuses channels that already exist (GitHub comments; manual
--resumebetween turns). - Cons: not the full ask — you can't type into the running turn;
stream-jsonoutput is developer-grade, not the friendly TUI; two humans racing--resumeagainst a webhook event re-introduces the concurrency race (needs athe-loop sessions attachhelper that pauses dispatch, or documented discipline).
Option C — runner abstraction: process (today) | tmux, chosen in config
Not a third mechanism but the packaging of A and/or B: introduce a SessionRunner seam next to HarnessAdapter (routing.runner: process | tmux, default process), registry entries gain runner + tmuxTarget, and tmux mode is opt-in. Preserves decision-005 (tmux absent → clean error or fallback to process).
Rejected / parked alternatives to tmux
- GNU screen — same detach/attach model, weaker scripting surface (
stuffvs. send-keys/paste-buffer, no format strings for idle-detection). tmux is strictly better where both are installable. ✗ - dtach / abduco — minimal detach/attach, no windowing, no programmatic input API worth relying on, no read-only attach. Too little. ✗
- Zellij — modern, nice, but far less ubiquitous, and its automation surface (
zellij action write-chars) is younger than tmux's. Revisit if tmux proves limiting. ✗ - ttyd / GoTTY / wetty (web terminal servers) — not alternatives; the complement. They serve a command's PTY over HTTP(S); serving
tmux attach -t …is precisely the "user with a URL" mode. Parked as an optional layer on top of tmux; access control is assumed environmental (localhost / VPN / provider network — see constraints), so the layer only needs sensible default binding, not its own auth. ○ - Harness-native sharing (claude.ai/code remote sessions, Cursor background agents' web UI) — solves observe/interact but only on the vendor's hosted surface; issue #32 is explicitly about the self-hosted receiver (same reasoning as decision-016). ✗
- asciinema-style streaming — observe-only broadcast, no interaction. Subsumed by tmux read-only attach. ✗
Sketches & notes
flowchart LR
GH[GitHub webhook] --> RX[gh-webhook receiver]
RX --> D[Dispatcher<br/>per-session FIFO]
D -- "runner=process (today)" --> P["claude -p … --resume<br/>(headless subprocess)"]
D -- "runner=tmux (new)" --> T["tmux server<br/>session loop/<slug>"]
T --> H["claude / cursor-agent<br/>interactive TUI in PTY"]
U1[laptop terminal] -- "tmux attach" --> T
U2[ssh user] -- "ssh host + tmux attach" --> T
U3[browser] -- "http(s) → ttyd (VPN/localhost) → tmux attach" --> TUser-interaction pattern (Option A): the human is a co-pilot on the same keyboard — the-loop sessions list (gains a TMUX column) → tmux attach -t loop/<slug> → watch turns stream; type guidance or answer the TUI's permission prompts inline; detach with C-b d and the agent continues autonomously. Webhook events keep arriving in the same pane, visibly.
Does it work in the three access modes?
| Mode | Path | Verdict |
|---|---|---|
| Local (receiver on laptop) | any terminal → tmux attach -t loop/<slug> (same user's tmux server) | ✅ native |
| SSH into remote host | ssh host, then attach; this is tmux's canonical use-case; read-only attach for spectators | ✅ native |
| Web terminal (user has a URL) | ttyd/GoTTY on the host serving tmux attach (or a picker script); access control assumed environmental (localhost / VPN / provider network) | ✅ workable; optional layer, not v1-default |
All three converge on "get a shell as the right user, attach to the tmux socket" — which is exactly why tmux (a user-level daemon owning the PTYs) is the right substrate and a plain child process is not.
Open questions
- Interaction fidelity (product):
is observe-only with attach-between-turns (Option B) enough for v1, or is typing into the live TUI (Option A) the actual bar?→ Resolved: Option A (owner, PR #33 review, 2026-07-17). Typing into the live TUI is the bar; the architecture is A-semantics behind the Option Crunnerseam. - Event injection reliability (Option A): is best-effort
send-keys(with idle-detection + the dispatcher's existing serialization) acceptable for webhook delivery, or must event delivery remain the crash-safe headless path? A hybrid — interactive TUI in tmux plus headless-p --resumeonly when the pane is idle — may be the pragmatic middle. With Option A chosen, this becomes the central design-phase decision (likely resolved by a small spike against both harness TUIs). Carried forward open (owner locked the brainstorm and advanced, 2026-07-17): to be decided indesign.md. - Session-id + completion signals per harness: confirm
claude --session-id/ SessionStart-hook capture and the cursor-agent equivalents; without an id the registry can't route follow-up events. - Web mode scope → Resolved: in scope, shipped (owner, PR #33 review, 2026-07-17). Two rulings landed here:
- Auth: access control is environmental — VPN / hosting provider network for remote hosts, nothing needed on a local laptop; the-loop ships no auth of its own, only sensible default binding.
- Installability: "if one installs the-loop, the dependency of ttyd is satisfied." The web layer is therefore a first-class capability, not a documented recipe. Since ttyd (like tmux) is a native binary a Python wheel cannot carry, the candidate satisfaction mechanisms are: (a) preflight/doctor checks —
the-loop initandgh-webhook startverifytmux/ttydon PATH when the modes needing them are enabled, failing with per-platform install guidance (brew install ttyd,apt install ttyd, …), the same pattern asHarnessAdapter.is_available()— the recommended baseline; (b) auto-download of ttyd's static release binary into a the-loop-managed dir — parked as a design-phase enhancement (supply-chain and update burden); (c) auto-install via the system package manager — rejected (sudo, unpredictable across platforms). - What web mode is: a web-terminal server (ttyd) on the same host serving
tmux attach -t loop/<slug>as a browser page — opening the URL gives a live terminal on the agent's PTY, roughlyttyd --writable tmux attach -t loop/<slug>bound to localhost or the VPN interface.
- Mixed fleets:
may→ Resolved: receiver-global (owner, PR #33 review, 2026-07-17). One setting (runnerdiffer per work item (overrides), or is it receiver-global?routing.runner) in.the-loop/config.yaml; every spawned session uses the same runner, and flipping it needs no migration since it only affects newly spawned sessions. Per-work-item selection (front-matteroverridesor a label) stays a possible later extension at zero migration cost — the registry recordsrunner/tmuxTargetper session anyway, so the dispatcher handles a mixed fleet regardless.
Leaning / working hypothesis
Option C packaging with A semantics — confirmed by the owner's Option-A call on question 1 — staged: introduce a receiver-global routing.runner (process default, tmux opt-in; question 5 resolved); tmux mode spawns the harness TUI in loop/<work-item-slug>, captures the session id via pre-assigned id/hook, injects events via bracketed-paste + idle-check (keeping the dispatcher's FIFO as the safety layer), and registers tmuxTarget in the session registry. Local + SSH attach are v1; the web terminal ships too (question 4 resolved) riding on the environmental-access-control assumption (localhost / VPN / provider network — no auth built by the-loop), with tmux/ttyd dependency verification at init/receiver-start (per-platform install guidance; static-binary auto-download as a candidate enhancement). Option B's stream-view remains the documented fallback if the question-2 spike finds injection too fragile.
Hand-off → requirements
Carries forward once locked: the receiver-global runner config seam, tmux session naming + registry fields, event-injection contract (or the B fallback), per-harness id/completion capture, sessions list/attach UX, the shipped web-terminal layer with the environmental-access-control assumption, and the installability requirement (verify tmux/ttyd with per-platform guidance; static-binary auto-download as a candidate enhancement). Rejected alternatives (screen, dtach, Zellij, asciinema, vendor-hosted) and rejected dependency mechanisms (system-package auto-install) stay here as the record.