Design Overview
tmux-rc — Design
This documents the why behind the architecture and the decisions that were weighed
and rejected. For how the code is laid out, read the source under tmux-rc/.
Guiding constraint: the terminal is the interface, not the agent
Every design choice flows from one decision: observe the terminal, not the agent.
We do not integrate with any harness’s internal protocols — not Codex’s, not Claude
Code’s, not Gemini CLI’s, not OpenCode’s. We read the rendered text of a tmux pane and
send keystrokes back. This is what makes
tmux-rc vendor-agnostic — a raw psql prompt and a Claude Code permission dialog
are the same kind of thing to us: text on a screen, possibly waiting for input.
The cost of this choice is that classification is inherently fuzzy — we’re parsing human-facing UI, not a structured API. We accept that and lean on heuristics plus a cheap LLM to absorb the fuzziness. The benefit is enormous reach with near-zero per-tool integration work: supporting a new agent is usually just adding a prompt pattern, sometimes nothing at all.
Why tmux (rejected: raw pty, screen, integrating each agent)
tmux is already how the target user runs long sessions, and it solves the two hard problems for free:
- Read access without disruption.
capture-panereturns the current screen contents for any pane at any time, from an unrelated process, without attaching or interfering. A raw pty wrapper would require us to sit in the I/O path and re-render the terminal ourselves — far more code and a source of bugs. - Bidirectional multiplexing. Multiple clients can attach to one tmux session
simultaneously, and
send-keysinjects input from outside. So the user can stay attached on their laptop while tmux-rc (and the phone) also watch and act on the same session — no client is exclusive. This is exactly the “both the app and a human connected at once” property we want, and tmux gives it natively.
Rejected alternatives: hand-rolling a pty multiplexer (reimplementing tmux, badly);
screen (weaker scripting surface than tmux’s capture-pane/list-panes);
integrating each agent’s own remote/IPC hooks (defeats vendor-agnosticism, N× work).
Why polling (rejected: tmux control-mode for the PoC)
The watcher polls capture-pane on a short interval (~1.5s) and diffs against the
previous capture. tmux does offer control-mode (tmux -CC) for push-based event
streams, which would be lower-latency and lower-overhead. We deferred it because:
- The timeline feature needs periodic snapshots anyway, so we’re sampling regardless.
- Polling is trivial to reason about and debug; control-mode parsing is a project of its own.
- ~1.5s latency is imperceptible for “is my agent done / blocked” — this is a glance-and-unblock tool, not a live terminal.
Control-mode is the natural post-PoC upgrade; nothing in the state model or the HTTP API changes when we swap the watcher’s input source.
Why heuristics first, LLM lazily (rejected: LLM on every tick)
Classification is layered deliberately:
- Heuristics (free, instant, always run). Tool detection from the running command and known banners; activity from tail patterns (a shell prompt at the bottom with unchanged text ⇒ idle; a known question pattern ⇒ waiting; otherwise running); Claude Code’s context% and cost from its predictable status line via regex.
- LLM (Gemini Flash Lite, lazy). Only invoked when heuristics can’t produce a clean one-line status and the pane actually changed, or when a prompt is detected but its options don’t parse cleanly. It returns structured JSON: a status phrase, an activity guess, and any question+options.
Running the LLM on every pane every tick was rejected on cost and latency: it would be constant spend and constant lag even when a pane is idle or unchanged, for output a regex already nails (a shell prompt, a Claude status line). The lazy design keeps the common case free and reserves the model for genuinely ambiguous screens — which is also where a model’s fuzziness-tolerance actually earns its keep.
Why Gemini Flash Lite via Vertex (rejected: Anthropic, local model)
- Flash Lite is cheap and fast, and Gemini models are strong at reading both terminal text and screenshots — which matters because the timeline gives us images essentially for free, and a multimodal model can read a rendered screen directly.
- Vertex because a Google Cloud project is already available and reaching it is
one client construction with
vertexai=True. (Auth has since moved from developer ADC to a long-lived service-account key so the unattended daemon doesn’t wedge on reauth — see design/durable-vertex-auth.md .) - Rejected Anthropic: the entire premise is not being locked to one provider, and the user’s inference already runs through Google. Rejected a local model (Ollama): slower and more variable at structured extraction for no PoC benefit when Vertex is a free API call away.
Crucially we reimplement the ~30-line Vertex call rather than importing the main app’s
backend.genai.llm. That library is excellent but pulls in FastAPI/SQLAlchemy/WorkOS
and a config-loading lifecycle we don’t want in a standalone tool. Copying the small,
stable slice (client construction, inline image parts, response_mime_type=json) keeps
tmux-rc independent and lets the watcher be rewritten in another language later.
Why Python (rejected: Go, C)
The tmux side is just subprocess calls — no hot loop, no pty wrangling, no low-latency requirement — so it’s trivial in any language, which means the language should be chosen for the rest of the system. Python wins there: FastAPI + the Vertex client are a few lines each, and it keeps the watcher small enough to port to Go later (for a single static binary) without touching the PWA. C was rejected outright — hand-rolling subprocess and HTTP for a glorified screen-scraper is pure downside.
State model and API shape
The unit is a PaneState: identity (pane/window/session), classified tool,
activity (running/idle/waiting) with idle_seconds, a short status_line, optional
context_pct, an optional question (prompt + options), a snapshot reference, and a
timestamp. The HTTP API is list-shaped from day one — GET /api/state returns a list
even in Milestone 1’s single-pane mode — so the jump to all-panes is a watcher change,
not an API or UI redesign. Answers post to /api/panes/{id}/send, which is a thin
wrapper over send-keys.
Build order
Milestone 1 proves the entire vertical slice on one pane: watch → classify →
phone card → detect waiting → tap → send-keys → snapshot timeline. Only once that
loop demonstrably works do we fan out to all panes in Milestone 2, which introduces no
new architecture. This ordering front-loads the risky, end-to-end integration (does
the answer round-trip actually advance a real agent’s prompt?) instead of the easy
breadth (more rows).
Security posture (PoC)
The service exposes read access to terminal contents and the ability to inject keystrokes — powerful and dangerous. For the PoC it relies entirely on network isolation: bind to the LAN, or front it with a tunnel the user controls. No auth, no TLS termination of our own. This is acceptable only because it’s a single-user dogfood tool on a trusted network; any real deployment must add authentication before exposure. This is called out as a non-goal, not an oversight.