agents_wait: wait for idle, dialog or text instead of polling snapshots (cx-ncc.93)

Merged
TE
22d ago
+1475-28

Summary

An agent that waits for another agent in cmux had to poll agents_snapshot. It can now call agents_wait (MCP) or cmux agents wait (CLI), which share one code path (cli/agents/wait.rs) and answer when the condition holds:

  • until: idle: the agent of a term_, tab_ or agent_ id, or an agent chat's session, stops working (idle, done, or blocked on a dialog; match.state says which).
  • until: dialog: a permission or question dialog is pending, a target's when given (the agents_dialog_list read).
  • until: text: literal text, or a regex with regex: true, appears on a terminal's screen.

timeout_seconds defaults to 30 and is at most 120. The receipt is {until, matched, waited_seconds, match, state}, where state is a fresh agents_snapshot-shaped snapshot cut by the #20609 receipt rule. A timeout is matched: false, not an error; a condition that already holds answers at once. Bad arguments, an invalid regex (checked with the regex crate, the daemon's own engine and limits) and an unknown target are refused as not_run before any wait. For until: dialog, a term_, tab_ or agent_ target that no owner knows is refused like idle and text; a target of another kind is taken as a dialog request id, which may name a dialog that has not appeared yet, so that wait runs to its timeout instead of being refused. A text target whose terminal closes mid-wait reports match.state: "closed", the same result idle gives.

cmux agents wait exits 1 on matched: false and still prints the JSON receipt, as screen wait does (docs/cli-contract.md and the cmux-workspace skill say so).

The MCP server answers one request at a time, so an agents_wait call holds later calls on the same connection for up to its timeout (at most 120 s). terminal_wait already behaves this way; this PR does not change it.

What wakes a wait:

  • text is event-driven: it is the daemon's own terminal.wait, which wakes on terminal output.
  • idle and dialog hold the daemon's session.events stream open and check on each event (at most every 200 ms). They also check every second, because an agent chat's state and the dialog feed live in the app, whose events.stream publishes no event for them. So dialogs and chat-only agent state are polled at 1 s; terminal agents (daemon agent records, OSC 7501 program status) wake on daemon events. With no daemon stream, the wait checks every second.

An MCP call with _meta.progressToken gets notifications/progress every 5 s (progress = seconds waited, total = timeout). The CLI shows the #20627 stderr notes for waits of 35 s or more (human output, or JSON with a TTY), with a new agents_waiting_progress string (English and Japanese).

Security: the tool is read-only (readOnlyHint, idempotent, no key). It reads other agents' state and pending dialogs, as agents_snapshot and agents_dialog_list already do, and a terminal's screen, as terminal_wait already does; its description says so. Chat transcripts are not readable for text, because no existing read exposes them. No socket method or relay policy changed; RemoteRelayCommandPolicy stays deny.

plans/cmux-next/mcp.md lists the tool.

Proof

Behavior tests: eleven at the MCP boundary (nine originally, two added in review) (cli/mcp/tests/agent_wait.rs, through Server::handle/run with the fake owners, whose daemon snapshot and dialog feed change mid-wait) and one CLI test through a real headless daemon (tests/cli/agents_wait_cli.rs: text written to a terminal, an agent reported working then idle, a 1 s timeout, a usage error).

  • Red, tests only at 33e0b44fc4af: dispatch.sh <wt> agents_wait, run 38651317708. The nine MCP tests failed on unknown tool: agents_wait (behavior, not compile).
  • Green: run 38654134200 at 45491f13c7ae passed test (linux); its Linux clippy flagged two lints, fixed in c9ee02af48bf; focused run 38656540524 on that head (c9ee02af48bf) passed every job, with all ten agents_wait tests run and passing on Linux (macOS ran the same filter).
  • Full: run 38660253813 on the same head. Linux cargo test passed, including all ten agents_wait tests. The run is red on failures outside this diff:
    • Windows build and Windows tests: cli/wire/socket.rs calls crate::app_identity, which is #[cfg(unix)]. This is a base break from bc697beb0b57 (cx-is1kf.5), and it stops the Windows Rust tests before they run.
    • Linux TUI smoke (smoke-tui.py:953): a workspace-order assertion after prefix-W, in TUI code this PR doesn't touch.
    • macOS jobs: the runner refused the job (Set up runner), so they never ran. The macOS clippy and focused filter passed in run 38656540524.
  • Local: scripts/cmux-next/crash_ratchet.py ok; check-no-godfiles.sh --only rust --base origin/feat-cmux-next passes (the first version grew cli/app.rs past 1000 lines; it no longer does).

Review fixups (one red, one green, same focused command dispatch.sh <worktree> agents_wait):

  • Red, tests only at 2c6c1867b8fb: run 38666204524. The two new tests failed on behavior: agents_wait_refuses_an_invalid_regex_and_an_unknown_dialog_agent_before_waiting (an invalid regex was not refused) and agents_wait_text_reports_a_terminal_that_closes_mid_wait (a closed terminal was an error). The other nine passed.
  • Fix in 1268f9996b5e, then one base merge of feat-cmux-next at be93becd1f4c (includes #20767 and #20745) in 0f3cb113fdc9.
  • Green on 0f3cb113fdc9: run 38685803291, every job passed (21 jobs, 0 failed); its Linux log shows all eleven MCP agent_wait tests and the real-daemon CLI test agents_wait_cli_answers_on_text_and_idle_through_a_real_daemon ran and passed.
  • Local on the merged head: crash_ratchet.py ok; check-no-godfiles.sh --only rust --base be93becd1f4c passes.

Changelog

Added: agents_wait MCP tool and cmux agents wait, which wait until an agent goes idle, a dialog appears or text reaches a terminal, instead of polling snapshots.

🤖 Generated with Claude Code