acpmux: every chat's inbox; another agent's plain send is never a turn of its own (A1)

TE
6d ago
+2523-97

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 send and the MCP send tool. The one routing predicate is inbox::plain_send, which has a unit test.

  • Still on the old queue path; A2 ports them as a follow-up:

    • sends with a --supersede key;
    • 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: false where they send plain messages to test the queue path.

  • Rollback switch: config agentInbox: false puts 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 inbox record in the log, so a restart keeps it.

  • Threads. A message's thread is, in order of preference:

    • the --pr/--thread its sender gave (_meta.acpmux.pr/topic);
    • otherwise the sender plus the PR its first line names;
    • otherwise the sender alone.
  • 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, alias blocker);
      • an action (result, the default; alias action) 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.

  • Replies. A sender waiting on session/prompt (a waited acpmux 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 (an inbox_answered record). 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_noticed record) 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 is unsent (an inbox_removed record, 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: unsent if the notice did not go in, retracted if it did. Once a notice has carried it, the withdraw answers retracted with via: "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_threads lists 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] and acpmux send --pr N | --thread T.
  • 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.json lists the methods.
  • Not the forum: base's hub/forum.rs is 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