cmux NEXT: a new local chat carries its workspace to acpmux, so workspace subagent limits apply

Merged
TE
11d ago
+162-3

Leo: "I want cmux next to be able to increase or decrease subagent amounts ad hoc, with the acpmux daemon and all that. Slide it, even per workspace. Right-click, edit subagent count."

A chat started in the app now tells acpmux which cmux workspace it belongs to, so a workspace's subagent limit applies to it.

acpmux (#23963) reads a workspace's maxRunning only through a chat's meta.workspace_id, which it records from _meta.acpmux.workspaceId on session/new (or from CMUX_WORKSPACE_ID in a unix-socket session env). Chats started from a terminal or the Chief already carry it. Chats started in the app's agent pane didn't: direct.ts sends no workspace id, the pane's params rule refuses the key, and AcpmuxPathPolicy filled in only cwd. So a workspace limit set from the app was saved but changed nothing for those chats (#24061 left out Workspace Subagent Limit… for this reason).

With this PR, the pane host adds the pane's own workspace to each local session/new:

  • AgentTabStore.localWorkspaceKey(of:) returns the durable key of the workspace that holds the tab, but only when that workspace's daemon is this Mac's (daemonForTab(key)?.isLocal). wireAgentFolder hands it to the transport as workspaceID.
  • AgentPaneTransport.checkPathsInScope puts it in AcpmuxPathPolicy.Scope.workspaceID. checkNow calls stampWorkspace, which writes _meta.acpmux.workspaceId over anything already there.
  • The value is lowercased and must be a dashed UUID of ASCII hex digits, the same rule as acpmux's session_env::is_workspace_id. Any other key (a handle: stand-in, an empty or legacy key) is dropped, not sent, so acpmux can't refuse the start with workspace.value_refused.
  • Its sub-sessions get the same workspace from acpmux, which already copies it (hub/subsessions.rs ~679), so the limit covers the chat's whole family.

Security (request-policy change; CLAUDE.md "Remote CLI relay")

This adds no method and allows no new key from the page. The host writes one field into a frame that the page already sends.

  • The page can't choose the id. knownParams["session/new"].acpmux still has no workspaceId, so P1 (breaksParamsRule) refuses a page frame that names one before any other rule runs (intentInvalid). The new comment in AcpmuxPaneMethods+Params.swift says the key must stay out. As a second guard, stampWorkspace writes over the key or removes it whenever the frame reaches it. The test thePageCannotNameAWorkspace checks both.
  • Only trusted scope. The value comes from AgentTabStore through a host closure (transport.workspaceID), the same path the pane's roots and agent home already take. It never comes from page state. It is resolved for the tab's own key (resolve(provisional)), so a pane can only name the workspace that holds it.
  • Local only.
    • A start over a remote wire (remoteWire) never reaches the path check, so it gets no stamp.
    • A start that names a non-empty peer gets none, and any value already there is removed: a peer's workspaces aren't this Mac's.
    • A tab whose workspace lives on another machine's daemon gets nil.
    • acpmux itself clears workspace_id when a chat transfers to another machine (hub/transfer_guard.rs ~335).
  • No command-bearing params. The field is a validated UUID string. In acpmux it isn't session env and reaches no agent process (agent/command.rs removes ACPMUX_AGENT_WORKSPACE before the harness starts). It can't start, read or reach any object. Its only effects are the limit lookup and the display pick below.
  • Local state. Nothing new is shown to the page. The page already sees its chat's summary, including subsessionLimits.

Computer use display: what changes

acpmux reads meta.workspace_id in five places: the sub-session maxRunning lookup (subsessions.rs ~461), sub-session and side-chat starts that copy it (subsessions.rs ~679, btw.rs ~117), forks that inherit it (hub/fork.rs ~63), and agent_displays::mark at spawn and fork (hub/spawn.rs ~139, hub/fork.rs ~64).

mark puts the workspace in the spawn env only when the daemon runs with CMUX_AGENT_DISPLAYS=per-workspace. AgentTools::scoped picks a display only when agent_displays::per_workspace() is on. Only the Linux cloud machine image turns that mode on. Nothing in this repo sets it, and the Mac app's local acpmux runs with it off.

  • On this Mac (the only daemon that gets the stamp): nothing changes. mark only removes the key, AgentTools.displays is None, and scoped returns before the display code. An app chat keeps the same cmux-cua server with no CMUX_CUA_DISPLAY, exactly as before.
  • If a daemon that gets the stamp ran in per-workspace mode: an app chat would change from no computer use server at all (fail closed: no workspace) to cmux-cua with CMUX_CUA_DISPLAY=:N. :N is the workspace's own display from <ACPMUX_HOME>/agent-displays/displays.json, shared with that workspace's terminal and Chief chats. The v2 bridge would be dropped, as for every session in that mode. That is the mode's design: one display per workspace, and an agent never drives another workspace's display. It's the right result. It can't happen from the app today, because the only daemons in that mode are cloud machines, and their chats are remote-wire starts that this PR leaves unstamped.
  • Sub-sessions, side chats and forks of an app chat now inherit its workspace. In per-workspace mode they would share their parent's display, which is also right.

Known limit: acpmux records the workspace when the chat is created. A chat tab that later moves to another workspace keeps the first one's limit. A chat started before this change has no workspace, so it still follows its own and the global limit.

Changelog

  • Fixed: a workspace's subagent limit now applies to chats started in the app, not only to chats started from a terminal.

Observed / Not run

  • cmux-ci compile-check --ref 98ccabba358a6e7ebc041c8f6ec84a7562d7688b --app passed (fleet, 47 s, job 2e07e289ca2259944a9e7394).
  • AcpmuxPathPolicyWorkspaceTests (new: stamp, lowercase, page value refused by P1 and replaced or dropped, invalid keys not sent, peer and other methods unstamped) and the existing path-policy suites AgentPaneBrowsedFolderRootTests, AgentPanePickedFolderRootTests, AgentPaneSendPickedFolderPromptTests: submitted to the fleet (cmux-ci run package-test-lane suite, step 3345b0c03cab14fbc7e2a7d6), still queued behind the fleet's suite backlog (about 300 suite steps ahead, 1.5 h in queue) as of this edit. This PR's CI is the fast tier, which doesn't run the macOS package tests, so these suites haven't run yet.
  • prepush-gate.py (base 8cdf8ca03e4, head 98ccabba358): no new violations.
  • The merge with the current feat-cmux-next (31afd278979) is clean (git merge-tree). #23990 (same lane) edits the same Params file in a different table and doesn't conflict.
  • PR CI reds that aren't this PR's: cmux-next checks ("Fail on any red check"), release-notary and its Guard status are red at the same steps on #24061, #23990 and #24225 (other heads, same base). The prepush gate's matching scanners (concurrency, crash safety, string tables) found no new violations here. risk evidence is the risk:high hold, waiting on review.
  • Not run against a live acpmux. Not run: an end-to-end check that a workspace limit set by _acpmux/subsession/limits {workspaceId} holds an app chat's sub-sessions. acpmux's side of that is covered by #23963's tests.

No capture: no UI changes. The app adds a field to an acpmux request that no surface shows.

Agent session: cosmic-gray

đŸ¤– Generated with Claude Code