Waiting¶
An agent driving a terminal spends most of its time waiting. This page covers which wait to reach for, why the wait tools are bounded, and — for anyone tempted to redesign them — what was measured and rejected.
Pick the cheapest wait that works¶
Reach for them in this order.
You wrote the command: use run_command. It runs the
command, waits for it, and hands back output plus exit status in one call.
Measured against sleep 2; echo done: one call, 2.10 s, 81 tokens, correct.
You wrote the command but want it in an interactive pane: compose a
channel. Append ; tmux wait-for -S NAME and block on
wait_for_channel. This is the only deterministic wait —
tmux blocks server-side and returns on the signal itself, one tmux process
for the whole wait, and a 16-token result. Nothing here is inferred from
pixels.
send_keys("pytest; tmux wait-for -S tests_done")
wait_for_channel("tests_done")
The ; fires the signal whether pytest passed or failed, so the wait
cannot deadlock on failure.
You did not write the command: use wait_for_text. A
daemon printing ready, a dev server you attached to, a build someone
else started. This is the only case where scraping the pane is the right
answer, and it is a heuristic — see below.
You want to keep watching rather than wait once: use
capture_since. It returns an opaque cursor you pass back,
so repeated observation does not re-send content you already have.
Always pass stop to wait_for_text when a failure marker
exists. Measured on a five-minute build failing after 5 s:
stop=["error:", "FAILED"] returned in 4.57 s and 91 tokens; without it
the same wait ran to the ceiling — 6.6x the wall-clock and 3.9x the
tokens for a worse answer.
Why the wait tools are bounded¶
Every wait is capped by LIBTMUX_MCP_WAIT_MAX_SECONDS (default 30 s,
hard limit 120 s). An over-large timeout is clamped, not rejected, and
the result reports the value actually enforced on effective_timeout.
The ceiling bounds the agent’s turn, not the transport. This is worth stating plainly because the opposite is intuitive and wrong. The wait tools await throughout, and FastMCP serves requests concurrently over one stdio connection, so a long wait does not block anything else. Measured on fastmcp 3.4.4:
tool under test |
interleaved calls served |
median latency |
|---|---|---|
awaits for 6 s (what the wait tools do) |
58 |
3.4 ms |
blocks the event loop for 6 s (control) |
1 |
6014 ms |
What an unbounded wait actually costs is the agent’s turn: it picks the wrong search text once, and MCP gives it no way to change its mind mid-call. The ceiling makes that failure cheap and repeatable instead of terminal.
How wait_for_text decides something is new¶
tmux has no hook that fires on pane output — the notify_* set in
notify.c covers session, window, and client lifecycle only. So any
“wait for text” must poll, tap the pty, or attach a control-mode client.
wait_for_text polls.
At entry it records an absolute grid anchor (history_size + cursor_y)
and snapshots the content of the entry cursor row and everything below
it. Each tick it re-captures from the anchor and drops rows whose content
was in that snapshot. New content matches; pre-existing paint does not.
Two consequences worth knowing:
The entry cursor row counts. On a quiescent pane the cursor sits at the end of the prompt, so the first line a command prints lands on that row. It is anchored on, not skipped, and single-line output and carriage-return spinners both match.
A stale match does not end the wait. If your pattern is already on screen when you call, the wait keeps waiting for a fresh occurrence and reports
matched_at_entry: trueif none arrives. That is what makes re-running a command whose output looks identical work.
Read the result fields rather than guessing:
field |
what it tells you |
|---|---|
|
the pane really was quiet — suspect the command never ran |
|
output arrived and did not match — read |
|
the text is on screen but was already there when you called |
|
a pager or TUI owned the pane; read the screen, do not retry |
|
a |
Where it is genuinely weak: output that outruns history-limit between
polls. The tool detects that band and emits a warning telling you to use
wait_for_channel, because it cannot be fixed by polling
harder.
What a wrong pattern costs¶
The failure everyone worries about is picking search text that never appears. It costs one call, bounded by the ceiling, and the result tells you which of the three things went wrong. Measured with a 6-second budget:
what happened |
|
what comes back |
|---|---|---|
the daemon printed something else |
|
|
the command never ran |
|
a prompt-only |
the pane is scrolling, no match |
|
a 20-line tail of the real output |
In every case the answer is in the result, so the fix is a better
pattern on the next call rather than another blind wait. Read tail
before retrying — on a timeout it usually already contains what you were
waiting for.
Designs that were measured and rejected¶
Recorded so they are not re-litigated from intuition.
Background tasks (MCP SEP-1686, task=True). Rejected. The
connection it would free is already free (table above). Worse, progress
notifications are silently dropped in task mode — roughly 1200
ctx.report_progress calls produced zero notifications/progress frames
against four in the synchronous control. And no client asks for it: of
the four CLIs measured, none advertised a tasks capability in their
initialize frame. The shipped bounded, cancellable design was verified
to work verbatim under task mode, so this stays a small follow-up if
clients ever arrive.
Event-driven pipe-pane. Rejected. cmd-pipe-pane.c keeps a single
wp->pipe_fd per pane and starting a new pipe frees the old one, so an
internal event pipe would silently destroy any logging a user started
with pipe_pane, and vice versa. It also matches the raw pty
byte stream rather than the rendered grid, which fires on the echo of the
command just sent and never fires on unterminated prompts. Latency was
not the deciding factor either way: polling at the default 50 ms interval
wakes in ~21 ms median, against ~18 ms for a control-mode client.
Removing the tool. Rejected. The wait does not disappear with the
tool — it moves into the agent’s turn loop, where it has no ceiling at
all, because MCP gives an agent no sleep primitive. Measured on a
45-second dev-server start: wait_for_text took 2 calls and
538 wire tokens; the same goal polled via capture_since took
10 calls and 3438.
A deferred handle — returning early with a resume cursor so the agent
can change its mind. Rejected, and this one was close. It would have
given the “server decides to stop blocking” semantics without needing any
client capability, reusing capture_since‘s existing opaque
cursor. But it solves the wrong-pattern case, and that case already
resolves in one self-diagnosing call (table above). Deferring on a soft
deadline would instead ADD calls to the common good case: a 5-second
soft deadline turns one legitimate 45-second wait into roughly nine
calls instead of two.