Summary
Driving an interactive CLI (Claude Code) inside a tmux pane through the terminal / tmux Operator connector is unreliable: the write tools are fire-and-forget. There is no check that the pane is ready to accept input before we send, and no confirmation that the input landed after we send. The model only discovers a dropped keystroke by re-capturing and reasoning about it — and if it declares success first, the failure is silent.
Live incident (dogfooding, 2026-08-10)
Instance Heartfull (tmux) (cda75e28-…), trace f04b6224-1569-4153-9faf-14d73d3af8ca:
07:15:45 — agent runs tmux_new_session (command claude) and immediately reports "Claude Code is up and ready, running on Opus 5." Claude Code had not finished painting its TUI.
07:16:29 — on the next turn it captures the pane and sees serge-ivo@Mac platform % claude — i.e. still a shell prompt, not Claude's input box — then tmux_send_keys the user's message into it. It goes nowhere.
- Agent's own words: "The pane hasn't changed, which suggests the instruction didn't land in the prompt. Let me try sending it again properly."
The retry only happened because the user typed "Check again." There is no automatic recovery.
Root cause — the write path has no grounding, while the read path does
The read path is already carefully grounded:
workers/api/src/lib/terminal-label.ts:81-95 labels every captured pane as live / idle-stale / unavailable / offline.
workers/api/src/lib/agent-think.ts:557-563 instructs the model to trust those labels and never upgrade stale scrollback into a claim.
The write path has no equivalent. Every send is a blind send-keys:
- Connector:
workers/api/src/lib/connectors/tmux.ts — tmux_send_keys (:105-130) sends text without Enter unless the model passes keys; tmux_run_command (:79-102) appends Enter. Same shape in connectors/terminal.ts (terminal_send_keys :118-147, terminal_run_command :92-117, terminal_new_target :148-172).
- Runner dispatch:
packages/browser-runner/src/server.ts:326-334 — /tmux/send = if (b.text) sendText(...) then for (k of keys) sendKey(...); returns the pane immediately, before the CLI reacts.
- Primitives:
packages/browser-runner/src/coding/tmux.ts — sendText (:35, -l literal), sendKey (:30), createSession (:50, new-session -d), runCommand (:128 = sendText + sendKey Enter). None wait for or verify anything.
Three defects fall out of this:
- (a) No readiness gate. After launching an interactive CLI we return "ready" instantly; sends into a still-booting TUI are dropped.
- (b) Enter is the model's responsibility.
send_keys requires the model to remember keys:["Enter"] to submit to a CLI. Easy to get wrong; no runtime safety.
- (c) No landed-confirmation. The tool returns "Sent." with no signal whether the pane actually changed, so "did it land?" is left to model judgment — which failed here.
Proposed fix — make writes settle-aware (mirror the read-side grounding)
Reuse the settle heuristic the Coder raw-engine path already ships (packages/browser-runner/src/coding/headless.ts:348-351: ~1.5 s of output silence = quiescent; short timeout backstop). The Coder ready/runState() primitive (headless.ts:304-355) can't be reused verbatim (it needs a PTY + stream-json; the Operator connector drives a raw pane), but the heuristic transfers.
Runner side (/tmux/send, /tmux/run, /terminal/send, /terminal/run, and the command: path of /tmux/session|/terminal/session create):
- Capture a baseline pane before sending.
- Send.
- Poll-capture until the pane quiesces (unchanged for ~750 ms–1.5 s) or a short timeout.
- Return
{ paneBefore, paneAfter, changed }.
Connector side: surface that as a grounded result — e.g. "Sent; pane changed" vs "Sent, but the pane did NOT change — the input may not have landed (is the CLI at its input prompt?)". For the new_session/new_target(command) case, don't return until the launched CLI's pane settles (its input prompt is showing), so "up and ready" is verified rather than assumed.
Acceptance criteria
Follow-ups (separate issues)
Minor observability note surfaced while debugging this: agent_trace stores a truncated tool message, so the exact tmux_send_keys args (was keys:["Enter"] passed?) weren't recoverable. Persisting structured tool args/results would make write-path failures diagnosable — folded into #483.
Summary
Driving an interactive CLI (Claude Code) inside a tmux pane through the terminal / tmux Operator connector is unreliable: the write tools are fire-and-forget. There is no check that the pane is ready to accept input before we send, and no confirmation that the input landed after we send. The model only discovers a dropped keystroke by re-capturing and reasoning about it — and if it declares success first, the failure is silent.
Live incident (dogfooding, 2026-08-10)
Instance
Heartfull (tmux)(cda75e28-…), tracef04b6224-1569-4153-9faf-14d73d3af8ca:07:15:45— agent runstmux_new_session(commandclaude) and immediately reports "Claude Code is up and ready, running on Opus 5." Claude Code had not finished painting its TUI.07:16:29— on the next turn it captures the pane and seesserge-ivo@Mac platform % claude— i.e. still a shell prompt, not Claude's input box — thentmux_send_keysthe user's message into it. It goes nowhere.The retry only happened because the user typed "Check again." There is no automatic recovery.
Root cause — the write path has no grounding, while the read path does
The read path is already carefully grounded:
workers/api/src/lib/terminal-label.ts:81-95labels every captured pane as live / idle-stale / unavailable / offline.workers/api/src/lib/agent-think.ts:557-563instructs the model to trust those labels and never upgrade stale scrollback into a claim.The write path has no equivalent. Every send is a blind
send-keys:workers/api/src/lib/connectors/tmux.ts—tmux_send_keys(:105-130) sends text without Enter unless the model passeskeys;tmux_run_command(:79-102) appends Enter. Same shape inconnectors/terminal.ts(terminal_send_keys:118-147,terminal_run_command:92-117,terminal_new_target:148-172).packages/browser-runner/src/server.ts:326-334—/tmux/send=if (b.text) sendText(...)thenfor (k of keys) sendKey(...); returns the pane immediately, before the CLI reacts.packages/browser-runner/src/coding/tmux.ts—sendText(:35,-lliteral),sendKey(:30),createSession(:50,new-session -d),runCommand(:128 =sendText+sendKey Enter). None wait for or verify anything.Three defects fall out of this:
send_keysrequires the model to rememberkeys:["Enter"]to submit to a CLI. Easy to get wrong; no runtime safety.Proposed fix — make writes settle-aware (mirror the read-side grounding)
Reuse the settle heuristic the Coder raw-engine path already ships (
packages/browser-runner/src/coding/headless.ts:348-351: ~1.5 s of output silence = quiescent; short timeout backstop). The Coderready/runState()primitive (headless.ts:304-355) can't be reused verbatim (it needs a PTY + stream-json; the Operator connector drives a raw pane), but the heuristic transfers.Runner side (
/tmux/send,/tmux/run,/terminal/send,/terminal/run, and thecommand:path of/tmux/session|/terminal/sessioncreate):{ paneBefore, paneAfter, changed }.Connector side: surface that as a grounded result — e.g.
"Sent; pane changed"vs"Sent, but the pane did NOT change — the input may not have landed (is the CLI at its input prompt?)". For thenew_session/new_target(command)case, don't return until the launched CLI's pane settles (its input prompt is showing), so "up and ready" is verified rather than assumed.Acceptance criteria
{ paneBefore, paneAfter, changed }(or equivalent) after settling, not the pre-reaction pane.new_session/new_targetwith a startupcommandwaits until the pane quiesces before returning.run_command/send_keysreport whether the pane changed, in words the model can ground on.changed:false; send into a ready CLI →changed:true; launch-and-wait returns only once the prompt shows.Follow-ups (separate issues)
Minor observability note surfaced while debugging this:
agent_tracestores a truncated tool message, so the exacttmux_send_keysargs (waskeys:["Enter"]passed?) weren't recoverable. Persisting structured tool args/results would make write-path failures diagnosable — folded into #483.