cmux NEXT Cloud: cmux cloud start-session, start an agent on a machine (2/2)

Merged
TE
12d ago
+1633-17

Audit group §2d (p0-machines): start a VM from an ACP chat. This is part 2 of 2. Part 1, #25414 (landed), holds the agent ask card and the runs registry (CloudStartSessionAsks.swift) with their tests and strings.

Lawrence: "start new VMs from acp chat", with "decouple persistence of acp trajectories from the VM itself, in case the VM is resuming".
Leo: "so chieff related stuff we dont need to worry too much about but ergo of commands and docs and error/hint messages we do need ... anyways those are just quite a few things that we already kinda own/should prob own"

What changes

One explicit call:

cmux cloud start-session --target machine:<id> --agent claude [--cwd /home/cmux/work]
cmux cloud start-session --name dev --agent codex          # take machine "dev", or create it

It answers {machine_id, session_id, host}, plus machine_name, target, agent, and the chat's workspace_id, surface_id and tab_id.

  1. It takes the machine (by --target, else the one named --name, else it creates that machine) and connects. A paused machine resumes.
  2. It opens a chat tab on the machine's store (host registry:<id>) in the machine's first workspace.
  3. It starts the session in that machine's own acpmux through agent-session-start, the same keyed start a new remote chat's first send uses (AgentTabRemoteHost.startNow reuses keyedStart).

The person sees the chat in the machine's workspace.

Running it again. A run that answers pending (a new machine can take minutes) keeps going. The same command again joins it, or picks up its end once. A run whose caller already got its answer is not kept, so the next run starts a new session.

An agent's run asks first. A run from the cmux MCP server (or nested inside one: the outer run's origin decides), or from a shell naming its chat, puts a card in the agent's own chat: "The agent asks to start a claude session on Cloud machine dev", with Start and Not now. It follows cloud move's agent ask (CloudMoveAsks). Only Start creates or starts anything, and then as the person's run. Nobody answering within 2 minutes is a refusal that says so. The chat may be local or on a Cloud machine (an agent started by this verb can start another).

--name never makes a second machine. It needs a list the server answered within 15 s for the user and team signed in now (CloudService.hasCurrentList(within:)), and reads one if not. A failed or superseded read, or another team's list, refuses: a machine of that name may exist, check cmux cloud list-machines, then run the same command again. Two runs naming the same new machine share one create.

Per account. A run carries the user and team signed in when it was asked, and checks it (and cancellation) before every step that changes anything and after every wait: the list read, the create (before the new machine joins this Mac's list or connects: CloudService.createMachine's proceed), the resume or connect, the first-workspace fallback (CloudHandlers.terminalAnchor's proceed, right before it creates one), the tab, and each keyed restart and start try (AgentTabRemoteHost.startNow's proceed). After the start the session exists, so the result reports it, but nothing is shown on screen if the account changed meanwhile. A --target must be in the machine list read for the account signed in now (hasLoadedCurrentMachines): after a team switch, the previous team's machines stay in the store until that list lands, and a target among them refuses, saying to check cmux cloud list-machines. A team switch or sign-out while a card is open or a start is going on stops the run with a refusal that says so and gives the command. Runs and creations are also keyed by that account, so another account's ask never joins or takes them, and a sign-out or user change (CloudService.dropAllMachines) cancels and forgets them.

Why a new verb, not cloud move --new-session

  • cloud move moves an existing local chat: it syncs its folder and moves its session and history. Its target is a tab.
  • Starting a session targets a machine and has nothing to move. A --new-session flag would make one verb take two target kinds, with half its flags (--path, update/retry) meaningless in one mode.
  • web/app/api/vm/[id]/acpmux/keyed-start doesn't start a session. It only restarts the machine's acpmux with the person's key (for approvals), and the app already calls it through restartWithApprovals when a start needs that.

So the verb starts the session the way the app already does, through agent-session-start on the machine's daemon, and keyed-start is reached only through that approvals path.

Persistence (hq#4381)

  • Nothing here waits on a client snapshot. Create and resume wait only for the machine's daemon and store id, as cloud move does.
  • cmux-tui/crates/acpmux/src on current base has no peer_snapshot or similar hook. I grepped peer_snapshot, client_snapshot and PeerSnapshot.
  • So no hook is called. Once it lands, the place to call it is CloudStartSession.run right after startNow answers. The session id is known there, and a comment marks the spot. I did not make up a hook name.

Errors

Every failure names the machine and the agent and gives a CLI command, for example:

  • "Machine dev started but its agent didn't (The agent service on dev did not start). Run cmux cloud start-session --target machine:vm-x --agent claude again."
  • A missing folder says to pass --cwd.
  • A machine that doesn't connect points at cmux cloud machine-status.
  • A script run with no machine says how to name one.
  • Closing the new chat's tab before its agent started (while the store creates it, or while its start runs) says the tab closed (not that the account changed) and gives the command to run again.
  • A step that failed because the run was cancelled or the account ended says that, never that the machine didn't connect or the agent didn't start.
  • An agent outside a chat, a card that can't show, Not now, or no answer each name the machine and agent and give the full command to run again, every argument shell-quoted (for example cmux cloud start-session --name 'my dev' --agent claude).

Strings are in new tables (CloudSessionActions.xcstrings, CloudSessions.xcstrings) in all 21 languages the l10n gate requires. They don't touch Cloud.xcstrings.

Capture

No capture: the capture build of this head (fleet job aa9ed4b4719fad6af2a2921b, and the earlier 336a287df6bad0203956cb94) fails before the app builds, because the fleet's Linux pool has bun 1.4.2 and webviews needs 1.4.4 or newer. The card is the existing agent tool card (AgentPaneToolCard, as cloud move shows it) with this verb's text; the DEBUG scene cloud-start-session-ask (cmux-ci capture <sha> --state scene:cloud-start-session-ask) shows it once the pool is updated.

Timers and loops

The card's deadline and account watch, the caller deadline, the runs registry and CloudService's account observer are in #25414. This PR adds:

  • Tab listed: a one-shot 10 s wait for the machine's tree to list the new tab (CloudMove.holds, an observation raced with one sleep; the group is cancelled when either side wins). A tab not listed in time refuses.
  • Test bound: the card test's two stream waits race a one-shot 10 s sleep in a task group, cancelled when either side wins.
  • Machine up: the 180 s connect wait, the same CloudMove.holds.
  • Answer bound: the run answers pending: true 10 s before action.run's 200 s person deadline (CloudStartSessionRuns.answer), with text giving the same command to run again. The work keeps going until the start succeeds or fails, or the account ends.
  • No loops or observers outlive the run.

Size

1624 added lines over the ~800 cap:

  • 546 are the two translation tables (16 app keys, plus the action title and description, in 21 languages).
  • 450 are tests.
  • 73 are the regenerated exports and docs.
  • 555 are code: the start path (CloudStartSession.swift), its strings, the catalog entry, the account guards in AgentTabRemoteHost, CloudService and CloudHandlers, and one-line registrations.

This is already the second half of a split (the coordinator asked for it after nine rounds); the start path, its strings and its tests can't land apart without leaving a verb with no strings or no tests.

Tests

CloudStartSessionTests (the card and the runs registry are tested in #25414):

  • the descriptor is a CLI verb and an MCP tool that waits for the person
  • argument parsing, with a bad --agent and a bad --cwd refused
  • a script run with no machine
  • an agent outside a chat refused before any card shows
  • a sign-out or team switch while the agent's card asks refuses its run and clears the card (its stream waits end after 10 s)
  • closing the new chat's tab after Start says the tab closed and gives the command, both while the store creates it and while its start runs; a cancelled run stays cancelled
  • a step that failed after cancellation or an account change says so
  • a sign-out or team switch during a keyed start stops it before the next restart or start try, with and without the keyed reason; the same account runs both and reports the start
  • the retry command keeps the machine name one shell word
  • an agent's chat on a Cloud machine is found
  • --name with no machine list read (no sign-in) refuses, creates nothing, and gives the commands to check and rerun
  • only a fresh list of this account is current (old, never loaded, another team's)
  • a --target outside the list of the account signed in now starts nothing and says so
  • a run asked in another account starts nothing and says so; the account check refuses a change and stops a cancelled run
  • a nested run of an MCP run asks like an agent
  • the command to run again keeps every argument, quoted
  • the pending answer through the deadline race carries pending: true
  • the description
  • refusal mapping, including the folder cases
  • every hint is a CLI-offered verb

Agent session: Air-Blue

🤖 Generated with Claude Code