acpmux: a peer link watches without tool inventories; ls --with-tools reads a peer row's tools per session

TE
5d ago
+166-33

Leo, 2026-10-31: "why is there a 15mb cap and why are we not following protocol/transport best practices with compression/better formats"

This PR handles one part of that: what goes into each watch row. Compression of frames is a separate PR, and so is a warning when a list nears the cap.

What changed

  • A peer link's watch no longer carries tool inventories. The handshake used to ask _acpmux/watch {withTools: true} first. On one fleet pair the list came to 23 MB for 3,700 rows with tools (16.6 MB without), over the 15 MiB message limit, so on every link the peer built that list, refused it as message.too_large, and was then asked again without tools. The link now asks withTools: false from the start, and the pages after it do the same. Rows with tools also carried whole titles (rows without tools cut them to 200 characters, #28373), so the link's session_changed notices are smaller now too. A pre-#28182 peer that is still too large goes to pages of rows, as in #28542 / #28588.
  • Tools are fetched per session, on demand:
    • An open (attached) peer chat already gets its tools. The attach reply and the session_changed notices for an attached session carry the full summary (server/mod.rs), and retain_summary_tools keeps them across census notices. The webview merges each notice into its summary (direct.ts), so a notice without tools never removes them. The webview shows a chat's tools only from its attach/info summary.
    • acpmux ls --with-tools (new cli/run/ls_tools.rs) fills in each peer row that has no tools with _acpmux/info {sessionId: <peer>/<id>, waitLogs: false}, which the daemon forwards to that row's peer, 8 at a time. It only does this for the views that print tools (--json / --fields). A row whose read fails is printed as listed, with one stderr line giving the count. Local rows are unchanged: the daemon still returns their tools when the request has withTools: true.
  • Compatibility:
    • An old peer answers withTools: false the way it always has. That is the request every link already fell back to.
    • An old client or old peer that asks this daemon for withTools: true still gets tools for this daemon's own rows. server/list_reply.rs is unchanged, and tests/tool_inventory.rs and tests/ls_selection.rs cover it.
  • docs/acpmux-schema.json: tools now says a peer's row carries them only while this daemon is attached to it.

Consumers of a session row's tools / toolsMutable (checked)

consumerreads tools fromaffected
webviews direct.ts / App.tsx (Tools menu)the attach / info summary, merged with attached session_changedno: attached peer sessions still get full summaries
Swift app (Packages/)never reads tools on a session summaryno
acpmux ls --with-toolslist rowspeer rows now read per session (this PR)
_acpmux/wait, session_summaries_ofremote cacheonly attached rows had live tools; nothing reads tools from wait
_acpmux/set_tools, mcp/reconnectforwarded to the peer, which reads its own metano

Bytes per row (part 2 of the ask)

These were measured on one fleet host's real list (443 rows; JSON as sent, compact). Each field's bytes include its key. The sample is private text and is not committed.

fieldmean B/rowmedian rowheaviest rowshare of list (with tools)read by
tools1,29533514,571 (121 tools)32.1%attached / info only (removed from the link here)
lastPrompt2132192195.3%sidebar, CLI
enforcement1861931524.6%webviews (handoff review)
lastTurn1851802504.6%webviews, CLI, wait
sessionReceipt1721731734.3%peers (generation), mailbox
lastMessage1691582034.2%webviews, Swift, CLI
preview164180134.1%webviews, Swift
usageStats9402502.3%usage warnings, ls --wide, #27923 hover card
lane88125472.2%coordinators, CLI
title70442,701 (whole, with tools)1.8%everyone (cut to 200 without tools)
usageWarnings68191,0701.7%Swift, CLI
qualifiedSessionId6666661.6%webviews, Swift, CLI

Whole rows: mean 4,033 B with tools and 2,717 B without; median row 2,888 B; heaviest 18,595 B with tools and 6,337 B without. Fields set to null, false, 0, [] or {} add up to about 21% of the no-tools list.

Beyond tools, I trimmed nothing. Every other large field has a named reader in a list row (column above). The rest of the size is repetition: near-constant enforcement objects, sessionReceipt repeating sessionId and agentSessionId, lane.step repeating lastMessage.text, and default-valued keys. Frame compression handles that without a protocol change for every client. Removing a duplicate field would need the webview and Swift readers changed first.

Performance

metricbeforeafterdeltaharness
whole lists a link asks for while the peer's list with tools is over the limit2 (one built and refused)1-1peer_handshake_tests a_watch_list_is_asked_for_without_tools_from_the_start, a_peer_whose_list_needed_pages_of_rows_is_read_in_them_at_once_next_time
link list, 3,700-row host23 MB (refused) then 16.6 MB16.6 MB-23 MB per link setupfigures reported for that host pair
local list with tools vs without, 443 rows1,786,814 B1,203,635 B-32.6%acpmux ls --json --all [--with-tools] on one fleet host

Tests

  • Red at the test commit (e09ea8b7b0dc): cargo test -p acpmux --lib -- ls_tools a_watch_list_is_asked_for_without_tools_from_the_start gave 0 passed, 3 failed (the handshake asked withTools: true first, and fill was a stub).
  • Green at the head: cargo test -p acpmux --lib -- ls_tools handshake_tests list_reply mirror_relay peers:: gave 29 passed, 0 failed.

Changelog

Changed: acpmux peer links no longer pull every remote session's tool inventory in their session list. acpmux ls --with-tools reads a remote session's tools from its machine.

Observed / Not run

  • Observed: the red and green focused runs above, on Linux. prepush-gate.py: no new violations. Rustfmt (pinned 1.95) is clean on the changed files. Byte numbers come from a real list.
  • Not run locally: the full acpmux suite, clippy and the panic ratchet. They go through CI and cmux-ci on this PR. I did not run a live two-daemon link against a fleet peer.

risk:normal. No UI change. No capture: daemon wire behavior only.

Agent session: agent session

🤖 Generated with Claude Code