acpmux: a peer link watches without tool inventories; ls --with-tools reads a peer row's tools per session
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 asmessage.too_large, and was then asked again without tools. The link now askswithTools: falsefrom 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'ssession_changednotices 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_changednotices for an attached session carry the full summary (server/mod.rs), andretain_summary_toolskeeps 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(newcli/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 haswithTools: true.
- An open (attached) peer chat already gets its tools. The attach reply and the
- Compatibility:
- An old peer answers
withTools: falsethe 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: truestill gets tools for this daemon's own rows.server/list_reply.rsis unchanged, andtests/tool_inventory.rsandtests/ls_selection.rscover it.
- An old peer answers
docs/acpmux-schema.json:toolsnow says a peer's row carries them only while this daemon is attached to it.
Consumers of a session row's tools / toolsMutable (checked)
| consumer | reads tools from | affected |
|---|---|---|
webviews direct.ts / App.tsx (Tools menu) | the attach / info summary, merged with attached session_changed | no: attached peer sessions still get full summaries |
Swift app (Packages/) | never reads tools on a session summary | no |
acpmux ls --with-tools | list rows | peer rows now read per session (this PR) |
_acpmux/wait, session_summaries_of | remote cache | only attached rows had live tools; nothing reads tools from wait |
_acpmux/set_tools, mcp/reconnect | forwarded to the peer, which reads its own meta | no |
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.
| field | mean B/row | median row | heaviest row | share of list (with tools) | read by |
|---|---|---|---|---|---|
| tools | 1,295 | 335 | 14,571 (121 tools) | 32.1% | attached / info only (removed from the link here) |
| lastPrompt | 213 | 219 | 219 | 5.3% | sidebar, CLI |
| enforcement | 186 | 193 | 152 | 4.6% | webviews (handoff review) |
| lastTurn | 185 | 180 | 250 | 4.6% | webviews, CLI, wait |
| sessionReceipt | 172 | 173 | 173 | 4.3% | peers (generation), mailbox |
| lastMessage | 169 | 158 | 203 | 4.2% | webviews, Swift, CLI |
| preview | 164 | 180 | 13 | 4.1% | webviews, Swift |
| usageStats | 94 | 0 | 250 | 2.3% | usage warnings, ls --wide, #27923 hover card |
| lane | 88 | 12 | 547 | 2.2% | coordinators, CLI |
| title | 70 | 44 | 2,701 (whole, with tools) | 1.8% | everyone (cut to 200 without tools) |
| usageWarnings | 68 | 19 | 1,070 | 1.7% | Swift, CLI |
| qualifiedSessionId | 66 | 66 | 66 | 1.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
| metric | before | after | delta | harness |
|---|---|---|---|---|
| whole lists a link asks for while the peer's list with tools is over the limit | 2 (one built and refused) | 1 | -1 | peer_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 host | 23 MB (refused) then 16.6 MB | 16.6 MB | -23 MB per link setup | figures reported for that host pair |
| local list with tools vs without, 443 rows | 1,786,814 B | 1,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_startgave 0 passed, 3 failed (the handshake askedwithTools: truefirst, andfillwas 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-cion 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