Skip to content

[bug] Terminal/tmux Operator writes are fire-and-forget — no readiness gate, no landed-confirmation; the first message to an interactive CLI is silently dropped #481

Description

@serge-ivo

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:

  1. 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.
  2. 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.
  3. 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.tstmux_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.tssendText (: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):

  1. Capture a baseline pane before sending.
  2. Send.
  3. Poll-capture until the pane quiesces (unchanged for ~750 ms–1.5 s) or a short timeout.
  4. 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

  • Runner send/run endpoints return { paneBefore, paneAfter, changed } (or equivalent) after settling, not the pre-reaction pane.
  • new_session/new_target with a startup command waits until the pane quiesces before returning.
  • Connector run_command / send_keys report whether the pane changed, in words the model can ground on.
  • Reasonable timeouts + backstop so a busy/animated pane can't hang the tool (reuse the headless.ts settle constants).
  • Unit tests for: send into a not-yet-ready pane → 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_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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingconnectorsConnector + tool framework

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions