acpmux: every chat's inbox; another agent's plain send is never a turn of its own (A1)
Leo, 2026-10-29:
- "What are we gonna do with these 200 fucking messages? You never end a fucking turn. As soon as it comes in, it fucking, like, occupies you for another fucking turn."
- "im the only one that steers... shouldn't some of these messages like... you can turn on steer or queue or whatever? idk what the best situation is... didnt we think of a forum-type system earlier? idk how swarms and dynamic workflows are best done..."
- "i feel like messages should kinda act as pseudo threads within threads/side chats?"
- "until the acpmux fixes get into the app your turns almost never end ... what is the actual solution? forum or whatever claude code does?"
- Earlier: "are these messages literally not getting to you ... i literally can't stop you i clicked the stop button and it didnt do anything ... otherwise i have to manually just click steer on all of them unless you come up with better solulus"
A coordinator session had 319 other agents' messages queued as 319 turns, behind a turn that the person's steers kept going.
The coordinator's design, from those words: agent messages never become turns; only the person's prompts are turns.
Default change. Before this PR, a plain send from another agent started a turn in an idle chat, or queued or steered into a busy one. Now it goes into the chat's inbox and starts no turn. It wakes an idle chat (one debounced turn) only when it is:
- a blocker;
- a report of work the chat gave out;
- the chat's first work;
- an action from the chat's parent or delegator.
Any other action, and every FYI, waits for the chat's next turn, or up to 10 minutes in an idle chat. Config agentInbox: false restores the old behaviour.
What
Every chat gets an inbox (hub/inbox.rs). Another agent's plain send never becomes a turn or a steer of its own.
-
What goes in (this PR, A1): a plain send from another agent on this host:
acpmux sendand the MCPsendtool. The one routing predicate isinbox::plain_send, which has a unit test. -
Still on the old queue path; A2 ports them as a follow-up:
- sends with a
--supersedekey; - steer-only sends;
- team messages;
- peer-daemon and mesh-relay forwards;
- lane reports;
- task cards;
- a paused agent pair's releases.
Their tests are unchanged and pin
agentInbox: falsewhere they send plain messages to test the queue path. - sends with a
-
Rollback switch: config
agentInbox: falseputs plain sends back on the queue without a new build. -
Storage. A text-only agent message goes into the receiver's inbox, kept apart from its queue. It is an
inboxrecord in the log, so a restart keeps it. -
Threads. A message's thread is, in order of preference:
- the
--pr/--threadits sender gave (_meta.acpmux.pr/topic); - otherwise the sender plus the PR its first line names;
- otherwise the sender alone.
- the
-
Notices. The agent hears of new messages as ONE notice (provenance
inbox_notice), the way Claude Code reports background work. The notice has one line per thread with new messages: the count, the sender(s), and the newest message's first line cut to 120 characters. Blockers come first.-
Running turn: the notice is a steer read at the next tool step. Only one is in flight at a time, so a turn takes at most one per step. The turn cap (#27520) still holds.
-
A prompt waits in the queue: no notice steers in ahead of it; the notice goes into that prompt's own turn (#27566's case).
-
Idle chat: it wakes as ONE turn, 2 s after the first message that may wake it, so a burst is one turn. A message wakes it only if it is:
- a blocker (
block/question, aliasblocker); - an action (
result, the default; aliasaction) from the session that started the chat or gave it its first work; - a lane's report or a task's result (work the chat gave out);
- or the chat's first work (it has had no turn yet).
An FYI never wakes a chat. Anything left waits at most 10 minutes before a notice brings it.
- a blocker (
-
-
Replies. A sender waiting on
session/prompt(a waitedacpmux send) gets the answer of the steer or turn that carried its message. If the chat closes first, the sender gets the closing error. A chat that is closing or closed takes messages as it did before the inbox. A notice that went in logs its answer, or its error if it failed after it went in (aninbox_answeredrecord). A resend of one of its messages gets that same answer and is not carried again: before or after a restart, and after the in-memory inbox let go of the message (it keeps 500 and only lets go of answered, read ones). -
Durability. Messages are marked noticed (an
inbox_noticedrecord) only once the notice went in, meaning the agent's writer confirmed its prompt, for a steer and for a turn of its own. If a Stop or a failure comes before that, they stay new. Only one notice is in flight: a turn of its own until its prompt is written, a steer until the agent took it at its tool step. A steer notice checks again for a waiting prompt and the turn cap while it holds the input handoff, so nothing is written between that check and its own write. A refused notice is tried again after 30 s, or sooner when a turn starts or ends. A closed chat's threads survive a restart. -
Withdraw (
_acpmux/prompt_withdraw): an inbox item that no notice has carried yet isunsent(aninbox_removedrecord, which a restart honours). A resend of it answers withdrawn and never puts it back. A withdraw of a message whose notice is on its way but not yet written waits up to 10 s for it:unsentif the notice did not go in,retractedif it did. Once a notice has carried it, the withdraw answersretractedwithvia: "inbox", and the retraction goes into the item's thread. -
Reading and answering.
- The MCP tool
inbox_read(_acpmux/inbox_thread) reads a thread whole and marks it read;_acpmux/inbox_threadslists the threads. - The MCP tool
inbox_reply(_acpmux/inbox_reply) answers into the sender's inbox under the same thread, so it never becomes a turn for them. - CLI:
acpmux session inbox NAME [--thread T]andacpmux send --pr N | --thread T.
- The MCP tool
-
Not inbox items; they run as before: images, uploads, slash commands, sub-session family messages, Web prompts.
-
Wire contract (for the GUI, which is a separate PR):
_acpmux/inbox_threads {sessionId}ā{threads: [{threadId, key, label, from[], unread, new, count, newestAt, newestPreview, class}]}, newest first;_acpmux/inbox_thread {sessionId, threadId, markRead?}ā{threadId, label, messages: [{promptId, from, fromSession, class, kind, at, text, read}]};_acpmux/inbox_reply {sessionId, threadId, text};- log records
inbox,inbox_noticed,inbox_answered,inbox_read,inbox_removed; the notice prompt's provenance is{kind: "inbox_notice", noticeId, threads}. - Documented in
docs/acpmux-schema.json;spec/acpmux-protocol.jsonlists the methods.
-
Not the forum: base's
hub/forum.rsis rules announcements, not a message store, so the inbox does not reuse it.
No capture: no visible change. The only webview file is the generated protocol method list (methods.ts, three untyped methods), and the GUI for threads is a separate PR.
Tests
tests/hub_integration/agent_inbox.rs:
- 300 agent messages arrive as one notice at the next tool step, with no extra turns;
- an idle chat wakes only for a blocker or for its first work, as one turn, and a waited send gets that turn's reply;
- a queued prompt never waits behind agent messages (the starvation case from #27566);
- a withdraw unsends an inbox item until a notice carries it (a resend then answers withdrawn), then retracts it into its thread without a turn; a resend of a carried message gets its notice's answer;
- a chat that closes answers its waiting senders with an error and keeps its threads readable;
- threads are read whole and marked read, another session's read is refused, and a reply lands in the sender's inbox under the same thread without waking it.
Red: https://github.com/manaflow-ai/cmux/actions/runs/40973826419 (3 of 3 fail). Green at the head: https://github.com/manaflow-ai/cmux/actions/runs/41053769339 (fmt, protocol IR, clippy, lib tests, every integration test binary; only the base reds below).
Base reds, each compared per test against base 736519210a2, where it fails the same way:
hermetic_daemon_guard::every_test_binary_runs_in_the_hermetic_environment;send_async::overlapping_direct_and_queued_sends_of_one_prompt_id_record_one_outgoing_copy, a peer-link send, so on the queue path. It is flaky: 6 of 8 fail on base, 1 of 8 at the head;- GitHub checks: cmux-next "No literal state roots" (
help_examples/session.rs:179), release-notary's What's New highlights (chief-name-in-agent-chats.md), the test-execution registry (test_cmux_tui_owned_mini_caches.py), and the resource API boundary (crates/cmux-tui/src/cli.rs, which this PR does not touch).
Earlier runs against older bases also showed the remote_guard_modes, send_relay_cli, peer_state_origin, auto_resume::retry and transcript_events reds; base has fixed them since. The intent-review warning, the gallery advisory and the PR-checks link jobs failed on GitHub's installation API rate limit.
Merge with base. Base's acpmux session inbox NAME --digest [--dry-run] (the queue digest) and this PR's --thread [--mark-read] share one subcommand: --digest/--dry-run drain the queue, and --thread reads inbox threads; the two conflict. The digest drains exactly the kinds that still queue until A2. Base's new queue tests (inbox digest, stop, session queue CLI, peer person stop) pin agentInbox: false. recover_message_events also scans inbox records, so a message the inbox took has its message projection repaired after a restart, as a queued one does. finish_turn keeps inbox_turn_ended on base's new Stop-before-prompt path. The inbox flags are flattened clap Args, because inline they overflowed libtest's 2 MiB thread in a debug build.
Risk
High, for every chat: other agents' messages no longer steer in or queue as turns.
- An action from a session that neither started the chat nor gave it its first work waits for the chat's next turn, or for up to 10 minutes if the chat is idle.
- A lane that relies on a peer's result to wake it must be the one that delegated the work, or the peer must send it as a blocker.
Agent session: agent session
š¤ Generated with Claude Code