cmux-tui: agent message delivery pump, launch credentials and unsend (agent messaging 4/6)
Merged
TE
21d ago+2109+2109+0123456789012345678901234567890123456789-207-207-012345678901234567890123456789
Agent messaging 4/6 (cx-55d27). Agents' messages now actually reach acpmux recipients without anyone re-running cmux agent message, acpmux agents send as verified senders, and a sender can take a message back.
What changes
- Launch credentials for acpmux agents. When acpmux spawns an agent session, it calls
credential.mint {acp_session}on the cmux daemon its ownCMUX_TUI_SOCKETnames. It then setsCMUX_LAUNCH_CREDENTIAL, plus that sameCMUX_TUI_SOCKET, in the agent's environment.- Inherited, preset and Chief credential variables are always removed first. This now happens inside
launch_credential::apply, soacpmux/src/agent.rsshrinks from 1179 to 1175 lines. - If no daemon is set, none answers, or the answer takes longer than 2 s, the agent starts without a credential and its sends stay unverified. Launch never blocks on this.
- The daemon's own acpmux starter now passes its socket.
- Inherited, preset and Chief credential variables are always removed first. This now happens inside
- Behavior change: acpmux agents act as
AcpSession, not the local user. An agent'scmuxcalls now reach the daemon asAcpSession, wherever any call checks the actor. For example,workspace_status.setwithowner.pidis refused for them (state/router.rsstatus_set_meta: trusted local callers only), as it already is for terminal agents. - Daemon delivery pump (
mux/agent_message_pump.rs). This is the one acpmux delivery path.- It sends a queued
acp:<id>delivery to the session as a prompt whose id is the message id, with_meta.acpmux.provenance. - It hands over and records each delivery under the lock unsend takes.
- When acpmux accepts, the delivery becomes
delivered(viaacp.prompt). When acpmux refuses, it becomesfailed. - A prompt acpmux never accepts (timeout, or the link closes after the send) stays
queuedwith the attempt counted. acpmux runs a prompt id once, so the retry is safe. - Each pass reads only queued acpmux deliveries, in SQL, 200 per page, with a
(sequence, position)cursor. Queued terminal messages can't hide them. - It wakes on send, release, daemon start and each agent-chat-watch reconnect. While a delivery is waiting it also wakes on a backoff timer: 1 s, doubling to a 60 s cap, with no cap on the number of retries. Waiting means acpmux is down, doesn't run the session yet, refuses with
transfer.*, or never accepted the prompt. - Held deliveries wait for
agent.message.release.
- It sends a queued
- The CLI no longer hands over to acpmux.
cmux agent messagewaits up to 15 s for the pump and reports a delivery that failed or is still waiting. Previously the CLI prompted acpmux outside the delivery lock and recorded the result afterwards, so an unsend could recordunsentfor a message acpmux then ran. Codex terminal delivery is unchanged. agent.message.unsend {message_id, recipient?}returns{outcome, outcomes:[{recipient, outcome, error?}], message}. The outcome isunsent,retracted,too_lateorfailed.- Who (fail closed, coordinator decision, pending Leo): only the verified sender, or the person in the verified cmux app (Frontend), may unsend.
- A plain local connection is refused (
operation.failed, reasonunsend_needs_app), asagent.message.releaserefuses it. Any local connection may be an agent that droppedCMUX_LAUNCH_CREDENTIAL. - Another agent is refused with
unsend_not_sender. There is no time limit. - Alternative for Leo: the originally approved default (1) also allowed the plain local user (
User{local}). That lets an agent that drops its credential unsend anyone's message. It's a one-match-arm change if Leo prefers it.
- A plain local connection is refused (
- Replays are checked first. A retry under the same key replays, and a reused key or a stale
expected_revisionis refused, before acpmux is asked anything. - Each acpmux recipient's outcome is acpmux's
_acpmux/prompt_withdrawanswer, and every answer commits:- A refusal is that recipient's
failed, witherror.reasonwithdraw_refusedanderror.acpmux_reason. - A silent acpmux on a delivery a hand-over may have reached is
failed, withacpmux_unavailable. That covers delivered, failed, or queued with attempts. It is neverunsent. - Only a queued delivery that no hand-over was ever tried for is
unsentwithout acpmux.
- A refusal is that recipient's
- The overall
outcomeisfailedif any delivery failed, elsetoo_late, elseretracted, elseunsent. - Wiring: catalog (mutation, idempotency required), spec docs, the 7 SDK descriptors (new catalog sha), the app-host NEVER list (regenerated
cmux-app.d.ts), and page access hard-denied. Not allowlisted for the remote relay.
- Who (fail closed, coordinator decision, pending Leo): only the verified sender, or the person in the verified cmux app (Frontend), may unsend.
- CLI
cmux agent message unsend <id> [--recipient <agent>]has one noun-first case, which now exercises--recipient. It is CLI-only in MCP and documented incli.md.
Testing
All tests are behavior tests against a real daemon: tests/agent_message_pump.rs (14) and tests/agent_message_launch_credential.rs. The fake acpmux now has prompt gates, transfer.moving refusals, withdraw refusals and hang-ups.
Red, on the old code with the new tests (fleet):
- f67896f9, a31dfb33 (first pass, on 09560b51): launch credential missing; pump 0/6.
- 09d74ea1 (on 8da02bb8). Each of these is red for the expected reason:
- partial unsend: the whole request was refused
- silent acpmux: the whole request was refused
- unaccepted prompt: became
failed - 205 queued terminal messages hid an acpmux delivery
- 3428f9ce (scratch commit e2cc058b: 8da02bb8 plus the rewritten tests):
- replay: acpmux was asked twice
transfer.moving: becamefailedand was never retried
- 2da3969f (scratch commit 580882a4): CLI racing an unsend. The CLI's own hand-over marked the message
failedwhile the pump held it at the gate, and the CLI exited 1. - ed28865c (on f1ade20c, before the fail-closed fix): a plain local unsend was accepted.
- c8efeadc (on 28cc8a66):
every_safe_transport_operation_has_a_noun_first_pathreported that unsend'srecipientfield was never exercised.
Green, on 1e29acb7e1 (fleet):
- b3d46706 (
test agent_message_): launch_credential 1/1, agent_message_policy 16/16, agent_message_pump 14/14. - 784e341f:
every_safe_transport_operation_has_a_noun_first_path. - dd51f1c0 (
all): fmt and clippy pass. Tests stop atacpmux --test router_free_tier(6 of 7 fail), a known base flake. On earlier heads the same step stopped at the base redcodex_bridge_config, which also fails on the merge base (bf8774d1 base, 1afdf179 head). - Targeted follow-ups on 28cc8a66. The only later commit changes
cases.rs.
Local checks:
check-resource-api-boundary.py,check-spec-inventory.py,check-sdk-schema.pyandgen-cmux-global.ts --checkpass.crash_ratchet.pypasses, with no new unwrap.- The god-file check on every touched file finds no failures and no growth: agent.rs is 1175 vs 1179 on base, and mux.rs and router.rs are unchanged.
- rustfmt is clean.
Not run:
- The test binaries after
router_free_tierbeyond the targeted filters above. - A Windows
cargo check. Non-unix paths have stubs. - Pump and withdraw end-to-end against a real acpmux.
Changelog
- Added: Agents can take back a message they sent with
cmux agent message unsend, and the daemon delivers queued messages to acpmux agents on its own.
🤖 Generated with Claude Code