From c863d717cdde7d5cc53ec73b89356898b3daea14 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 25 Oct 2026 21:00:58 -0700 Subject: [PATCH 01/33] cloud: start an agent session on a Cloud machine from the CLI or an agent's chat `cmux cloud start-session --target machine: | --name --agent [--cwd ]` takes the machine (or creates the named one), connects, opens a chat tab on its store and starts the session in that machine's acpmux through agent-session-start (the keyed start a page's first send uses). It answers machine_id, session_id and host. An agent's run asks the person with a card in its own chat first (Start, Not now), like an agent's Move to Cloud. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- Packages/macOS/CmuxNext/Package.swift | 1 + .../CmuxNextActions/ActionCatalog.swift | 1 + .../Catalog/CloudSessionActionCatalog.swift | 37 +++ .../CloudSessionActions.xcstrings | 66 +++++ .../CmuxNextApp/AgentTabRemoteHost.swift | 7 + .../CmuxNextApp/Cloud/CloudMoveAsks.swift | 2 +- .../CmuxNextApp/Cloud/CloudService.swift | 2 + .../CmuxNextApp/Cloud/CloudStartSession.swift | 242 +++++++++++++++++ .../Cloud/CloudStartSessionStrings.swift | 73 ++++++ .../CmuxNextApp/Handlers/CloudHandlers.swift | 1 + .../Resources/CloudSessions.xcstrings | 246 ++++++++++++++++++ .../CloudStartSessionTests.swift | 122 +++++++++ skills/cmux-cloud-vm/references/commands.md | 15 ++ web/data/next-docs/pages.ts | 3 + 14 files changed, 817 insertions(+), 1 deletion(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/CloudSessionActionCatalog.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextActions/CloudSessionActions.xcstrings create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift diff --git a/Packages/macOS/CmuxNext/Package.swift b/Packages/macOS/CmuxNext/Package.swift index eed4dda7771e..0c7db787c5b0 100644 --- a/Packages/macOS/CmuxNext/Package.swift +++ b/Packages/macOS/CmuxNext/Package.swift @@ -1364,6 +1364,7 @@ let packageTargets: [Target] = uiLayerPreviewTargets + [ .process("HistoryActions.xcstrings"), .process("HangReportsActions.xcstrings"), .process("ImportActions.xcstrings"), + .process("CloudSessionActions.xcstrings"), .process("MusicActions.xcstrings"), .process("LayoutActions.xcstrings"), .process("NewTabActions.xcstrings"), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.swift index 19a3a41e2da1..f53b2985859c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.swift @@ -135,6 +135,7 @@ public nonisolated enum ActionCatalog { CloudActionCatalog.self, AccountActionCatalog.self, RemoteActionCatalog.self, + CloudSessionActionCatalog.self, MachineIconActionCatalog.self, SettingsActionCatalog.self, DensityActionCatalog.self, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/CloudSessionActionCatalog.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/CloudSessionActionCatalog.swift new file mode 100644 index 000000000000..0d2c2c135dd0 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/CloudSessionActionCatalog.swift @@ -0,0 +1,37 @@ +// `cmux cloud start-session` (Lawrence: "start new VMs from acp chat"): take a +// Cloud machine (or create a named one), start an agent session in that +// machine's own acpmux, and answer `{machine_id, session_id, host}`. Titles +// live in CloudSessionActions.xcstrings; the handler is CloudStartSession.swift. + +nonisolated enum CloudSessionActionCatalog: ActionCatalogGroup { + static func descriptors() -> [ActionDescriptor] { + var start = ActionDescriptor( + id: "cloudStartSession", + title: String(localized: "action.cloudStartSession", defaultValue: "Start Agent Session on Cloud Machine…", table: "CloudSessionActions", bundle: .module), + keywords: ["vm", "cloud", "agent", "session", "chat", "acp", "acpmux", "start", "claude", "codex"], category: .cloud, symbol: "sparkles", + surfaces: [.palette, .contextMenu], arguments: [agent, name, cwd], targets: [.machine], cliName: "cloud start-session", + surfacePlan: ActionSurfacePlan(cli: .offered, contextMenus: [ActionSurfaceCatalog.p(.cloudMachine, .connection, 221, folder: .connection)]) + ) + // An agent's run waits for the person's Start on its chat's card, then for the session. + start.waitsForPerson = true + return [start] + } + + /// `--agent`: the harness to start (`claude`, `codex`, ...), as the machine's acpmux names it. + static var agent: ActionArgument { + ActionArgument(name: "agent", title: String(localized: "argument.cloudStartSession.agent", defaultValue: "Agent", table: "CloudSessionActions", bundle: .module), + kind: .string) + } + + /// `--name`: without `--target`, the machine with this name, created when there is none. + static var name: ActionArgument { + ActionArgument(name: "name", title: String(localized: "argument.cloudStartSession.name", defaultValue: "Machine Name", table: "CloudSessionActions", bundle: .module), + kind: .string, isRequired: false) + } + + /// `--cwd`: the folder on the machine the session starts in (default: its workspace's folder). + static var cwd: ActionArgument { + ActionArgument(name: "cwd", title: String(localized: "argument.cloudStartSession.cwd", defaultValue: "Folder on the Machine", table: "CloudSessionActions", bundle: .module), + kind: .string, isRequired: false) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/CloudSessionActions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/CloudSessionActions.xcstrings new file mode 100644 index 000000000000..77ece6811faa --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/CloudSessionActions.xcstrings @@ -0,0 +1,66 @@ +{ + "sourceLanguage": "en", + "strings": { + "action.cloudStartSession": { + "comment": "Command title: starts an agent session (Claude, Codex, ...) on a Cloud machine.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Start Agent Session on Cloud Machine…"}}, + "ja": {"stringUnit": {"state": "translated", "value": "Cloud マシンでエージェントセッションを開始…"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Agent-Sitzung auf Cloud-Maschine starten…"}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Démarrer une session d'agent sur une machine Cloud…"}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Iniciar sesión de agente en una máquina de Cloud…"}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "بدء جلسة وكيل على جهاز Cloud…"}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "Cloud 머신에서 에이전트 세션 시작…"}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "在 Cloud 机器上启动代理会话…"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "在 Cloud 機器上啟動代理工作階段…"}} + } + }, + "argument.cloudStartSession.agent": { + "comment": "Argument title: the agent (harness) to start, such as claude or codex.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Agent"}}, + "ja": {"stringUnit": {"state": "translated", "value": "エージェント"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Agent"}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Agent"}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Agente"}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "الوكيل"}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "에이전트"}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "代理"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "代理"}} + } + }, + "argument.cloudStartSession.name": { + "comment": "Argument title: the Cloud machine's name; a machine with that name is created when none exists.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Machine Name"}}, + "ja": {"stringUnit": {"state": "translated", "value": "マシン名"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Maschinenname"}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Nom de la machine"}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Nombre de la máquina"}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "اسم الجهاز"}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "머신 이름"}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "机器名称"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "機器名稱"}} + } + }, + "argument.cloudStartSession.cwd": { + "comment": "Argument title: the folder on the Cloud machine the agent session starts in.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Folder on the Machine"}}, + "ja": {"stringUnit": {"state": "translated", "value": "マシン上のフォルダ"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Ordner auf der Maschine"}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Dossier sur la machine"}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Carpeta en la máquina"}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "المجلد على الجهاز"}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "머신의 폴더"}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "机器上的文件夹"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "機器上的資料夾"}} + } + } + }, + "version": "1.0" +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabRemoteHost.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabRemoteHost.swift index 91e43bf552eb..5eef793b223d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabRemoteHost.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabRemoteHost.swift @@ -86,6 +86,13 @@ nonisolated struct AgentTabRemoteHost: AgentPaneHostProviding { return handshake } + /// Starts the new chat now, as the page's first send would (`cmux cloud start-session`): the + /// same keyed start, in `cwd` (nil: the tab's seed folder, then its workspace's) with `harness`. + func startNow(cwd: String?, harness: String) async throws -> AgentTabRemoteStart.Started { + guard let start else { throw AgentRemoteStartRefusal.startUnsupported(machine: machine) } + return try await Self.keyedStart(cwd, harness, approvals: approvals, servesKeyedReason: servesKeyedReason, start: start) + } + /// The chat's Allow needs this app's key on that machine's agent daemon (cx-oa6ah): an idle /// daemon gets it before the start; a busy one waits for the person's restart click on the /// Allow notice. That machine's cmux-tui decides whether a restart loses nothing (cx-0w0jj): diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveAsks.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveAsks.swift index b09c8fa7d239..7594cb35c9c8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveAsks.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveAsks.swift @@ -171,7 +171,7 @@ final class CloudMoveAsks { } /// The local chat tab of agent session `session`, with its pane. - private static func chat(_ session: String, services: AppServices) -> (TabModel, PaneModel)? { + static func chat(_ session: String, services: AppServices) -> (TabModel, PaneModel)? { let tabs = services.agentTabs for pane in services.daemon.store.workspaces.flatMap(\.screens).flatMap(\.panes) { if let tab = pane.tabs.first(where: { (tabs.sessions[tabs.resolve($0.id)] ?? $0.agentSession?.session) == session }) { return (tab, pane) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift index e3ac47344a6a..290df9f88fbe 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift @@ -76,6 +76,8 @@ final class CloudService { /// Agents' asks to move their chats, and the cards they raise in chats. @ObservationIgnored let moveAsks = CloudMoveAsks() @ObservationIgnored let toolCards = AgentToolCards() + /// Agents' asks to start a session on a Cloud machine (``CloudStartSessionAsks``). + @ObservationIgnored let startSessionAsks = CloudStartSessionAsks() /// Moved chats' file requests for this Mac, asked in their chats. @ObservationIgnored let accessAsks = CloudAccessAsks() /// Files the person drops or pastes into a moved chat, sent to its machine (``CloudUploads``). diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift new file mode 100644 index 000000000000..c54e86160e32 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift @@ -0,0 +1,242 @@ +import CmuxNextActions +import CmuxNextAgentPane +import CmuxNextCloud +import CmuxNextDaemon +import Foundation + +/// `cmux cloud start-session` (Lawrence: "start new VMs from acp chat"): one call takes a Cloud +/// machine (`--target machine:`, or `--name`, created when no machine has that name), starts +/// an agent session in that machine's own acpmux, and answers `{machine_id, session_id, host}`. +/// +/// The session starts the way a new chat in that machine's workspace starts at its first send: a +/// chat tab on the machine's store (`registry:` host), then `agent-session-start` through the +/// machine's daemon, with this app's key when the machine takes it (``AgentTabRemoteHost/startNow``). +/// The chat's tab stays in the machine's first workspace, so the person can open it. +/// +/// An agent's run (the cmux MCP server, or a shell naming its chat) never starts anything itself: +/// like an agent's Move to Cloud (``CloudMoveAsks``), it asks the person with a card in its own chat +/// (Start, Not now), and only a click on Start starts the machine and the session. +@MainActor +struct CloudStartSession { + static let action: ActionID = "cloudStartSession" + let services: AppServices + + /// What the run asks for, checked before anything starts. + struct Request: Equatable { + var machineID: String? + /// Without a machine id: the machine with this name, created when none has it. + var name: String? + var agent: String + /// A folder on the machine; nil starts in the workspace's folder there. + var cwd: String? + } + + static func bind(into registry: ActionRegistry, context: AppActionContext, reason: @escaping @MainActor () -> String?) { + let services = context.services + CloudHandlers.bind(action, registry, reason: reason) { invocation in + var request = try Self.request(invocation) + let start = CloudStartSession(services: services) + guard CloudMoveAsks.isAgent(invocation) else { + // A person's palette or menu run without a machine means the one they look at. + if request.machineID == nil, request.name == nil, CloudHandlers.origin(of: invocation) == .user { + request.machineID = try CloudHandlers.machine(invocation, context).machineID + } + if request.machineID == nil, request.name == nil { throw ActionFailure(message: CloudStartSessionStrings.needsMachine(agent: request.agent)) } + return CloudHandlers.result("start cloud session", context) { try await start.run(request, invocation, context) } + } + guard request.machineID != nil || request.name != nil else { throw ActionFailure(message: CloudStartSessionStrings.needsMachine(agent: request.agent)) } + guard let session = invocation.callerAgentSession, let (tab, _) = CloudMoveAsks.chat(session, services: services) else { + throw ActionFailure(message: CloudStartSessionStrings.notInChat) + } + let shown = request.machineID.flatMap { services.machines.session($0)?.machine.title } + if let id = request.machineID, shown == nil { throw ActionFailure(message: CloudStartSessionStrings.noSuchMachine(id: id, agent: request.agent)) } + let named = shown ?? request.name ?? "" + let exists = shown != nil || services.machines.cloud.contains { $0.machine.title == named } + let asks = services.cloud.startSessionAsks + let text = exists ? CloudStartSessionStrings.ask(agent: request.agent, machine: named) : CloudStartSessionStrings.askNew(agent: request.agent, machine: named) + CloudHandlers.result("start cloud session", context) { + try await asks.ask(tab: tab.id, text: text, services: services) + asks.show(tab: tab.id, .working, CloudStartSessionStrings.starting(agent: request.agent, machine: named), services: services) + do { + // The person's Start is the gesture: the machine is created as theirs, at the agent's ask. + let fields = try await start.run(request, ActionInvocation(target: invocation.target, arguments: invocation.arguments, origin: .user), + context, askedBy: session, revealing: false) + asks.close(tab: tab.id, services: services) + return fields + } catch { + asks.show(tab: tab.id, .failed, (error as? ActionFailure)?.message ?? String(describing: error), services: services) + throw error + } + } + } + } + + /// The run's arguments, checked: `--agent` is a harness id, `--cwd` an absolute folder. + static func request(_ invocation: ActionInvocation) throws -> Request { + let agent = invocation["agent"]?.stringValue?.trimmingCharacters(in: .whitespaces) ?? "" + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789._-") + guard !agent.isEmpty, agent.utf8.count <= 64, agent.unicodeScalars.allSatisfy(allowed.contains), let first = agent.first, first.isLetter || first.isNumber else { + throw ActionFailure(message: CloudStartSessionStrings.agentInvalid) + } + var cwd = invocation["cwd"]?.stringValue?.trimmingCharacters(in: .whitespaces) + if cwd?.isEmpty == true { cwd = nil } + if let cwd, !cwd.hasPrefix("/") || cwd.utf8.count > 4096 || cwd.unicodeScalars.contains(where: { $0.properties.generalCategory == .control }) { + throw ActionFailure(message: CloudStartSessionStrings.cwdInvalid) + } + let machineID = invocation.target.flatMap { $0.kind == .machine ? $0.id : nil } + let name = invocation["name"]?.stringValue?.trimmingCharacters(in: .whitespaces) + return Request(machineID: machineID, name: name?.isEmpty == false ? name : nil, agent: agent, cwd: cwd) + } + + /// Takes or creates the machine, connects, opens the chat's tab there and starts the session. + func run(_ request: Request, _ invocation: ActionInvocation, _ context: AppActionContext, askedBy: String? = nil, + revealing: Bool = true) async throws -> [String: ActionResultValue] { + let session = try await machine(request, invocation, askedBy: askedBy) + let name = session.machine.title, id = session.machineID, agent = request.agent + try await connected(session, invocation, request) + let daemon = session.daemon + guard let host = AgentTabStore.registryHost(of: daemon) else { + throw ActionFailure(message: CloudStartSessionStrings.notConnected(name, id: id, agent: agent)) + } + let anchor: (id: String, key: WorkspaceKey, pane: PaneModel) + do { + anchor = try await CloudHandlers.terminalAnchor(on: session, context) + } catch { + throw ActionFailure(message: CloudStartSessionStrings.notConnected(name, id: id, agent: agent)) + } + let started: AgentTabRemoteStart.Started + let tabKey: String + do { + let tabs = services.agentTabs + let pending = try tabs.open(in: anchor.pane.handle, of: daemon, seed: request.cwd.map { AgentPaneSeedSource(AgentPaneSeed(cwd: $0)) }) + tabKey = try await pending.value().key + // The start reads the tab as the machine's tree lists it. + _ = await CloudMove.holds(within: .seconds(10)) { [services] in services.locateTab(tabKey) != nil } + switch tabs.remoteHost(tabKey) { + case let remote as AgentTabRemoteHost: started = try await remote.startNow(cwd: request.cwd, harness: agent) + case let refused as AgentTabRefusedHost: throw refused.refusal + default: throw AgentRemoteStartRefusal.startUnsupported(machine: name) + } + } catch { + throw ActionFailure(message: Self.startFailure(error, name: name, id: id, agent: agent)) + } + // Where a hook that saves the session's trajectory off the machine (hq#4381) would go, once + // acpmux has one: the session started, so its id is known here. None exists on base yet. + if revealing, invocation.allowsViewChange { CloudHandlers.show(anchor.id, context) } + return [ + "machine_id": .string(id), "machine_name": .string(name), "target": .string("machine:\(id)"), + "session_id": .string(started.session), "host": .string(host), "agent": .string(agent), + "workspace_id": .string(anchor.id), "surface_id": .string(String(started.surface)), "tab_id": .string(tabKey), + ] + } + + /// The start's refusal in the run's words: the machine is up, its agent is not. + static func startFailure(_ error: any Error, name: String, id: String, agent: String) -> String { + if let refusal = error as? AgentRemoteStartRefusal { + switch refusal.code { + case AgentRemoteStartRefusal.folderNeededCode, "agent_session.home_unconfirmed", "agent_session.above_home": + return CloudStartSessionStrings.needsFolder(name, id: id, agent: agent) + default: + return CloudStartSessionStrings.agentFailed(name, id: id, agent: agent, reason: Self.reason(refusal.userMessage)) + } + } + let reason = (error as? AgentTabRefusal)?.message ?? (error as? ActionFailure)?.message ?? String(describing: error) + return CloudStartSessionStrings.agentFailed(name, id: id, agent: agent, reason: Self.reason(reason)) + } + + /// A refusal sentence inside the run's parentheses: no closing period. + private static func reason(_ text: String) -> String { + var text = text.trimmingCharacters(in: .whitespacesAndNewlines) + while text.hasSuffix(".") { text.removeLast() } + return text + } + + /// The machine named by id, else by name, else a new one with that name. + private func machine(_ request: Request, _ invocation: ActionInvocation, askedBy: String?) async throws -> CloudMachineSession { + let machines = services.machines + if let id = request.machineID { + guard let session = machines.session(id) else { throw ActionFailure(message: CloudStartSessionStrings.noSuchMachine(id: id, agent: request.agent)) } + return session + } + let name = request.name ?? "" + if let named = machines.cloud.first(where: { $0.machine.title == name }) { return named } + return try await services.cloud.createMachine(name: name, startedByPerson: invocation.origin == .user, askedBy: askedBy ?? invocation.callerAgentSession) + } + + /// The machine connected with its store's id: a paused one resumes, a new one comes up. Nothing + /// here waits on a client snapshot (hq#4381): the machine's own acpmux keeps its sessions. + private func connected(_ session: CloudMachineSession, _ invocation: ActionInvocation, _ request: Request) async throws { + let failure = { ActionFailure(message: CloudStartSessionStrings.notConnected(session.machine.title, id: session.machineID, agent: request.agent)) } + if session.daemon.connection == nil { + await session.refreshIfMaybePaused() + let origin = CloudHandlers.connectOrigin(for: invocation) + if session.machine.status == .paused { session.attach(origin: origin) } else { session.connect(origin: origin) } + } + let daemon = session.daemon + guard await CloudMove.holds(within: Self.connectWait, until: { + session.resumeFailure != nil || (daemon.connection != nil && AgentTabStore.registryHost(of: daemon) != nil) + }) else { throw failure() } + if session.resumeFailure != nil { throw failure() } + } + + /// How long a machine has to come up (Move to Cloud's own wait). + static let connectWait: Duration = .seconds(180) +} + +/// An agent's ask to start a Cloud session, as a card in its own chat (``AgentToolCards``): the +/// agent's run waits for the person's Start or Not now, for at most ``wait``. +@MainActor +final class CloudStartSessionAsks { + static let owner = "cloudStartSession" + /// How long a card waits for the person; the run says so after. + static let wait: Duration = .seconds(120) + private var open: Set = [] + + /// Shows the card in chat tab `tab` and returns once the person clicks Start. Throws when they + /// choose Not now, when nobody answers within ``wait``, or when the chat has no page. + func ask(tab: String, text: String, services: AppServices) async throws { + guard !open.contains(tab) else { throw ActionFailure(message: CloudStartSessionStrings.askOpen) } + let (answers, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingOldest(1)) + let actions = [AgentPaneToolCard.Action(id: "start", title: CloudStartSessionStrings.start), + AgentPaneToolCard.Action(id: "notNow", title: CloudMoveAskStrings.notNow)] + let card = AgentPaneToolCard(id: "cloud-start-session-" + tab, state: .ask, text: text, actions: actions) + guard services.cloud.toolCards.set([card], owner: Self.owner, tab: tab, answer: { _, action, _ in continuation.yield(action == "start") }) else { + throw ActionFailure(message: CloudStartSessionStrings.noPage) + } + open.insert(tab) + // task-owner: the card's one-shot deadline; cancelled below once the person answers. + let deadline = Task { + // wakeup-allow: one-shot deadline for the person's answer on the card + try? await Task.sleep(for: Self.wait) + continuation.yield(nil) + } + defer { + deadline.cancel() + continuation.finish() + open.remove(tab) + } + var answer: Bool? + for await value in answers { + answer = value + break + } + guard answer == true else { + close(tab: tab, services: services) + throw ActionFailure(message: answer == false ? CloudStartSessionStrings.declined : CloudStartSessionStrings.expired) + } + } + + /// The card after Start: working, or failed with the reason and Dismiss. + func show(tab: String, _ state: AgentPaneToolCard.State, _ text: String, services: AppServices) { + let actions = state == .failed ? [AgentPaneToolCard.Action(id: "dismiss", title: CloudMoveAskStrings.dismiss)] : [] + let card = AgentPaneToolCard(id: "cloud-start-session-" + tab, state: state, text: text, actions: actions) + services.cloud.toolCards.set([card], owner: Self.owner, tab: tab) { [weak self, weak services] _, _, _ in + guard let self, let services else { return } + close(tab: tab, services: services) + } + } + + func close(tab: String, services: AppServices) { + services.cloud.toolCards.set([], owner: Self.owner, tab: tab) { _, _, _ in } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift new file mode 100644 index 000000000000..2f974042cec2 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift @@ -0,0 +1,73 @@ +import Foundation + +/// What `cmux cloud start-session` says (``CloudStartSession``). Each failure names the machine +/// and the agent and gives the command to run next. Keys live in Resources/CloudSessions.xcstrings +/// with every app locale. `%1$@` is the machine's name, `%2$@` its id, `%3$@` the agent. +enum CloudStartSessionStrings { + /// A non-person run that names no machine. + static func needsMachine(agent: String) -> String { + String(format: String(localized: "cloud.startSession.needsMachine", defaultValue: "Say which Cloud machine to start %1$@ on: `cmux cloud start-session --target machine: --agent %1$@`, or `--name ` to take that machine or create it. `cmux cloud list-machines` lists your machines.", table: "CloudSessions", bundle: .module), agent) + } + + static func noSuchMachine(id: String, agent: String) -> String { + String(format: String(localized: "cloud.startSession.noSuchMachine", defaultValue: "No Cloud machine `%1$@` is listed on this Mac. `cmux cloud list-machines` lists them, and `cmux cloud start-session --name --agent %2$@` creates one.", table: "CloudSessions", bundle: .module), id, agent) + } + + static var agentInvalid: String { + String(localized: "cloud.startSession.agentInvalid", defaultValue: "Pass the agent to start with --agent, for example `cmux cloud start-session --name dev --agent claude`.", table: "CloudSessions", bundle: .module) + } + + static var cwdInvalid: String { + String(localized: "cloud.startSession.cwdInvalid", defaultValue: "Pass --cwd as an absolute folder on the machine, for example --cwd /home/cmux/work.", table: "CloudSessions", bundle: .module) + } + + static func notConnected(_ name: String, id: String, agent: String) -> String { + String(format: String(localized: "cloud.startSession.notConnected", defaultValue: "Machine `%1$@` didn't connect, so no agent started there. Check it with `cmux cloud machine-status --target machine:%2$@`, then run `cmux cloud start-session --target machine:%2$@ --agent %3$@` again.", table: "CloudSessions", bundle: .module), name, id, agent) + } + + /// The machine is up and the start was refused; `reason` is the refusal in plain words. + static func agentFailed(_ name: String, id: String, agent: String, reason: String) -> String { + String(format: String(localized: "cloud.startSession.agentFailed", defaultValue: "Machine `%1$@` started but its agent didn't (%4$@). Run `cmux cloud start-session --target machine:%2$@ --agent %3$@` again.", table: "CloudSessions", bundle: .module), name, id, agent, reason) + } + + /// The start found no folder it may use (none in the workspace, or the home folder). + static func needsFolder(_ name: String, id: String, agent: String) -> String { + String(format: String(localized: "cloud.startSession.needsFolder", defaultValue: "Machine `%1$@` started but its agent didn't: it needs a folder on that machine to start in. Run `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` again with a folder there.", table: "CloudSessions", bundle: .module), name, id, agent) + } + + // MARK: An agent's ask in its chat + + static func ask(agent: String, machine: String) -> String { + String(format: String(localized: "cloud.startSession.ask", defaultValue: "The agent asks to start a %1$@ session on Cloud machine %2$@.", table: "CloudSessions", bundle: .module), agent, machine) + } + + static func askNew(agent: String, machine: String) -> String { + String(format: String(localized: "cloud.startSession.askNew", defaultValue: "The agent asks to create Cloud machine %2$@ and start a %1$@ session there.", table: "CloudSessions", bundle: .module), agent, machine) + } + + static func starting(agent: String, machine: String) -> String { + String(format: String(localized: "cloud.startSession.starting", defaultValue: "Starting %1$@ on %2$@…", table: "CloudSessions", bundle: .module), agent, machine) + } + + static var start: String { String(localized: "cloud.startSession.start", defaultValue: "Start", table: "CloudSessions", bundle: .module) } + + static var notInChat: String { + String(localized: "cloud.startSession.notInChat", defaultValue: "Couldn't find the chat this agent runs in, so there is nowhere to ask the person. Run `cmux cloud start-session` from an agent's shell in a cmux chat, or ask the person to run it.", table: "CloudSessions", bundle: .module) + } + + static var noPage: String { + String(localized: "cloud.startSession.noPage", defaultValue: "Couldn't show the question in the chat, so nothing started. Run `cmux cloud start-session` again once the chat is open.", table: "CloudSessions", bundle: .module) + } + + static var askOpen: String { + String(localized: "cloud.startSession.askOpen", defaultValue: "This chat already shows a question to start a Cloud session. Wait for the person's answer before you run `cmux cloud start-session` again.", table: "CloudSessions", bundle: .module) + } + + static var declined: String { + String(localized: "cloud.startSession.declined", defaultValue: "The person chose Not now, so nothing started. Ask them in the chat before you run `cmux cloud start-session` again.", table: "CloudSessions", bundle: .module) + } + + static var expired: String { + String(localized: "cloud.startSession.expired", defaultValue: "Nobody answered the question in the chat within 2 minutes, so nothing started. Run `cmux cloud start-session` again when the person is there.", table: "CloudSessions", bundle: .module) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift index 3d12480b7c74..a17f9bb39b0b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift @@ -20,6 +20,7 @@ enum CloudHandlers { bindNetworkActions(into: registry, context: context, reason: signedInReason) bindDomainActions(into: registry, context: context, reason: signedInReason) bindListActions(into: registry, context: context) + CloudStartSession.bind(into: registry, context: context, reason: signedInReason) bindCreation(into: registry, context: context, reason: signedInReason) bindAccount(into: registry, context: context, reason: reason) bindMove(into: registry, context: context) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings new file mode 100644 index 000000000000..75a4beb84cdf --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings @@ -0,0 +1,246 @@ +{ + "sourceLanguage": "en", + "strings": { + "cloud.startSession.needsMachine": { + "comment": "cmux cloud start-session run by a script or agent without a machine. %1$@ is the agent (claude). Keep the commands as written.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Say which Cloud machine to start %1$@ on: `cmux cloud start-session --target machine: --agent %1$@`, or `--name ` to take that machine or create it. `cmux cloud list-machines` lists your machines."}}, + "ja": {"stringUnit": {"state": "translated", "value": "%1$@ を開始する Cloud マシンを指定してください: `cmux cloud start-session --target machine: --agent %1$@`、または `--name ` でその名前のマシンを使うか作成します。`cmux cloud list-machines` でマシンを一覧表示できます。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Gib an, auf welcher Cloud-Maschine %1$@ starten soll: `cmux cloud start-session --target machine: --agent %1$@`, oder `--name `, um diese Maschine zu nehmen oder zu erstellen. `cmux cloud list-machines` listet deine Maschinen."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Indiquez sur quelle machine Cloud démarrer %1$@ : `cmux cloud start-session --target machine: --agent %1$@`, ou `--name ` pour prendre cette machine ou la créer. `cmux cloud list-machines` liste vos machines."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Indica en qué máquina de Cloud iniciar %1$@: `cmux cloud start-session --target machine: --agent %1$@`, o `--name ` para usar esa máquina o crearla. `cmux cloud list-machines` muestra tus máquinas."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "حدّد جهاز Cloud الذي يبدأ عليه %1$@: `cmux cloud start-session --target machine: --agent %1$@`، أو `--name ` لاستخدام ذلك الجهاز أو إنشائه. يعرض `cmux cloud list-machines` أجهزتك."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "%1$@을(를) 시작할 Cloud 머신을 지정하세요: `cmux cloud start-session --target machine: --agent %1$@`, 또는 `--name `으로 그 머신을 사용하거나 만듭니다. `cmux cloud list-machines`로 머신 목록을 볼 수 있습니다."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "请指定在哪台 Cloud 机器上启动 %1$@:`cmux cloud start-session --target machine: --agent %1$@`,或用 `--name ` 使用或创建该机器。`cmux cloud list-machines` 会列出你的机器。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "請指定在哪台 Cloud 機器上啟動 %1$@:`cmux cloud start-session --target machine: --agent %1$@`,或用 `--name ` 使用或建立該機器。`cmux cloud list-machines` 會列出你的機器。"}} + } + }, + "cloud.startSession.noSuchMachine": { + "comment": "cmux cloud start-session --target names no listed machine. %1$@ is the machine id, %2$@ the agent.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "No Cloud machine `%1$@` is listed on this Mac. `cmux cloud list-machines` lists them, and `cmux cloud start-session --name --agent %2$@` creates one."}}, + "ja": {"stringUnit": {"state": "translated", "value": "Cloud マシン `%1$@` はこの Mac に表示されていません。`cmux cloud list-machines` で一覧を表示し、`cmux cloud start-session --name --agent %2$@` で作成できます。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Keine Cloud-Maschine `%1$@` ist auf diesem Mac aufgeführt. `cmux cloud list-machines` listet sie, und `cmux cloud start-session --name --agent %2$@` erstellt eine."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Aucune machine Cloud `%1$@` n'est listée sur ce Mac. `cmux cloud list-machines` les liste, et `cmux cloud start-session --name --agent %2$@` en crée une."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "No hay ninguna máquina de Cloud `%1$@` en este Mac. `cmux cloud list-machines` las muestra y `cmux cloud start-session --name --agent %2$@` crea una."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "لا يوجد جهاز Cloud باسم `%1$@` في قائمة هذا الـ Mac. يعرضها `cmux cloud list-machines`، وينشئ `cmux cloud start-session --name --agent %2$@` جهازًا جديدًا."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "이 Mac에 Cloud 머신 `%1$@`이(가) 없습니다. `cmux cloud list-machines`로 목록을 보고 `cmux cloud start-session --name --agent %2$@`로 만들 수 있습니다."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "这台 Mac 上没有 Cloud 机器 `%1$@`。`cmux cloud list-machines` 会列出机器,`cmux cloud start-session --name --agent %2$@` 会创建一台。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "這台 Mac 上沒有 Cloud 機器 `%1$@`。`cmux cloud list-machines` 會列出機器,`cmux cloud start-session --name --agent %2$@` 會建立一台。"}} + } + }, + "cloud.startSession.agentInvalid": { + "comment": "cmux cloud start-session without a valid --agent. Keep the command as written.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Pass the agent to start with --agent, for example `cmux cloud start-session --name dev --agent claude`."}}, + "ja": {"stringUnit": {"state": "translated", "value": "開始するエージェントを --agent で指定してください (例: `cmux cloud start-session --name dev --agent claude`)。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Gib den zu startenden Agent mit --agent an, zum Beispiel `cmux cloud start-session --name dev --agent claude`."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Indiquez l'agent à démarrer avec --agent, par exemple `cmux cloud start-session --name dev --agent claude`."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Indica el agente que se iniciará con --agent, por ejemplo `cmux cloud start-session --name dev --agent claude`."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "مرّر الوكيل المراد بدؤه باستخدام ‎--agent، مثل `cmux cloud start-session --name dev --agent claude`."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "--agent로 시작할 에이전트를 지정하세요(예: `cmux cloud start-session --name dev --agent claude`)."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "请用 --agent 指定要启动的代理,例如 `cmux cloud start-session --name dev --agent claude`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "請用 --agent 指定要啟動的代理,例如 `cmux cloud start-session --name dev --agent claude`。"}} + } + }, + "cloud.startSession.cwdInvalid": { + "comment": "cmux cloud start-session with a relative or bad --cwd. Keep --cwd /home/cmux/work as written.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Pass --cwd as an absolute folder on the machine, for example --cwd /home/cmux/work."}}, + "ja": {"stringUnit": {"state": "translated", "value": "--cwd にはマシン上の絶対パスのフォルダを指定してください (例: --cwd /home/cmux/work)。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Gib --cwd als absoluten Ordner auf der Maschine an, zum Beispiel --cwd /home/cmux/work."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Indiquez --cwd sous forme de dossier absolu sur la machine, par exemple --cwd /home/cmux/work."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Indica --cwd como una carpeta absoluta en la máquina, por ejemplo --cwd /home/cmux/work."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "مرّر ‎--cwd كمجلد بمسار مطلق على الجهاز، مثل ‎--cwd /home/cmux/work."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "--cwd에는 머신의 절대 경로 폴더를 지정하세요(예: --cwd /home/cmux/work)."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "请将 --cwd 设为机器上的绝对路径文件夹,例如 --cwd /home/cmux/work。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "請將 --cwd 設為機器上的絕對路徑資料夾,例如 --cwd /home/cmux/work。"}} + } + }, + "cloud.startSession.notConnected": { + "comment": "The machine never connected. %1$@ is its name, %2$@ its id, %3$@ the agent.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Machine `%1$@` didn't connect, so no agent started there. Check it with `cmux cloud machine-status --target machine:%2$@`, then run `cmux cloud start-session --target machine:%2$@ --agent %3$@` again."}}, + "ja": {"stringUnit": {"state": "translated", "value": "マシン `%1$@` に接続できなかったため、エージェントは開始されませんでした。`cmux cloud machine-status --target machine:%2$@` で確認してから、`cmux cloud start-session --target machine:%2$@ --agent %3$@` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Maschine `%1$@` hat sich nicht verbunden, daher wurde dort kein Agent gestartet. Prüfe sie mit `cmux cloud machine-status --target machine:%2$@` und führe dann `cmux cloud start-session --target machine:%2$@ --agent %3$@` erneut aus."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "La machine `%1$@` ne s'est pas connectée, aucun agent n'y a donc démarré. Vérifiez-la avec `cmux cloud machine-status --target machine:%2$@`, puis relancez `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "La máquina `%1$@` no se conectó, así que no se inició ningún agente. Revísala con `cmux cloud machine-status --target machine:%2$@` y vuelve a ejecutar `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "لم يتصل الجهاز `%1$@`، لذا لم يبدأ أي وكيل عليه. افحصه باستخدام `cmux cloud machine-status --target machine:%2$@`، ثم شغّل `cmux cloud start-session --target machine:%2$@ --agent %3$@` مرة أخرى."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "머신 `%1$@`이(가) 연결되지 않아 에이전트가 시작되지 않았습니다. `cmux cloud machine-status --target machine:%2$@`로 확인한 다음 `cmux cloud start-session --target machine:%2$@ --agent %3$@`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "机器 `%1$@` 未连接,因此没有启动代理。请用 `cmux cloud machine-status --target machine:%2$@` 检查,然后再次运行 `cmux cloud start-session --target machine:%2$@ --agent %3$@`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "機器 `%1$@` 未連線,因此沒有啟動代理。請用 `cmux cloud machine-status --target machine:%2$@` 檢查,然後再次執行 `cmux cloud start-session --target machine:%2$@ --agent %3$@`。"}} + } + }, + "cloud.startSession.agentFailed": { + "comment": "The machine is up but the agent session didn't start. %1$@ name, %2$@ id, %3$@ agent, %4$@ the reason in plain words.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Machine `%1$@` started but its agent didn't (%4$@). Run `cmux cloud start-session --target machine:%2$@ --agent %3$@` again."}}, + "ja": {"stringUnit": {"state": "translated", "value": "マシン `%1$@` は起動しましたが、エージェントは開始されませんでした (%4$@)。`cmux cloud start-session --target machine:%2$@ --agent %3$@` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Maschine `%1$@` ist gestartet, ihr Agent aber nicht (%4$@). Führe `cmux cloud start-session --target machine:%2$@ --agent %3$@` erneut aus."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "La machine `%1$@` a démarré, mais pas son agent (%4$@). Relancez `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "La máquina `%1$@` se inició, pero su agente no (%4$@). Vuelve a ejecutar `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "بدأ الجهاز `%1$@` لكن وكيله لم يبدأ (%4$@). شغّل `cmux cloud start-session --target machine:%2$@ --agent %3$@` مرة أخرى."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "머신 `%1$@`은(는) 시작됐지만 에이전트는 시작되지 않았습니다(%4$@). `cmux cloud start-session --target machine:%2$@ --agent %3$@`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "机器 `%1$@` 已启动,但其代理未启动(%4$@)。请再次运行 `cmux cloud start-session --target machine:%2$@ --agent %3$@`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "機器 `%1$@` 已啟動,但其代理未啟動(%4$@)。請再次執行 `cmux cloud start-session --target machine:%2$@ --agent %3$@`。"}} + } + }, + "cloud.startSession.needsFolder": { + "comment": "The agent session needs a folder on the machine. %1$@ name, %2$@ id, %3$@ agent. Keep the command as written.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Machine `%1$@` started but its agent didn't: it needs a folder on that machine to start in. Run `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` again with a folder there."}}, + "ja": {"stringUnit": {"state": "translated", "value": "マシン `%1$@` は起動しましたが、エージェントは開始されませんでした: そのマシン上の開始フォルダが必要です。そのマシンのフォルダを指定して `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Maschine `%1$@` ist gestartet, ihr Agent aber nicht: Er braucht einen Ordner auf dieser Maschine zum Starten. Führe `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` erneut mit einem Ordner dort aus."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "La machine `%1$@` a démarré, mais pas son agent : il lui faut un dossier sur cette machine où démarrer. Relancez `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` avec un dossier de cette machine."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "La máquina `%1$@` se inició, pero su agente no: necesita una carpeta en esa máquina donde iniciarse. Vuelve a ejecutar `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` con una carpeta de allí."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "بدأ الجهاز `%1$@` لكن وكيله لم يبدأ: يحتاج إلى مجلد على ذلك الجهاز ليبدأ فيه. شغّل `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` مرة أخرى مع مجلد هناك."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "머신 `%1$@`은(는) 시작됐지만 에이전트는 시작되지 않았습니다. 그 머신에서 시작할 폴더가 필요합니다. 그 머신의 폴더로 `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "机器 `%1$@` 已启动,但其代理未启动:它需要在该机器上的一个文件夹中启动。请用该机器上的文件夹再次运行 `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "機器 `%1$@` 已啟動,但其代理未啟動:它需要在該機器上的一個資料夾中啟動。請用該機器上的資料夾再次執行 `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work`。"}} + } + }, + "cloud.startSession.ask": { + "comment": "Card in an agent's chat. %1$@ is the agent (claude), %2$@ the Cloud machine's name.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "The agent asks to start a %1$@ session on Cloud machine %2$@."}}, + "ja": {"stringUnit": {"state": "translated", "value": "エージェントが Cloud マシン %2$@ で %1$@ セッションの開始を求めています。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Der Agent möchte eine %1$@-Sitzung auf der Cloud-Maschine %2$@ starten."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "L'agent demande à démarrer une session %1$@ sur la machine Cloud %2$@."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "El agente pide iniciar una sesión de %1$@ en la máquina de Cloud %2$@."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "يطلب الوكيل بدء جلسة %1$@ على جهاز Cloud ‏%2$@."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "에이전트가 Cloud 머신 %2$@에서 %1$@ 세션을 시작하려고 합니다."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "代理请求在 Cloud 机器 %2$@ 上启动 %1$@ 会话。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "代理請求在 Cloud 機器 %2$@ 上啟動 %1$@ 工作階段。"}} + } + }, + "cloud.startSession.askNew": { + "comment": "Card in an agent's chat when the machine doesn't exist yet. %1$@ is the agent, %2$@ the new machine's name.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "The agent asks to create Cloud machine %2$@ and start a %1$@ session there."}}, + "ja": {"stringUnit": {"state": "translated", "value": "エージェントが Cloud マシン %2$@ を作成し、そこで %1$@ セッションを開始することを求めています。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Der Agent möchte die Cloud-Maschine %2$@ erstellen und dort eine %1$@-Sitzung starten."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "L'agent demande à créer la machine Cloud %2$@ et à y démarrer une session %1$@."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "El agente pide crear la máquina de Cloud %2$@ e iniciar allí una sesión de %1$@."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "يطلب الوكيل إنشاء جهاز Cloud ‏%2$@ وبدء جلسة %1$@ عليه."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "에이전트가 Cloud 머신 %2$@을(를) 만들고 그곳에서 %1$@ 세션을 시작하려고 합니다."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "代理请求创建 Cloud 机器 %2$@ 并在其上启动 %1$@ 会话。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "代理請求建立 Cloud 機器 %2$@ 並在其上啟動 %1$@ 工作階段。"}} + } + }, + "cloud.startSession.starting": { + "comment": "Card while the session starts. %1$@ is the agent, %2$@ the machine.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Starting %1$@ on %2$@…"}}, + "ja": {"stringUnit": {"state": "translated", "value": "%2$@ で %1$@ を開始しています…"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "%1$@ wird auf %2$@ gestartet…"}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Démarrage de %1$@ sur %2$@…"}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Iniciando %1$@ en %2$@…"}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "جارٍ بدء %1$@ على %2$@…"}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "%2$@에서 %1$@ 시작 중…"}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "正在 %2$@ 上启动 %1$@…"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "正在 %2$@ 上啟動 %1$@…"}} + } + }, + "cloud.startSession.start": { + "comment": "Button on the card: start the agent session.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Start"}}, + "ja": {"stringUnit": {"state": "translated", "value": "開始"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Starten"}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Démarrer"}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Iniciar"}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "بدء"}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "시작"}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "启动"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "啟動"}} + } + }, + "cloud.startSession.notInChat": { + "comment": "An agent's run whose chat cmux can't find. Keep the command as written.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Couldn't find the chat this agent runs in, so there is nowhere to ask the person. Run `cmux cloud start-session` from an agent's shell in a cmux chat, or ask the person to run it."}}, + "ja": {"stringUnit": {"state": "translated", "value": "このエージェントが動いているチャットが見つからないため、確認できる場所がありません。cmux チャット内のエージェントのシェルから `cmux cloud start-session` を実行するか、本人に実行を依頼してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Der Chat dieses Agents wurde nicht gefunden, daher gibt es keinen Ort für die Frage. Führe `cmux cloud start-session` aus der Shell eines Agents in einem cmux-Chat aus oder bitte die Person, es auszuführen."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Impossible de trouver la discussion de cet agent : il n'y a nulle part où poser la question. Lancez `cmux cloud start-session` depuis le shell d'un agent dans une discussion cmux, ou demandez à la personne de le lancer."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "No se encontró el chat de este agente, así que no hay dónde preguntar. Ejecuta `cmux cloud start-session` desde la shell de un agente en un chat de cmux, o pide a la persona que lo ejecute."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "تعذّر العثور على الدردشة التي يعمل فيها هذا الوكيل، لذا لا يوجد مكان لطرح السؤال. شغّل `cmux cloud start-session` من صدفة وكيل في دردشة cmux، أو اطلب من الشخص تشغيله."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "이 에이전트가 실행 중인 채팅을 찾지 못해 물어볼 곳이 없습니다. cmux 채팅 속 에이전트의 셸에서 `cmux cloud start-session`을(를) 실행하거나 사용자에게 실행을 요청하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "找不到此代理所在的聊天,因此无处询问。请从 cmux 聊天中代理的 shell 运行 `cmux cloud start-session`,或请对方运行。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "找不到此代理所在的聊天,因此無處詢問。請從 cmux 聊天中代理的 shell 執行 `cmux cloud start-session`,或請對方執行。"}} + } + }, + "cloud.startSession.noPage": { + "comment": "The card couldn't show in the chat. Keep the command as written.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Couldn't show the question in the chat, so nothing started. Run `cmux cloud start-session` again once the chat is open."}}, + "ja": {"stringUnit": {"state": "translated", "value": "チャットに確認を表示できなかったため、何も開始されませんでした。チャットを開いてから `cmux cloud start-session` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Die Frage konnte im Chat nicht angezeigt werden, daher wurde nichts gestartet. Führe `cmux cloud start-session` erneut aus, sobald der Chat geöffnet ist."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Impossible d'afficher la question dans la discussion : rien n'a démarré. Relancez `cmux cloud start-session` une fois la discussion ouverte."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "No se pudo mostrar la pregunta en el chat, así que no se inició nada. Vuelve a ejecutar `cmux cloud start-session` cuando el chat esté abierto."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "تعذّر عرض السؤال في الدردشة، لذا لم يبدأ شيء. شغّل `cmux cloud start-session` مرة أخرى بعد فتح الدردشة."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "채팅에 질문을 표시하지 못해 아무것도 시작되지 않았습니다. 채팅이 열리면 `cmux cloud start-session`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "无法在聊天中显示问题,因此没有启动任何内容。聊天打开后请再次运行 `cmux cloud start-session`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "無法在聊天中顯示問題,因此沒有啟動任何內容。聊天開啟後請再次執行 `cmux cloud start-session`。"}} + } + }, + "cloud.startSession.askOpen": { + "comment": "A second ask while the chat's card is still open. Keep the command as written.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "This chat already shows a question to start a Cloud session. Wait for the person's answer before you run `cmux cloud start-session` again."}}, + "ja": {"stringUnit": {"state": "translated", "value": "このチャットにはすでに Cloud セッション開始の確認が表示されています。本人の回答を待ってから `cmux cloud start-session` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Dieser Chat zeigt schon eine Frage zum Starten einer Cloud-Sitzung. Warte auf die Antwort der Person, bevor du `cmux cloud start-session` erneut ausführst."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Cette discussion affiche déjà une question pour démarrer une session Cloud. Attendez la réponse de la personne avant de relancer `cmux cloud start-session`."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Este chat ya muestra una pregunta para iniciar una sesión de Cloud. Espera la respuesta de la persona antes de volver a ejecutar `cmux cloud start-session`."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "تعرض هذه الدردشة بالفعل سؤالًا لبدء جلسة Cloud. انتظر إجابة الشخص قبل تشغيل `cmux cloud start-session` مرة أخرى."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "이 채팅에는 이미 Cloud 세션 시작 질문이 표시되어 있습니다. 사용자의 답을 기다린 다음 `cmux cloud start-session`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "此聊天已显示启动 Cloud 会话的问题。请等对方回答后再运行 `cmux cloud start-session`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "此聊天已顯示啟動 Cloud 工作階段的問題。請等對方回答後再執行 `cmux cloud start-session`。"}} + } + }, + "cloud.startSession.declined": { + "comment": "The person chose Not now on the card. Keep the command as written.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "The person chose Not now, so nothing started. Ask them in the chat before you run `cmux cloud start-session` again."}}, + "ja": {"stringUnit": {"state": "translated", "value": "「後で」が選ばれたため、何も開始されませんでした。`cmux cloud start-session` をもう一度実行する前に、チャットで確認してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Die Person hat „Nicht jetzt“ gewählt, daher wurde nichts gestartet. Frag sie im Chat, bevor du `cmux cloud start-session` erneut ausführst."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "La personne a choisi Pas maintenant : rien n'a démarré. Demandez-lui dans la discussion avant de relancer `cmux cloud start-session`."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "La persona eligió Ahora no, así que no se inició nada. Pregúntale en el chat antes de volver a ejecutar `cmux cloud start-session`."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "اختار الشخص «ليس الآن»، لذا لم يبدأ شيء. اسأله في الدردشة قبل تشغيل `cmux cloud start-session` مرة أخرى."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "사용자가 나중에를 선택해 아무것도 시작되지 않았습니다. `cmux cloud start-session`을(를) 다시 실행하기 전에 채팅에서 물어보세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "对方选择了“以后再说”,因此没有启动任何内容。再次运行 `cmux cloud start-session` 前请先在聊天中询问。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "對方選擇了「以後再說」,因此沒有啟動任何內容。再次執行 `cmux cloud start-session` 前請先在聊天中詢問。"}} + } + }, + "cloud.startSession.expired": { + "comment": "Nobody answered the card in time. Keep the command as written.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Nobody answered the question in the chat within 2 minutes, so nothing started. Run `cmux cloud start-session` again when the person is there."}}, + "ja": {"stringUnit": {"state": "translated", "value": "2 分以内にチャットの確認に回答がなかったため、何も開始されませんでした。本人がいるときに `cmux cloud start-session` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Niemand hat die Frage im Chat innerhalb von 2 Minuten beantwortet, daher wurde nichts gestartet. Führe `cmux cloud start-session` erneut aus, wenn die Person da ist."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Personne n'a répondu à la question dans la discussion en 2 minutes : rien n'a démarré. Relancez `cmux cloud start-session` quand la personne est là."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Nadie respondió a la pregunta del chat en 2 minutos, así que no se inició nada. Vuelve a ejecutar `cmux cloud start-session` cuando la persona esté."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "لم يُجب أحد عن السؤال في الدردشة خلال دقيقتين، لذا لم يبدأ شيء. شغّل `cmux cloud start-session` مرة أخرى عندما يكون الشخص موجودًا."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "2분 안에 채팅의 질문에 답한 사람이 없어 아무것도 시작되지 않았습니다. 사용자가 있을 때 `cmux cloud start-session`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "2 分钟内没有人回答聊天中的问题,因此没有启动任何内容。对方在时请再次运行 `cmux cloud start-session`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "2 分鐘內沒有人回答聊天中的問題,因此沒有啟動任何內容。對方在時請再次執行 `cmux cloud start-session`。"}} + } + } + }, + "version": "1.0" +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift new file mode 100644 index 000000000000..493ae73c7991 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -0,0 +1,122 @@ +import AppKit +import CmuxNextActions +@testable import CmuxNextApp +import CmuxNextCloud +import Foundation +import Testing + +/// `cmux cloud start-session` (Lawrence: "start new VMs from acp chat"): its arguments are checked +/// before anything starts, an agent's run asks in its own chat, and every failure names the machine +/// and the agent and gives the command to run next. +@MainActor @Suite(.serialized) +struct CloudStartSessionTests { + static func fixture() -> (services: AppServices, session: CloudMachineSession) { + let services = MachineIconTests.services() + AppActions.bind(services) + return (services, MachineIconTests.cloudMachine(services, id: "vm-start-\(UUID().uuidString.prefix(8))")) + } + + static func invocation(target: String? = nil, _ arguments: [String: ActionValue], origin: ActionOrigin = .cli, agent: String? = nil) -> ActionInvocation { + var invocation = ActionInvocation(target: target.map { ActionTargetRef(kind: .machine, id: $0) }, arguments: arguments, origin: origin) + invocation.callerAgentSession = agent + return invocation + } + + static func refusal(_ services: AppServices, _ invocation: ActionInvocation) throws -> String? { + let invoke = try #require(services.registry.action(for: CloudStartSession.action)?.invoke, "cloudStartSession is not bound") + return services.registry.capturingRefusal { invoke(invocation) } + } + + @Test func itIsACLIVerbAndAnMCPToolThatWaitsForThePerson() throws { + let descriptor = try #require(Self.fixture().services.registry.descriptor(for: CloudStartSession.action)) + #expect(descriptor.cliName == "cloud start-session") + #expect(descriptor.cli) + #expect(descriptor.surfacePlan.mcp?.isOffered == true) + #expect(descriptor.waitsForPerson, "an agent's run waits for the person's Start on its card") + #expect(descriptor.targets == [.machine]) + } + + // MARK: Arguments + + @Test func theRequestTakesTheTargetNameAgentAndFolder() throws { + let request = try CloudStartSession.request(Self.invocation(target: "vm-a", ["agent": .string(" codex "), "name": .string(" dev "), "cwd": .string("/home/cmux/work/app")])) + #expect(request == CloudStartSession.Request(machineID: "vm-a", name: "dev", agent: "codex", cwd: "/home/cmux/work/app")) + let bare = try CloudStartSession.request(Self.invocation(["agent": .string("claude"), "name": .string(""), "cwd": .string("")])) + #expect(bare == CloudStartSession.Request(machineID: nil, name: nil, agent: "claude", cwd: nil)) + } + + @Test(arguments: ["", "Claude Code", "-rf", "claude;rm", String(repeating: "a", count: 65)]) + func aBadAgentIsRefused(agent: String) { + #expect(throws: ActionFailure(message: CloudStartSessionStrings.agentInvalid)) { + try CloudStartSession.request(Self.invocation(["agent": .string(agent)])) + } + } + + @Test(arguments: ["work", "~/work", "/a\u{7}b"]) + func aFolderThatIsNoAbsolutePathIsRefused(cwd: String) { + #expect(throws: ActionFailure(message: CloudStartSessionStrings.cwdInvalid)) { + try CloudStartSession.request(Self.invocation(["agent": .string("claude"), "cwd": .string(cwd)])) + } + } + + // MARK: Refusals before anything starts + + @Test func aScriptRunWithoutAMachineSaysHowToNameOne() throws { + let services = Self.fixture().services + let refusal = try Self.refusal(services, Self.invocation(["agent": .string("claude")])) + #expect(refusal == CloudStartSessionStrings.needsMachine(agent: "claude")) + #expect(refusal?.contains("cmux cloud start-session --target machine: --agent claude") == true) + } + + @Test func anAgentOutsideAChatIsRefusedBeforeAnyCard() throws { + let f = Self.fixture() + let refusal = try Self.refusal(f.services, Self.invocation(target: f.session.machineID, ["agent": .string("claude")], agent: "session-not-here")) + #expect(refusal == CloudStartSessionStrings.notInChat) + let mcp = try Self.refusal(f.services, Self.invocation(["agent": .string("claude"), "name": .string("dev")], origin: .mcp)) + #expect(mcp == CloudStartSessionStrings.notInChat) + } + + @Test func aCardThatCannotShowStartsNothingAndLeavesNoAskOpen() async { + let services = Self.fixture().services + let asks = CloudStartSessionAsks() + for _ in 0..<2 { + await #expect(throws: ActionFailure(message: CloudStartSessionStrings.noPage)) { + try await asks.ask(tab: "tab-without-page", text: "ask", services: services) + } + } + } + + // MARK: Failures name the machine and the agent, and give the next command + + @Test func aRefusedStartSaysTheMachineIsUpAndWhatToRun() { + let refused = AgentRemoteStartRefusal(code: "agent_session.acpmux_unavailable", machine: "devbox-3") + let message = CloudStartSession.startFailure(refused, name: "devbox-3", id: "vm-devbox3", agent: "codex") + #expect(message.hasPrefix("Machine `devbox-3` started but its agent didn't (")) + #expect(message.contains("cmux cloud start-session --target machine:vm-devbox3 --agent codex")) + #expect(!message.contains(".)"), "the reason's own period sits inside the parentheses: \(message)") + } + + @Test(arguments: [AgentRemoteStartRefusal.folderNeededCode, "agent_session.home_unconfirmed", "agent_session.above_home"]) + func aStartWithNoFolderSaysToPassOne(code: String) { + let message = CloudStartSession.startFailure(AgentRemoteStartRefusal(code: code, machine: "devbox-3"), name: "devbox-3", id: "vm-devbox3", agent: "claude") + #expect(message == CloudStartSessionStrings.needsFolder("devbox-3", id: "vm-devbox3", agent: "claude")) + #expect(message.contains("--cwd")) + } + + @Test func everyHintNamesAVerbTheCLIOffers() { + let services = Self.fixture().services + let messages = [ + CloudStartSessionStrings.needsMachine(agent: "claude"), CloudStartSessionStrings.noSuchMachine(id: "vm-x", agent: "claude"), + CloudStartSessionStrings.agentInvalid, CloudStartSessionStrings.notConnected("dev", id: "vm-x", agent: "claude"), + CloudStartSessionStrings.agentFailed("dev", id: "vm-x", agent: "claude", reason: "x"), CloudStartSessionStrings.needsFolder("dev", id: "vm-x", agent: "claude"), + CloudStartSessionStrings.notInChat, CloudStartSessionStrings.noPage, CloudStartSessionStrings.askOpen, + CloudStartSessionStrings.declined, CloudStartSessionStrings.expired, + ] + let offered = Set(services.registry.descriptors.filter(\.cli).map(\.cliName)) + for message in messages { + let verbs = message.components(separatedBy: "`cmux ").dropFirst().map { $0.split(separator: " ").prefix(2).joined(separator: " ") } + #expect(!verbs.isEmpty, "no command in: \(message)") + for verb in verbs { #expect(offered.contains(verb.trimmingCharacters(in: CharacterSet(charactersIn: "`,.;:"))), "\(verb) is not a CLI verb") } + } + } +} diff --git a/skills/cmux-cloud-vm/references/commands.md b/skills/cmux-cloud-vm/references/commands.md index ffeb9b69783f..b2311288f75a 100644 --- a/skills/cmux-cloud-vm/references/commands.md +++ b/skills/cmux-cloud-vm/references/commands.md @@ -144,6 +144,21 @@ public domain has no CLI path. Check each action's arguments with Creating, forking, restoring, resizing and killing machines need the user's authorization for the target and effect. +## Agent sessions on a machine + +```bash +cmux cloud start-session --target machine: --agent claude # start a session in that machine's acpmux +cmux cloud start-session --name dev --agent codex --cwd /home/cmux/work # take machine "dev", or create it +``` + +`start-session` answers `machine_id`, `session_id` and `host` (the machine's +`registry:` host), plus the chat's `workspace_id` and `surface_id`. The chat's +tab opens in the machine's first workspace. Without `--cwd` the session starts +in that workspace's folder. An agent's run asks the person first with a card in +its own chat (Start, Not now), like an agent's `cloud move`. It waits up to 2 +minutes for an answer and then refuses. Each refusal names the machine and gives +the command to run again. + ## Account and diagnostics ```bash diff --git a/web/data/next-docs/pages.ts b/web/data/next-docs/pages.ts index c7b08ca43244..5fdc1a1efe4f 100644 --- a/web/data/next-docs/pages.ts +++ b/web/data/next-docs/pages.ts @@ -150,6 +150,9 @@ export const nextDocPages: NextDocPage[] = [ body: [ { p: "A cloud machine is a remote computer that cmux sets up for you. Run a workspace there, or move one over and bring it back." }, { code: "cmux cloud new-machine\ncmux cloud new-workspace\ncmux cloud ssh\ncmux cloud exec\ncmux cloud move\ncmux cloud bring-back", lang: "bash" }, + { h2: "Start an agent on a machine" }, + { p: "Start a Claude or Codex session on a cloud machine. Name the machine with --target, or give a name with --name to use that machine or create it. An agent that runs this asks you first in its chat." }, + { code: "cmux cloud start-session --target machine: --agent claude\ncmux cloud start-session --name dev --agent codex", lang: "bash" }, { h2: "Fork, snapshot, hand off" }, { code: "cmux cloud fork-machine\ncmux cloud snapshot-machine\ncmux cloud restore-machine\ncmux cloud hand-off-machine", lang: "bash" }, { note: "Running machines do not update themselves. New software reaches new machines first." }, From 6a8a721a69dd20dd1d76e4df6b04dd5d14ceb20d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 25 Oct 2026 21:10:21 -0700 Subject: [PATCH 02/33] cloud start-session: answer pending before the person deadline; remove a card that can't show Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextApp/Cloud/CloudStartSession.swift | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift index c54e86160e32..c807e2a97a30 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift @@ -42,7 +42,9 @@ struct CloudStartSession { request.machineID = try CloudHandlers.machine(invocation, context).machineID } if request.machineID == nil, request.name == nil { throw ActionFailure(message: CloudStartSessionStrings.needsMachine(agent: request.agent)) } - return CloudHandlers.result("start cloud session", context) { try await start.run(request, invocation, context) } + return CloudHandlers.result("start cloud session", context, pendingAfter: Self.answerWithin, pending: Self.pending(request)) { + try await start.run(request, invocation, context) + } } guard request.machineID != nil || request.name != nil else { throw ActionFailure(message: CloudStartSessionStrings.needsMachine(agent: request.agent)) } guard let session = invocation.callerAgentSession, let (tab, _) = CloudMoveAsks.chat(session, services: services) else { @@ -54,7 +56,7 @@ struct CloudStartSession { let exists = shown != nil || services.machines.cloud.contains { $0.machine.title == named } let asks = services.cloud.startSessionAsks let text = exists ? CloudStartSessionStrings.ask(agent: request.agent, machine: named) : CloudStartSessionStrings.askNew(agent: request.agent, machine: named) - CloudHandlers.result("start cloud session", context) { + CloudHandlers.result("start cloud session", context, pendingAfter: Self.answerWithin, pending: Self.pending(request)) { try await asks.ask(tab: tab.id, text: text, services: services) asks.show(tab: tab.id, .working, CloudStartSessionStrings.starting(agent: request.agent, machine: named), services: services) do { @@ -71,6 +73,17 @@ struct CloudStartSession { } } + /// A new machine can take longer than `action.run`'s person deadline to come up: the run then + /// answers `pending` (the work goes on, and the session's tab opens in the machine's workspace). + static let answerWithin: Duration = ActionDescriptor.personDeadline - .seconds(10) + + static func pending(_ request: Request) -> [String: ActionResultValue] { + var fields: [String: ActionResultValue] = ["agent": .string(request.agent)] + if let id = request.machineID { fields["machine_id"] = .string(id) } + if let name = request.name { fields["machine_name"] = .string(name) } + return fields + } + /// The run's arguments, checked: `--agent` is a harness id, `--cwd` an absolute folder. static func request(_ invocation: ActionInvocation) throws -> Request { let agent = invocation["agent"]?.stringValue?.trimmingCharacters(in: .whitespaces) ?? "" @@ -201,6 +214,8 @@ final class CloudStartSessionAsks { AgentPaneToolCard.Action(id: "notNow", title: CloudMoveAskStrings.notNow)] let card = AgentPaneToolCard(id: "cloud-start-session-" + tab, state: .ask, text: text, actions: actions) guard services.cloud.toolCards.set([card], owner: Self.owner, tab: tab, answer: { _, action, _ in continuation.yield(action == "start") }) else { + continuation.finish() + close(tab: tab, services: services) throw ActionFailure(message: CloudStartSessionStrings.noPage) } open.insert(tab) From 8fae4c323505b22531de9b9aa756f0977c7e1fb7 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 25 Oct 2026 21:53:01 -0700 Subject: [PATCH 03/33] cloud start-session: the card stays its ask's until the run ends; a pending run's end is handed to the same command; name create failures Review fixes: - The ask owns its chat's card, by token, until its run ends; a second ask is refused. - Runs are joined by what they ask for. A run that outlasted its caller's answer is kept (at most 10 min and 32 runs, pruned when a run starts, no timer) for the same command's next run. - A machine that can't be created says so, with the command. - The --cwd hint gives the full command. - The action carries a description, as #25254 added. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../Catalog/CloudSessionActionCatalog.swift | 1 + .../CmuxNextApp/Cloud/CloudService.swift | 2 + .../CmuxNextApp/Cloud/CloudStartSession.swift | 106 ++++--------- .../Cloud/CloudStartSessionAsks.swift | 148 ++++++++++++++++++ .../Cloud/CloudStartSessionStrings.swift | 12 +- .../Resources/CloudSessions.xcstrings | 54 +++++-- .../CloudStartSessionTests.swift | 133 +++++++++++++++- 7 files changed, 365 insertions(+), 91 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/CloudSessionActionCatalog.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/CloudSessionActionCatalog.swift index 0d2c2c135dd0..20277c02ee82 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/CloudSessionActionCatalog.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/CloudSessionActionCatalog.swift @@ -8,6 +8,7 @@ nonisolated enum CloudSessionActionCatalog: ActionCatalogGroup { var start = ActionDescriptor( id: "cloudStartSession", title: String(localized: "action.cloudStartSession", defaultValue: "Start Agent Session on Cloud Machine…", table: "CloudSessionActions", bundle: .module), + description: "Starts an agent session (--agent claude, codex, ...) in the Cloud machine's own acpmux and returns machine_id, session_id and host, plus the chat tab's workspace_id and surface_id. Name the machine with --target machine:, or --name to take that machine or create it; --cwd is the folder there. An agent's run asks the person in its chat first. A run still starting after about 3 minutes answers pending: true; the same command again returns its session.", keywords: ["vm", "cloud", "agent", "session", "chat", "acp", "acpmux", "start", "claude", "codex"], category: .cloud, symbol: "sparkles", surfaces: [.palette, .contextMenu], arguments: [agent, name, cwd], targets: [.machine], cliName: "cloud start-session", surfacePlan: ActionSurfacePlan(cli: .offered, contextMenus: [ActionSurfaceCatalog.p(.cloudMachine, .connection, 221, folder: .connection)]) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift index 290df9f88fbe..208c4cbb4242 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift @@ -78,6 +78,8 @@ final class CloudService { @ObservationIgnored let toolCards = AgentToolCards() /// Agents' asks to start a session on a Cloud machine (``CloudStartSessionAsks``). @ObservationIgnored let startSessionAsks = CloudStartSessionAsks() + /// `cmux cloud start-session` runs, joined by a repeat of the same ask (``CloudStartSessionRuns``). + @ObservationIgnored let startSessionRuns = CloudStartSessionRuns() /// Moved chats' file requests for this Mac, asked in their chats. @ObservationIgnored let accessAsks = CloudAccessAsks() /// Files the person drops or pastes into a moved chat, sent to its machine (``CloudUploads``). diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift index c807e2a97a30..efb01505f560 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift @@ -35,7 +35,7 @@ struct CloudStartSession { let services = context.services CloudHandlers.bind(action, registry, reason: reason) { invocation in var request = try Self.request(invocation) - let start = CloudStartSession(services: services) + let start = CloudStartSession(services: services), runs = services.cloud.startSessionRuns guard CloudMoveAsks.isAgent(invocation) else { // A person's palette or menu run without a machine means the one they look at. if request.machineID == nil, request.name == nil, CloudHandlers.origin(of: invocation) == .user { @@ -43,11 +43,11 @@ struct CloudStartSession { } if request.machineID == nil, request.name == nil { throw ActionFailure(message: CloudStartSessionStrings.needsMachine(agent: request.agent)) } return CloudHandlers.result("start cloud session", context, pendingAfter: Self.answerWithin, pending: Self.pending(request)) { - try await start.run(request, invocation, context) + try await runs.join(CloudStartSessionRuns.Key(request, caller: nil), answeredWithin: Self.answerWithin) { try await start.run(request, invocation, context) } } } guard request.machineID != nil || request.name != nil else { throw ActionFailure(message: CloudStartSessionStrings.needsMachine(agent: request.agent)) } - guard let session = invocation.callerAgentSession, let (tab, _) = CloudMoveAsks.chat(session, services: services) else { + guard let caller = invocation.callerAgentSession, let (tab, _) = CloudMoveAsks.chat(caller, services: services) else { throw ActionFailure(message: CloudStartSessionStrings.notInChat) } let shown = request.machineID.flatMap { services.machines.session($0)?.machine.title } @@ -56,18 +56,23 @@ struct CloudStartSession { let exists = shown != nil || services.machines.cloud.contains { $0.machine.title == named } let asks = services.cloud.startSessionAsks let text = exists ? CloudStartSessionStrings.ask(agent: request.agent, machine: named) : CloudStartSessionStrings.askNew(agent: request.agent, machine: named) + // The person's Start is the gesture: the machine is created as theirs, at the agent's ask. + var asPerson = ActionInvocation(target: invocation.target, arguments: invocation.arguments, origin: .user) + asPerson.callerAgentSession = nil + // The same ask again (the agent's run answered pending) joins the one that runs. + let key = CloudStartSessionRuns.Key(request, caller: caller) CloudHandlers.result("start cloud session", context, pendingAfter: Self.answerWithin, pending: Self.pending(request)) { - try await asks.ask(tab: tab.id, text: text, services: services) - asks.show(tab: tab.id, .working, CloudStartSessionStrings.starting(agent: request.agent, machine: named), services: services) - do { - // The person's Start is the gesture: the machine is created as theirs, at the agent's ask. - let fields = try await start.run(request, ActionInvocation(target: invocation.target, arguments: invocation.arguments, origin: .user), - context, askedBy: session, revealing: false) - asks.close(tab: tab.id, services: services) - return fields - } catch { - asks.show(tab: tab.id, .failed, (error as? ActionFailure)?.message ?? String(describing: error), services: services) - throw error + try await runs.join(key, answeredWithin: Self.answerWithin) { + let token = try await asks.ask(tab: tab.id, text: text, services: services) + asks.show(tab: tab.id, token: token, .working, CloudStartSessionStrings.starting(agent: request.agent, machine: named), services: services) + do { + let fields = try await start.run(request, asPerson, context, askedBy: caller, revealing: false) + asks.finish(tab: tab.id, token: token, failure: nil, services: services) + return fields + } catch { + asks.finish(tab: tab.id, token: token, failure: (error as? ActionFailure)?.message ?? Self.startFailure(error, name: named, id: request.machineID ?? named, agent: request.agent), services: services) + throw error + } } } } @@ -78,7 +83,7 @@ struct CloudStartSession { static let answerWithin: Duration = ActionDescriptor.personDeadline - .seconds(10) static func pending(_ request: Request) -> [String: ActionResultValue] { - var fields: [String: ActionResultValue] = ["agent": .string(request.agent)] + var fields: [String: ActionResultValue] = ["agent": .string(request.agent), "text": .string(CloudStartSessionStrings.stillStarting)] if let id = request.machineID { fields["machine_id"] = .string(id) } if let name = request.name { fields["machine_name"] = .string(name) } return fields @@ -158,7 +163,7 @@ struct CloudStartSession { } /// A refusal sentence inside the run's parentheses: no closing period. - private static func reason(_ text: String) -> String { + static func reason(_ text: String) -> String { var text = text.trimmingCharacters(in: .whitespacesAndNewlines) while text.hasSuffix(".") { text.removeLast() } return text @@ -173,7 +178,14 @@ struct CloudStartSession { } let name = request.name ?? "" if let named = machines.cloud.first(where: { $0.machine.title == name }) { return named } - return try await services.cloud.createMachine(name: name, startedByPerson: invocation.origin == .user, askedBy: askedBy ?? invocation.callerAgentSession) + do { + return try await services.cloud.createMachine(name: name, startedByPerson: invocation.origin == .user, askedBy: askedBy ?? invocation.callerAgentSession) + } catch is CancellationError { + throw CancellationError() + } catch { + let detail = (error as? ActionFailure)?.message ?? (error as? any LocalizedError)?.errorDescription ?? String(describing: error) + throw ActionFailure(message: CloudStartSessionStrings.createFailed(name, agent: request.agent, reason: Self.reason(detail))) + } } /// The machine connected with its store's id: a paused one resumes, a new one comes up. Nothing @@ -195,63 +207,3 @@ struct CloudStartSession { /// How long a machine has to come up (Move to Cloud's own wait). static let connectWait: Duration = .seconds(180) } - -/// An agent's ask to start a Cloud session, as a card in its own chat (``AgentToolCards``): the -/// agent's run waits for the person's Start or Not now, for at most ``wait``. -@MainActor -final class CloudStartSessionAsks { - static let owner = "cloudStartSession" - /// How long a card waits for the person; the run says so after. - static let wait: Duration = .seconds(120) - private var open: Set = [] - - /// Shows the card in chat tab `tab` and returns once the person clicks Start. Throws when they - /// choose Not now, when nobody answers within ``wait``, or when the chat has no page. - func ask(tab: String, text: String, services: AppServices) async throws { - guard !open.contains(tab) else { throw ActionFailure(message: CloudStartSessionStrings.askOpen) } - let (answers, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingOldest(1)) - let actions = [AgentPaneToolCard.Action(id: "start", title: CloudStartSessionStrings.start), - AgentPaneToolCard.Action(id: "notNow", title: CloudMoveAskStrings.notNow)] - let card = AgentPaneToolCard(id: "cloud-start-session-" + tab, state: .ask, text: text, actions: actions) - guard services.cloud.toolCards.set([card], owner: Self.owner, tab: tab, answer: { _, action, _ in continuation.yield(action == "start") }) else { - continuation.finish() - close(tab: tab, services: services) - throw ActionFailure(message: CloudStartSessionStrings.noPage) - } - open.insert(tab) - // task-owner: the card's one-shot deadline; cancelled below once the person answers. - let deadline = Task { - // wakeup-allow: one-shot deadline for the person's answer on the card - try? await Task.sleep(for: Self.wait) - continuation.yield(nil) - } - defer { - deadline.cancel() - continuation.finish() - open.remove(tab) - } - var answer: Bool? - for await value in answers { - answer = value - break - } - guard answer == true else { - close(tab: tab, services: services) - throw ActionFailure(message: answer == false ? CloudStartSessionStrings.declined : CloudStartSessionStrings.expired) - } - } - - /// The card after Start: working, or failed with the reason and Dismiss. - func show(tab: String, _ state: AgentPaneToolCard.State, _ text: String, services: AppServices) { - let actions = state == .failed ? [AgentPaneToolCard.Action(id: "dismiss", title: CloudMoveAskStrings.dismiss)] : [] - let card = AgentPaneToolCard(id: "cloud-start-session-" + tab, state: state, text: text, actions: actions) - services.cloud.toolCards.set([card], owner: Self.owner, tab: tab) { [weak self, weak services] _, _, _ in - guard let self, let services else { return } - close(tab: tab, services: services) - } - } - - func close(tab: String, services: AppServices) { - services.cloud.toolCards.set([], owner: Self.owner, tab: tab) { _, _, _ in } - } -} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift new file mode 100644 index 000000000000..186a22ae453c --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift @@ -0,0 +1,148 @@ +import CmuxNextActions +import CmuxNextAgentPane +import Foundation + +/// An agent's ask to start a Cloud session, as a card in its own chat (``AgentToolCards``): the +/// agent's run waits for the person's Start or Not now, for at most ``wait``. The ask owns its +/// chat's card from the ask until its run ends (``finish(tab:token:failure:services:)``): another +/// ask in that chat meanwhile is refused, and never replaces it. +@MainActor +final class CloudStartSessionAsks { + static let owner = "cloudStartSession" + /// How long a card waits for the person; the run says so after. + static let wait: Duration = .seconds(120) + /// The ask that owns each chat's card, by tab. + private(set) var open: [String: UUID] = [:] + /// Shows `cards` in chat tab `tab` (none removes them), each click going to the answer; false + /// when the chat has no page. The app's tool cards by default; a test's own. + var present: (@MainActor (_ cards: [AgentPaneToolCard], _ tab: String, _ services: AppServices, _ answer: @escaping AgentToolCards.Answer) -> Bool)? + + /// Shows the card in chat tab `tab` and returns its token once the person clicks Start. Throws + /// when they choose Not now, when nobody answers within ``wait``, or when the chat has no page. + func ask(tab: String, text: String, services: AppServices) async throws -> UUID { + guard open[tab] == nil else { throw ActionFailure(message: CloudStartSessionStrings.askOpen) } + let token = UUID() + let (answers, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingOldest(1)) + let actions = [AgentPaneToolCard.Action(id: "start", title: CloudStartSessionStrings.start), + AgentPaneToolCard.Action(id: "notNow", title: CloudMoveAskStrings.notNow)] + guard set([card(tab, .ask, text, actions)], tab: tab, services: services, answer: { _, action, _ in continuation.yield(action == "start") }) else { + continuation.finish() + _ = set([], tab: tab, services: services) { _, _, _ in } + throw ActionFailure(message: CloudStartSessionStrings.noPage) + } + open[tab] = token + // task-owner: the card's one-shot deadline; cancelled below once the person answers. + let deadline = Task { + // wakeup-allow: one-shot deadline for the person's answer on the card + try? await Task.sleep(for: Self.wait) + continuation.yield(nil) + } + defer { + deadline.cancel() + continuation.finish() + } + var answer: Bool? + for await value in answers { + answer = value + break + } + guard answer == true else { + finish(tab: tab, token: token, failure: nil, services: services) + throw ActionFailure(message: answer == false ? CloudStartSessionStrings.declined : CloudStartSessionStrings.expired) + } + return token + } + + /// The card while its run goes on; only the ask that owns it changes it. + func show(tab: String, token: UUID, _ state: AgentPaneToolCard.State, _ text: String, services: AppServices) { + guard open[tab] == token else { return } + _ = set([card(tab, state, text, [])], tab: tab, services: services) { _, _, _ in } + } + + /// The run ended: the card goes, or says why it stopped with Dismiss; the chat may ask again. + func finish(tab: String, token: UUID, failure: String?, services: AppServices) { + guard open[tab] == token else { return } + open[tab] = nil + guard let failure else { return _ = set([], tab: tab, services: services) { _, _, _ in } } + let dismiss = AgentPaneToolCard.Action(id: "dismiss", title: CloudMoveAskStrings.dismiss) + _ = set([card(tab, .failed, failure, [dismiss])], tab: tab, services: services) { [weak self, weak services] _, _, _ in + // A later ask in this chat owns the card from then on; its Dismiss is its own. + guard let self, let services, open[tab] == nil else { return } + _ = set([], tab: tab, services: services) { _, _, _ in } + } + } + + private func card(_ tab: String, _ state: AgentPaneToolCard.State, _ text: String, _ actions: [AgentPaneToolCard.Action]) -> AgentPaneToolCard { + AgentPaneToolCard(id: "cloud-start-session-" + tab, state: state, text: text, actions: actions) + } + + private func set(_ cards: [AgentPaneToolCard], tab: String, services: AppServices, answer: @escaping AgentToolCards.Answer) -> Bool { + if let present { return present(cards, tab, services, answer) } + return services.cloud.toolCards.set(cards, owner: Self.owner, tab: tab, answer: answer) + } +} + +/// `cmux cloud start-session` runs by what they ask for: the same ask again while one runs joins +/// it, and the next same ask after a run that outlasted its caller's answer (the caller got +/// `pending`) gets that run's end, once. A run its caller saw end is not kept. Ended runs are kept at most ``kept`` long and at most ``limit`` of them, pruned when +/// a run starts; no timer. +@MainActor +final class CloudStartSessionRuns { + struct Key: Hashable { + var machine: String + var agent: String + var cwd: String? + /// The agent chat that asked; nil for a person's or a script's run. + var caller: String? + + init(_ request: CloudStartSession.Request, caller: String?) { + machine = request.machineID.map { "id:" + $0 } ?? "name:" + (request.name ?? "") + agent = request.agent + cwd = request.cwd + self.caller = caller + } + } + + typealias Fields = [String: ActionResultValue] + + private struct Run { + var task: Task + var ended: ContinuousClock.Instant? + } + + static let kept: Duration = .seconds(600) + static let limit = 32 + private var runs: [Key: Run] = [:] + var now: () -> ContinuousClock.Instant = { ContinuousClock.now } + + /// The run of `key`: one already running or ended (an ended one is handed out once), else + /// `work`, started now. `answeredWithin`: how long the caller waits before it answers + /// `pending`; a run that ends sooner reached its caller and is not kept. + func join(_ key: Key, answeredWithin: Duration, _ work: @escaping @MainActor () async throws -> Fields) async throws -> Fields { + if let run = runs[key] { + if run.ended != nil { runs[key] = nil } + return try await run.task.value + } + prune() + // task-owner: the start itself; it runs to its end even after its caller answered pending. + let task = Task { @MainActor in try await work() } + runs[key] = Run(task: task) + let started = now() + defer { + if runs[key]?.task == task { + // A margin under the caller's own wait: an end that close may have lost the race. + if now() - started < answeredWithin - .seconds(2) { runs[key] = nil } else { runs[key]?.ended = now() } + } + } + return try await task.value + } + + var count: Int { runs.count } + + private func prune() { + let cutoff = now() - Self.kept + runs = runs.filter { $0.value.ended.map { $0 > cutoff } ?? true } + let ended = runs.filter { $0.value.ended != nil }.sorted { $0.value.ended! < $1.value.ended! } + for (key, _) in ended.prefix(max(0, runs.count - Self.limit + 1)) { runs[key] = nil } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift index 2f974042cec2..38c8983a35a2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift @@ -18,7 +18,17 @@ enum CloudStartSessionStrings { } static var cwdInvalid: String { - String(localized: "cloud.startSession.cwdInvalid", defaultValue: "Pass --cwd as an absolute folder on the machine, for example --cwd /home/cmux/work.", table: "CloudSessions", bundle: .module) + String(localized: "cloud.startSession.cwdInvalid", defaultValue: "Pass --cwd as an absolute folder on the machine, for example `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`.", table: "CloudSessions", bundle: .module) + } + + /// The machine to create (`--name`) could not be created; `reason` says why. + static func createFailed(_ name: String, agent: String, reason: String) -> String { + String(format: String(localized: "cloud.startSession.createFailed", defaultValue: "Couldn't create Cloud machine `%1$@` (%3$@), so no agent started. Run `cmux cloud start-session --name %1$@ --agent %2$@` again, or pick a machine from `cmux cloud list-machines`.", table: "CloudSessions", bundle: .module), name, agent, reason) + } + + /// A run that answered `pending`: the same command again answers its end. + static var stillStarting: String { + String(localized: "cloud.startSession.stillStarting", defaultValue: "The session is still starting. Run the same `cmux cloud start-session` command again to get its machine_id and session_id.", table: "CloudSessions", bundle: .module) } static func notConnected(_ name: String, id: String, agent: String) -> String { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings index 75a4beb84cdf..b7c3397900ee 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings @@ -47,18 +47,48 @@ } }, "cloud.startSession.cwdInvalid": { - "comment": "cmux cloud start-session with a relative or bad --cwd. Keep --cwd /home/cmux/work as written.", - "extractionState": "manual", - "localizations": { - "en": {"stringUnit": {"state": "translated", "value": "Pass --cwd as an absolute folder on the machine, for example --cwd /home/cmux/work."}}, - "ja": {"stringUnit": {"state": "translated", "value": "--cwd にはマシン上の絶対パスのフォルダを指定してください (例: --cwd /home/cmux/work)。"}}, - "de": {"stringUnit": {"state": "needs_review", "value": "Gib --cwd als absoluten Ordner auf der Maschine an, zum Beispiel --cwd /home/cmux/work."}}, - "fr": {"stringUnit": {"state": "needs_review", "value": "Indiquez --cwd sous forme de dossier absolu sur la machine, par exemple --cwd /home/cmux/work."}}, - "es": {"stringUnit": {"state": "needs_review", "value": "Indica --cwd como una carpeta absoluta en la máquina, por ejemplo --cwd /home/cmux/work."}}, - "ar": {"stringUnit": {"state": "needs_review", "value": "مرّر ‎--cwd كمجلد بمسار مطلق على الجهاز، مثل ‎--cwd /home/cmux/work."}}, - "ko": {"stringUnit": {"state": "needs_review", "value": "--cwd에는 머신의 절대 경로 폴더를 지정하세요(예: --cwd /home/cmux/work)."}}, - "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "请将 --cwd 设为机器上的绝对路径文件夹,例如 --cwd /home/cmux/work。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "請將 --cwd 設為機器上的絕對路徑資料夾,例如 --cwd /home/cmux/work。"}} + "comment": "cmux cloud start-session with a relative or bad --cwd. Keep the command as written.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Pass --cwd as an absolute folder on the machine, for example `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`."}}, + "ja": {"stringUnit": {"state": "translated", "value": "--cwd にはマシン上の絶対パスのフォルダを指定してください (例: `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`)。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Gib --cwd als absoluten Ordner auf der Maschine an, zum Beispiel `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Indiquez --cwd sous forme de dossier absolu sur la machine, par exemple `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Indica --cwd como una carpeta absoluta en la máquina, por ejemplo `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "مرّر ‎--cwd كمجلد بمسار مطلق على الجهاز، مثل `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "--cwd에는 머신의 절대 경로 폴더를 지정하세요(예: `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`)."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "请将 --cwd 设为机器上的绝对路径文件夹,例如 `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "請將 --cwd 設為機器上的絕對路徑資料夾,例如 `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`。"}} + } + }, + "cloud.startSession.createFailed": { + "comment": "The named machine could not be created. %1$@ is its name, %2$@ the agent, %3$@ the reason. Keep the commands as written.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Couldn't create Cloud machine `%1$@` (%3$@), so no agent started. Run `cmux cloud start-session --name %1$@ --agent %2$@` again, or pick a machine from `cmux cloud list-machines`."}}, + "ja": {"stringUnit": {"state": "translated", "value": "Cloud マシン `%1$@` を作成できなかったため (%3$@)、エージェントは開始されませんでした。`cmux cloud start-session --name %1$@ --agent %2$@` をもう一度実行するか、`cmux cloud list-machines` からマシンを選んでください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Cloud-Maschine `%1$@` konnte nicht erstellt werden (%3$@), daher wurde kein Agent gestartet. Führe `cmux cloud start-session --name %1$@ --agent %2$@` erneut aus oder wähle eine Maschine aus `cmux cloud list-machines`."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Impossible de créer la machine Cloud `%1$@` (%3$@) : aucun agent n'a démarré. Relancez `cmux cloud start-session --name %1$@ --agent %2$@`, ou choisissez une machine dans `cmux cloud list-machines`."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "No se pudo crear la máquina de Cloud `%1$@` (%3$@), así que no se inició ningún agente. Vuelve a ejecutar `cmux cloud start-session --name %1$@ --agent %2$@` o elige una máquina de `cmux cloud list-machines`."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "تعذّر إنشاء جهاز Cloud ‏`%1$@` (%3$@)، لذا لم يبدأ أي وكيل. شغّل `cmux cloud start-session --name %1$@ --agent %2$@` مرة أخرى، أو اختر جهازًا من `cmux cloud list-machines`."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "Cloud 머신 `%1$@`을(를) 만들지 못해(%3$@) 에이전트가 시작되지 않았습니다. `cmux cloud start-session --name %1$@ --agent %2$@`을(를) 다시 실행하거나 `cmux cloud list-machines`에서 머신을 고르세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "无法创建 Cloud 机器 `%1$@`(%3$@),因此没有启动代理。请再次运行 `cmux cloud start-session --name %1$@ --agent %2$@`,或从 `cmux cloud list-machines` 中选一台机器。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "無法建立 Cloud 機器 `%1$@`(%3$@),因此沒有啟動代理。請再次執行 `cmux cloud start-session --name %1$@ --agent %2$@`,或從 `cmux cloud list-machines` 中選一台機器。"}} + } + }, + "cloud.startSession.stillStarting": { + "comment": "The run answered pending while the session still starts. Keep the command as written; keep machine_id and session_id in English.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "The session is still starting. Run the same `cmux cloud start-session` command again to get its machine_id and session_id."}}, + "ja": {"stringUnit": {"state": "translated", "value": "セッションはまだ開始中です。同じ `cmux cloud start-session` コマンドをもう一度実行すると、machine_id と session_id を取得できます。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Die Sitzung startet noch. Führe denselben Befehl `cmux cloud start-session` erneut aus, um machine_id und session_id zu erhalten."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "La session démarre encore. Relancez la même commande `cmux cloud start-session` pour obtenir son machine_id et son session_id."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "La sesión todavía se está iniciando. Vuelve a ejecutar el mismo comando `cmux cloud start-session` para obtener su machine_id y session_id."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "لا تزال الجلسة قيد البدء. شغّل الأمر `cmux cloud start-session` نفسه مرة أخرى للحصول على machine_id وsession_id."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "세션이 아직 시작 중입니다. 같은 `cmux cloud start-session` 명령을 다시 실행하면 machine_id와 session_id를 받을 수 있습니다."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "会话仍在启动。请再次运行同一条 `cmux cloud start-session` 命令以获取其 machine_id 和 session_id。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "工作階段仍在啟動。請再次執行同一條 `cmux cloud start-session` 命令以取得其 machine_id 和 session_id。"}} } }, "cloud.startSession.notConnected": { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index 493ae73c7991..c7ef53854c87 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -1,5 +1,6 @@ import AppKit import CmuxNextActions +import CmuxNextAgentPane @testable import CmuxNextApp import CmuxNextCloud import Foundation @@ -29,6 +30,8 @@ struct CloudStartSessionTests { @Test func itIsACLIVerbAndAnMCPToolThatWaitsForThePerson() throws { let descriptor = try #require(Self.fixture().services.registry.descriptor(for: CloudStartSession.action)) + let text = try #require(descriptor.description, "no description for cmux action describe and MCP") + #expect(text.contains("--target machine:") && text.contains("session_id") && text.count <= 600) #expect(descriptor.cliName == "cloud start-session") #expect(descriptor.cli) #expect(descriptor.surfacePlan.mcp?.isOffered == true) @@ -107,7 +110,8 @@ struct CloudStartSessionTests { let services = Self.fixture().services let messages = [ CloudStartSessionStrings.needsMachine(agent: "claude"), CloudStartSessionStrings.noSuchMachine(id: "vm-x", agent: "claude"), - CloudStartSessionStrings.agentInvalid, CloudStartSessionStrings.notConnected("dev", id: "vm-x", agent: "claude"), + CloudStartSessionStrings.agentInvalid, CloudStartSessionStrings.cwdInvalid, CloudStartSessionStrings.stillStarting, + CloudStartSessionStrings.createFailed("dev", agent: "claude", reason: "x"), CloudStartSessionStrings.notConnected("dev", id: "vm-x", agent: "claude"), CloudStartSessionStrings.agentFailed("dev", id: "vm-x", agent: "claude", reason: "x"), CloudStartSessionStrings.needsFolder("dev", id: "vm-x", agent: "claude"), CloudStartSessionStrings.notInChat, CloudStartSessionStrings.noPage, CloudStartSessionStrings.askOpen, CloudStartSessionStrings.declined, CloudStartSessionStrings.expired, @@ -119,4 +123,131 @@ struct CloudStartSessionTests { for verb in verbs { #expect(offered.contains(verb.trimmingCharacters(in: CharacterSet(charactersIn: "`,.;:"))), "\(verb) is not a CLI verb") } } } + + // MARK: A machine that can't be created + + @Test func aMachineThatCannotBeCreatedSaysSoWithTheCommand() async throws { + let services = Self.fixture().services + let name = "fresh-\(UUID().uuidString.prefix(6).lowercased())" + let invoke = try #require(services.registry.action(for: CloudStartSession.action)?.invoke) + let run = services.registry.capturingRun { invoke(Self.invocation(["agent": .string("claude"), "name": .string(name)])) } + let task = try #require(run.results.first, "start-session tracked no result") + guard case .failure(let failure) = await task.value else { return Issue.record("a create with no Cloud sign-in went through") } + #expect(failure.message.hasPrefix("Couldn't create Cloud machine `\(name)` ("), "\(failure.message)") + #expect(failure.message.contains("`cmux cloud start-session --name \(name) --agent claude`")) + #expect(!failure.message.contains(".)")) + } + + // MARK: The card belongs to its ask until the run ends + + /// Yields to the main actor's other tasks until `ready` (at most 50 turns; no clock). + static func settle(_ ready: () -> Bool) async { + for _ in 0..<50 where !ready() { await Task.yield() } + } + + /// Cards shown in place of a chat page, and the clicks a test sends them. + final class Cards { + var shown: [String: [AgentPaneToolCard]] = [:] + var answers: [String: AgentToolCards.Answer] = [:] + + func install(_ asks: CloudStartSessionAsks) { + asks.present = { [unowned self] cards, tab, _, answer in + shown[tab] = cards.isEmpty ? nil : cards + answers[tab] = answer + return true + } + } + + func click(_ tab: String, _ action: String) { answers[tab]?("cloud-start-session-" + tab, action, nil) } + } + + @Test func aSecondAskWhileTheFirstRunsNeverTakesItsCard() async throws { + let services = Self.fixture().services + let asks = CloudStartSessionAsks(), cards = Cards() + cards.install(asks) + let first = Task { try await asks.ask(tab: "chat-1", text: "first", services: services) } + await Self.settle { cards.shown["chat-1"] != nil } + #expect(cards.shown["chat-1"]?.first?.state == .ask) + cards.click("chat-1", "start") + let token = try await first.value + // Started: the run goes on, and the chat's card is still the first ask's. + asks.show(tab: "chat-1", token: token, .working, "starting", services: services) + await #expect(throws: ActionFailure(message: CloudStartSessionStrings.askOpen)) { + _ = try await asks.ask(tab: "chat-1", text: "second", services: services) + } + #expect(cards.shown["chat-1"]?.first?.text == "starting") + // Another ask's token changes nothing. + asks.finish(tab: "chat-1", token: UUID(), failure: "not yours", services: services) + #expect(cards.shown["chat-1"]?.first?.text == "starting") + asks.finish(tab: "chat-1", token: token, failure: "Machine `dev` started but its agent didn't (x).", services: services) + #expect(cards.shown["chat-1"]?.first?.state == .failed) + #expect(asks.open["chat-1"] == nil, "the chat may ask again") + cards.click("chat-1", "dismiss") + #expect(cards.shown["chat-1"] == nil) + } + + @Test func notNowStartsNothingAndFreesTheChat() async throws { + let services = Self.fixture().services + let asks = CloudStartSessionAsks(), cards = Cards() + cards.install(asks) + let ask = Task { try await asks.ask(tab: "chat-2", text: "ask", services: services) } + await Self.settle { cards.shown["chat-2"] != nil } + cards.click("chat-2", "notNow") + await #expect(throws: ActionFailure(message: CloudStartSessionStrings.declined)) { _ = try await ask.value } + #expect(cards.shown["chat-2"] == nil) + #expect(asks.open["chat-2"] == nil) + } + + // MARK: A run that outlasts its caller's answer + + /// What the runs' work counts and the clock they read, shared with their closures. + @MainActor final class Box { + var value: T + init(_ value: T) { self.value = value } + } + + @Test func theSameAskWhileARunGoesOnJoinsIt() async throws { + let runs = CloudStartSessionRuns() + let key = CloudStartSessionRuns.Key(CloudStartSession.Request(machineID: "vm-a", name: nil, agent: "claude", cwd: nil), caller: nil) + let (gate, open) = AsyncStream.makeStream() + let started = Box(0) + let work: @MainActor () async throws -> CloudStartSessionRuns.Fields = { + started.value += 1 + for await _ in gate { break } + return ["session_id": .string("s-1")] + } + let first = Task { try await runs.join(key, answeredWithin: .seconds(190), work) } + await Self.settle { started.value == 1 } + let second = Task { try await runs.join(key, answeredWithin: .seconds(190), work) } + await Self.settle { false } // lets the second join reach the running one + open.yield(()) + #expect(try await first.value["session_id"] == .string("s-1")) + #expect(try await second.value["session_id"] == .string("s-1")) + #expect(started.value == 1) + // Its caller saw it end: the next same ask starts a new run. + #expect(runs.count == 0) + } + + @Test func aRunThatOutlastedItsCallerIsHandedOutOnce() async throws { + let runs = CloudStartSessionRuns() + let clock = Box(ContinuousClock.now) + runs.now = { clock.value } + let key = CloudStartSessionRuns.Key(CloudStartSession.Request(machineID: nil, name: "dev", agent: "codex", cwd: nil), caller: "chat-9") + let started = Box(0) + let slow: @MainActor () async throws -> CloudStartSessionRuns.Fields = { + started.value += 1 + clock.value += .seconds(300) // longer than the caller waits + return ["session_id": .string("s-\(started.value)")] + } + #expect(try await runs.join(key, answeredWithin: .seconds(190), slow)["session_id"] == .string("s-1")) + #expect(runs.count == 1, "kept for the caller that got pending") + #expect(try await runs.join(key, answeredWithin: .seconds(190), slow)["session_id"] == .string("s-1")) + #expect(started.value == 1) + #expect(try await runs.join(key, answeredWithin: .seconds(190), slow)["session_id"] == .string("s-2"), "handed out once") + // Kept at most ten minutes. + clock.value += CloudStartSessionRuns.kept + .seconds(1) + let other = CloudStartSessionRuns.Key(CloudStartSession.Request(machineID: "vm-b", name: nil, agent: "codex", cwd: nil), caller: nil) + _ = try await runs.join(other, answeredWithin: .seconds(190)) { ["session_id": .string("b")] } + #expect(runs.count == 0) + } } From c8f6bf6d272dba2a7455bfe11a095ff25f42b29d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 25 Oct 2026 22:01:19 -0700 Subject: [PATCH 04/33] cloud start-session: keep a run only when every caller answered pending; quote the retry name; a capture scene of the ask card Review fixes: - CloudStartSessionRuns runs each caller's answer race itself. A run that ends with a caller waiting is dropped. One that every caller left is kept for the next same ask, handed out once, and expires for that same key too. - Tests use explicit signals (an injected wait, a card-shown stream) instead of yield counts. - The create-failure retry command quotes the machine name as one shell word. - DEBUG scene cloud-start-session-ask: the real ask card in the showcase chat. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextApp/Cloud/CloudStartSession.swift | 19 ++- .../Cloud/CloudStartSessionAsks.swift | 113 +++++++++++---- .../Cloud/CloudStartSessionStrings.swift | 7 +- .../Control/CaptureSceneRegistry.swift | 10 ++ .../Resources/CloudSessions.xcstrings | 20 +-- .../CloudStartSessionTests.swift | 133 ++++++++++++++---- 6 files changed, 224 insertions(+), 78 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift index efb01505f560..072113319720 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift @@ -42,8 +42,10 @@ struct CloudStartSession { request.machineID = try CloudHandlers.machine(invocation, context).machineID } if request.machineID == nil, request.name == nil { throw ActionFailure(message: CloudStartSessionStrings.needsMachine(agent: request.agent)) } - return CloudHandlers.result("start cloud session", context, pendingAfter: Self.answerWithin, pending: Self.pending(request)) { - try await runs.join(CloudStartSessionRuns.Key(request, caller: nil), answeredWithin: Self.answerWithin) { try await start.run(request, invocation, context) } + return CloudHandlers.result("start cloud session", context) { + try await runs.answer(CloudStartSessionRuns.Key(request, caller: nil), within: Self.answerWithin, pending: Self.pending(request)) { + try await start.run(request, invocation, context) + } } } guard request.machineID != nil || request.name != nil else { throw ActionFailure(message: CloudStartSessionStrings.needsMachine(agent: request.agent)) } @@ -61,8 +63,8 @@ struct CloudStartSession { asPerson.callerAgentSession = nil // The same ask again (the agent's run answered pending) joins the one that runs. let key = CloudStartSessionRuns.Key(request, caller: caller) - CloudHandlers.result("start cloud session", context, pendingAfter: Self.answerWithin, pending: Self.pending(request)) { - try await runs.join(key, answeredWithin: Self.answerWithin) { + CloudHandlers.result("start cloud session", context) { + try await runs.answer(key, within: Self.answerWithin, pending: Self.pending(request)) { let token = try await asks.ask(tab: tab.id, text: text, services: services) asks.show(tab: tab.id, token: token, .working, CloudStartSessionStrings.starting(agent: request.agent, machine: named), services: services) do { @@ -162,6 +164,13 @@ struct CloudStartSession { return CloudStartSessionStrings.agentFailed(name, id: id, agent: agent, reason: Self.reason(reason)) } + /// `text` as one shell word: as it is when it is a plain name, else in single quotes. + static func shellQuoted(_ text: String) -> String { + let plain = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-") + guard text.isEmpty || !text.unicodeScalars.allSatisfy(plain.contains) else { return text } + return "'" + text.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + /// A refusal sentence inside the run's parentheses: no closing period. static func reason(_ text: String) -> String { var text = text.trimmingCharacters(in: .whitespacesAndNewlines) @@ -184,7 +193,7 @@ struct CloudStartSession { throw CancellationError() } catch { let detail = (error as? ActionFailure)?.message ?? (error as? any LocalizedError)?.errorDescription ?? String(describing: error) - throw ActionFailure(message: CloudStartSessionStrings.createFailed(name, agent: request.agent, reason: Self.reason(detail))) + throw ActionFailure(message: CloudStartSessionStrings.createFailed(name, quoted: Self.shellQuoted(name), agent: request.agent, reason: Self.reason(detail))) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift index 186a22ae453c..c020a45c152a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift @@ -23,9 +23,7 @@ final class CloudStartSessionAsks { guard open[tab] == nil else { throw ActionFailure(message: CloudStartSessionStrings.askOpen) } let token = UUID() let (answers, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingOldest(1)) - let actions = [AgentPaneToolCard.Action(id: "start", title: CloudStartSessionStrings.start), - AgentPaneToolCard.Action(id: "notNow", title: CloudMoveAskStrings.notNow)] - guard set([card(tab, .ask, text, actions)], tab: tab, services: services, answer: { _, action, _ in continuation.yield(action == "start") }) else { + guard set([Self.askCard(tab: tab, text: text)], tab: tab, services: services, answer: { _, action, _ in continuation.yield(action == "start") }) else { continuation.finish() _ = set([], tab: tab, services: services) { _, _, _ in } throw ActionFailure(message: CloudStartSessionStrings.noPage) @@ -72,6 +70,13 @@ final class CloudStartSessionAsks { } } + /// The card that asks: the agent's ask in `text`, with Start and Not now. + static func askCard(tab: String, text: String) -> AgentPaneToolCard { + let actions = [AgentPaneToolCard.Action(id: "start", title: CloudStartSessionStrings.start), + AgentPaneToolCard.Action(id: "notNow", title: CloudMoveAskStrings.notNow)] + return AgentPaneToolCard(id: "cloud-start-session-" + tab, state: .ask, text: text, actions: actions) + } + private func card(_ tab: String, _ state: AgentPaneToolCard.State, _ text: String, _ actions: [AgentPaneToolCard.Action]) -> AgentPaneToolCard { AgentPaneToolCard(id: "cloud-start-session-" + tab, state: state, text: text, actions: actions) } @@ -82,10 +87,12 @@ final class CloudStartSessionAsks { } } -/// `cmux cloud start-session` runs by what they ask for: the same ask again while one runs joins -/// it, and the next same ask after a run that outlasted its caller's answer (the caller got -/// `pending`) gets that run's end, once. A run its caller saw end is not kept. Ended runs are kept at most ``kept`` long and at most ``limit`` of them, pruned when -/// a run starts; no timer. +/// `cmux cloud start-session` runs by what they ask for. Each caller waits for its run at most +/// its answer time, then answers `pending`, and the run goes on. The same ask again while a run +/// goes on waits for that run. A run that ends with a caller still waiting has reached it and is +/// dropped; one that ends after every caller answered `pending` is kept for the next same ask, +/// which gets its end once. A kept run lasts at most ``kept`` (checked on every ask) and at most +/// ``limit`` are kept, pruned when a run starts; no timer. @MainActor final class CloudStartSessionRuns { struct Key: Hashable { @@ -104,45 +111,93 @@ final class CloudStartSessionRuns { } typealias Fields = [String: ActionResultValue] + typealias Outcome = Result - private struct Run { - var task: Task - var ended: ContinuousClock.Instant? + private final class Run { + var task: Task? + /// Callers waiting for its end now. + var waiting = 0 + var outcome: Outcome? + /// When it ended with nobody waiting (kept for the next same ask). + var kept: ContinuousClock.Instant? } static let kept: Duration = .seconds(600) static let limit = 32 private var runs: [Key: Run] = [:] - var now: () -> ContinuousClock.Instant = { ContinuousClock.now } + var now: @MainActor () -> ContinuousClock.Instant = { ContinuousClock.now } + /// The caller's wait: a one-shot sleep, cancelled when the run ends first. + var sleep: @MainActor (Duration) async -> Void = { duration in + // wakeup-allow: one-shot answer deadline of one caller; cancelled when its run ends first + try? await Task.sleep(for: duration) + } + + /// The end of `key`'s run, or `pending` once `within` has passed (the run goes on): a kept + /// run's end (handed out once), the one going on, else `work`, started now. + func answer(_ key: Key, within: Duration, pending: Fields, _ work: @escaping @MainActor () async throws -> Fields) async throws -> Fields { + let run = take(key, work) + if let outcome = run.outcome, run.kept != nil { return try outcome.get() } + run.waiting += 1 + let (ends, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingOldest(1)) + // task-owner: this caller's wait for the run; ends with the run, which has its own deadlines. + let waiter = Task { @MainActor in continuation.yield(await run.task?.value) } + // task-owner: this caller's answer deadline; cancelled below once the run ends first. + let deadline = Task { @MainActor [sleep] in + await sleep(within) + continuation.yield(nil) + } + defer { + deadline.cancel() + waiter.cancel() + continuation.finish() + } + var ended: Outcome? + for await value in ends { + ended = value + break + } + run.waiting -= 1 + // An end that came as the deadline did still reaches this caller. + if let outcome = ended ?? run.outcome { return try outcome.get() } + return pending + } - /// The run of `key`: one already running or ended (an ended one is handed out once), else - /// `work`, started now. `answeredWithin`: how long the caller waits before it answers - /// `pending`; a run that ends sooner reached its caller and is not kept. - func join(_ key: Key, answeredWithin: Duration, _ work: @escaping @MainActor () async throws -> Fields) async throws -> Fields { + /// The run of `key` to answer: a kept one (taken out, its end handed out once), the one going + /// on, else a new one. + private func take(_ key: Key, _ work: @escaping @MainActor () async throws -> Fields) -> Run { if let run = runs[key] { - if run.ended != nil { runs[key] = nil } - return try await run.task.value + if let kept = run.kept { + runs[key] = nil + if now() - kept <= Self.kept { return run } + } else { + return run + } } prune() - // task-owner: the start itself; it runs to its end even after its caller answered pending. - let task = Task { @MainActor in try await work() } - runs[key] = Run(task: task) - let started = now() - defer { - if runs[key]?.task == task { - // A margin under the caller's own wait: an end that close may have lost the race. - if now() - started < answeredWithin - .seconds(2) { runs[key] = nil } else { runs[key]?.ended = now() } + let run = Run() + runs[key] = run + // task-owner: the start itself; it runs to its end even after its callers answered pending. + run.task = Task { @MainActor [weak self] in + let outcome: Outcome + do { outcome = .success(try await work()) } catch { outcome = .failure(error) } + run.outcome = outcome + if let self, runs[key] === run { + if run.waiting > 0 { runs[key] = nil } else { run.kept = now() } } + return outcome } - return try await task.value + return run } + /// Waits for `key`'s run to end (tests). + func settled(_ key: Key) async { _ = await runs[key]?.task?.value } + var count: Int { runs.count } private func prune() { let cutoff = now() - Self.kept - runs = runs.filter { $0.value.ended.map { $0 > cutoff } ?? true } - let ended = runs.filter { $0.value.ended != nil }.sorted { $0.value.ended! < $1.value.ended! } - for (key, _) in ended.prefix(max(0, runs.count - Self.limit + 1)) { runs[key] = nil } + runs = runs.filter { $0.value.kept.map { $0 > cutoff } ?? true } + let kept = runs.compactMap { key, run in run.kept.map { (key, $0) } }.sorted { $0.1 < $1.1 } + for (key, _) in kept.prefix(max(0, kept.count - Self.limit + 1)) { runs[key] = nil } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift index 38c8983a35a2..b6eef7a3e842 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift @@ -21,9 +21,10 @@ enum CloudStartSessionStrings { String(localized: "cloud.startSession.cwdInvalid", defaultValue: "Pass --cwd as an absolute folder on the machine, for example `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`.", table: "CloudSessions", bundle: .module) } - /// The machine to create (`--name`) could not be created; `reason` says why. - static func createFailed(_ name: String, agent: String, reason: String) -> String { - String(format: String(localized: "cloud.startSession.createFailed", defaultValue: "Couldn't create Cloud machine `%1$@` (%3$@), so no agent started. Run `cmux cloud start-session --name %1$@ --agent %2$@` again, or pick a machine from `cmux cloud list-machines`.", table: "CloudSessions", bundle: .module), name, agent, reason) + /// The machine to create (`--name`) could not be created; `reason` says why. `quoted` is the + /// name as one shell word for the command. + static func createFailed(_ name: String, quoted: String, agent: String, reason: String) -> String { + String(format: String(localized: "cloud.startSession.createFailed", defaultValue: "Couldn't create Cloud machine `%1$@` (%4$@), so no agent started. Run `cmux cloud start-session --name %2$@ --agent %3$@` again, or pick a machine from `cmux cloud list-machines`.", table: "CloudSessions", bundle: .module), name, quoted, agent, reason) } /// A run that answered `pending`: the same command again answers its end. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift index a9bf7f120884..ec32583e23dd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift @@ -1,5 +1,6 @@ #if DEBUG || CMUX_BENCH_HOOKS import AppKit +import CmuxNextAgentPane import CmuxNextSettings /// Describes the named DEBUG scenes available to the deterministic capture route. @@ -154,7 +155,15 @@ final class CaptureSceneRegistry { _ = DebugTunables.handle(["action": .string("set"), "key": .string("sidebar.sections.look"), "value": .string(name == "sidebar-tiles" ? "tray" : "quiet")], services: services) _ = await DebugAgentPane.handle(["action": .string("close_menus")], services) + let chat = services.windows.controllers.lazy.compactMap { $0.content?.focusedPane?.currentTabKey }.first + if let chat { services.cloud.toolCards.set([], owner: CloudStartSessionAsks.owner, tab: chat) { _, _, _ in } } switch name { + case "cloud-start-session-ask": + // The card an agent's `cmux cloud start-session` raises in its chat (CloudStartSessionAsks). + guard let chat else { return .object(["error": .string("no focused chat tab")]) } + let card = CloudStartSessionAsks.askCard(tab: chat, text: CloudStartSessionStrings.askNew(agent: "claude", machine: "dev")) + let shown = services.cloud.toolCards.set([card], owner: CloudStartSessionAsks.owner, tab: chat) { _, _, _ in } + return .object(["card": .bool(shown), "tab": .string(chat)]) case "composer": return await DebugAgentPane.handle(["action": .string("open_menu"), "label": .string("Model")], services) case "sidebar-tiles": @@ -194,6 +203,7 @@ final class CaptureSceneRegistry { CaptureSceneDefinition(name: "sidebar-tiles", description: "Showcase rail and workspace tiles."), CaptureSceneDefinition(name: "settings", description: "Showcase window with the settings destination available."), CaptureSceneDefinition(name: "history-narrow", description: "Narrow history destination fixture."), + CaptureSceneDefinition(name: "cloud-start-session-ask", description: "An agent's cmux cloud start-session ask card in its chat."), CaptureSceneDefinition(name: "hints-cmd-held", description: "Command modifier hint destination."), CaptureSceneDefinition(name: "hints-ctrl-held", description: "Control modifier hint destination."), CaptureSceneDefinition(name: homeChiefScene, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings index b7c3397900ee..b3728444c6aa 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings @@ -62,18 +62,18 @@ } }, "cloud.startSession.createFailed": { - "comment": "The named machine could not be created. %1$@ is its name, %2$@ the agent, %3$@ the reason. Keep the commands as written.", + "comment": "The named machine could not be created. %1$@ is its name, %2$@ the name as a shell word, %3$@ the agent, %4$@ the reason. Keep the commands as written.", "extractionState": "manual", "localizations": { - "en": {"stringUnit": {"state": "translated", "value": "Couldn't create Cloud machine `%1$@` (%3$@), so no agent started. Run `cmux cloud start-session --name %1$@ --agent %2$@` again, or pick a machine from `cmux cloud list-machines`."}}, - "ja": {"stringUnit": {"state": "translated", "value": "Cloud マシン `%1$@` を作成できなかったため (%3$@)、エージェントは開始されませんでした。`cmux cloud start-session --name %1$@ --agent %2$@` をもう一度実行するか、`cmux cloud list-machines` からマシンを選んでください。"}}, - "de": {"stringUnit": {"state": "needs_review", "value": "Cloud-Maschine `%1$@` konnte nicht erstellt werden (%3$@), daher wurde kein Agent gestartet. Führe `cmux cloud start-session --name %1$@ --agent %2$@` erneut aus oder wähle eine Maschine aus `cmux cloud list-machines`."}}, - "fr": {"stringUnit": {"state": "needs_review", "value": "Impossible de créer la machine Cloud `%1$@` (%3$@) : aucun agent n'a démarré. Relancez `cmux cloud start-session --name %1$@ --agent %2$@`, ou choisissez une machine dans `cmux cloud list-machines`."}}, - "es": {"stringUnit": {"state": "needs_review", "value": "No se pudo crear la máquina de Cloud `%1$@` (%3$@), así que no se inició ningún agente. Vuelve a ejecutar `cmux cloud start-session --name %1$@ --agent %2$@` o elige una máquina de `cmux cloud list-machines`."}}, - "ar": {"stringUnit": {"state": "needs_review", "value": "تعذّر إنشاء جهاز Cloud ‏`%1$@` (%3$@)، لذا لم يبدأ أي وكيل. شغّل `cmux cloud start-session --name %1$@ --agent %2$@` مرة أخرى، أو اختر جهازًا من `cmux cloud list-machines`."}}, - "ko": {"stringUnit": {"state": "needs_review", "value": "Cloud 머신 `%1$@`을(를) 만들지 못해(%3$@) 에이전트가 시작되지 않았습니다. `cmux cloud start-session --name %1$@ --agent %2$@`을(를) 다시 실행하거나 `cmux cloud list-machines`에서 머신을 고르세요."}}, - "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "无法创建 Cloud 机器 `%1$@`(%3$@),因此没有启动代理。请再次运行 `cmux cloud start-session --name %1$@ --agent %2$@`,或从 `cmux cloud list-machines` 中选一台机器。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "無法建立 Cloud 機器 `%1$@`(%3$@),因此沒有啟動代理。請再次執行 `cmux cloud start-session --name %1$@ --agent %2$@`,或從 `cmux cloud list-machines` 中選一台機器。"}} + "en": {"stringUnit": {"state": "translated", "value": "Couldn't create Cloud machine `%1$@` (%4$@), so no agent started. Run `cmux cloud start-session --name %2$@ --agent %3$@` again, or pick a machine from `cmux cloud list-machines`."}}, + "ja": {"stringUnit": {"state": "translated", "value": "Cloud マシン `%1$@` を作成できなかったため (%4$@)、エージェントは開始されませんでした。`cmux cloud start-session --name %2$@ --agent %3$@` をもう一度実行するか、`cmux cloud list-machines` からマシンを選んでください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Cloud-Maschine `%1$@` konnte nicht erstellt werden (%4$@), daher wurde kein Agent gestartet. Führe `cmux cloud start-session --name %2$@ --agent %3$@` erneut aus oder wähle eine Maschine aus `cmux cloud list-machines`."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Impossible de créer la machine Cloud `%1$@` (%4$@) : aucun agent n'a démarré. Relancez `cmux cloud start-session --name %2$@ --agent %3$@`, ou choisissez une machine dans `cmux cloud list-machines`."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "No se pudo crear la máquina de Cloud `%1$@` (%4$@), así que no se inició ningún agente. Vuelve a ejecutar `cmux cloud start-session --name %2$@ --agent %3$@` o elige una máquina de `cmux cloud list-machines`."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "تعذّر إنشاء جهاز Cloud ‏`%1$@` (%4$@)، لذا لم يبدأ أي وكيل. شغّل `cmux cloud start-session --name %2$@ --agent %3$@` مرة أخرى، أو اختر جهازًا من `cmux cloud list-machines`."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "Cloud 머신 `%1$@`을(를) 만들지 못해(%4$@) 에이전트가 시작되지 않았습니다. `cmux cloud start-session --name %2$@ --agent %3$@`을(를) 다시 실행하거나 `cmux cloud list-machines`에서 머신을 고르세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "无法创建 Cloud 机器 `%1$@`(%4$@),因此没有启动代理。请再次运行 `cmux cloud start-session --name %2$@ --agent %3$@`,或从 `cmux cloud list-machines` 中选一台机器。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "無法建立 Cloud 機器 `%1$@`(%4$@),因此沒有啟動代理。請再次執行 `cmux cloud start-session --name %2$@ --agent %3$@`,或從 `cmux cloud list-machines` 中選一台機器。"}} } }, "cloud.startSession.stillStarting": { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index c7ef53854c87..53f17570a1d4 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -111,7 +111,7 @@ struct CloudStartSessionTests { let messages = [ CloudStartSessionStrings.needsMachine(agent: "claude"), CloudStartSessionStrings.noSuchMachine(id: "vm-x", agent: "claude"), CloudStartSessionStrings.agentInvalid, CloudStartSessionStrings.cwdInvalid, CloudStartSessionStrings.stillStarting, - CloudStartSessionStrings.createFailed("dev", agent: "claude", reason: "x"), CloudStartSessionStrings.notConnected("dev", id: "vm-x", agent: "claude"), + CloudStartSessionStrings.createFailed("dev", quoted: "dev", agent: "claude", reason: "x"), CloudStartSessionStrings.notConnected("dev", id: "vm-x", agent: "claude"), CloudStartSessionStrings.agentFailed("dev", id: "vm-x", agent: "claude", reason: "x"), CloudStartSessionStrings.needsFolder("dev", id: "vm-x", agent: "claude"), CloudStartSessionStrings.notInChat, CloudStartSessionStrings.noPage, CloudStartSessionStrings.askOpen, CloudStartSessionStrings.declined, CloudStartSessionStrings.expired, @@ -138,26 +138,38 @@ struct CloudStartSessionTests { #expect(!failure.message.contains(".)")) } - // MARK: The card belongs to its ask until the run ends - - /// Yields to the main actor's other tasks until `ready` (at most 50 turns; no clock). - static func settle(_ ready: () -> Bool) async { - for _ in 0..<50 where !ready() { await Task.yield() } + @Test func theRetryCommandKeepsTheNameOneShellWord() { + #expect(CloudStartSession.shellQuoted("dev-1") == "dev-1") + #expect(CloudStartSession.shellQuoted("my dev") == "'my dev'") + #expect(CloudStartSession.shellQuoted("it's") == "'it'\\''s'") + #expect(CloudStartSession.shellQuoted("a;rm -rf ~") == "'a;rm -rf ~'") + let message = CloudStartSessionStrings.createFailed("my dev", quoted: CloudStartSession.shellQuoted("my dev"), agent: "claude", reason: "x") + #expect(message.contains("`cmux cloud start-session --name 'my dev' --agent claude`")) + #expect(message.hasPrefix("Couldn't create Cloud machine `my dev` (x)")) } - /// Cards shown in place of a chat page, and the clicks a test sends them. + // MARK: The card belongs to its ask until the run ends + + /// Cards shown in place of a chat page, the clicks a test sends them, and a signal per card shown. final class Cards { var shown: [String: [AgentPaneToolCard]] = [:] var answers: [String: AgentToolCards.Answer] = [:] + let (events, event) = AsyncStream.makeStream() func install(_ asks: CloudStartSessionAsks) { asks.present = { [unowned self] cards, tab, _, answer in shown[tab] = cards.isEmpty ? nil : cards answers[tab] = answer + if !cards.isEmpty { event.yield(tab) } return true } } + /// Waits until a card shows in `tab` (the ask reached its card). + func shownIn(_ tab: String) async { + for await shownTab in events where shownTab == tab { return } + } + func click(_ tab: String, _ action: String) { answers[tab]?("cloud-start-session-" + tab, action, nil) } } @@ -166,7 +178,7 @@ struct CloudStartSessionTests { let asks = CloudStartSessionAsks(), cards = Cards() cards.install(asks) let first = Task { try await asks.ask(tab: "chat-1", text: "first", services: services) } - await Self.settle { cards.shown["chat-1"] != nil } + await cards.shownIn("chat-1") #expect(cards.shown["chat-1"]?.first?.state == .ask) cards.click("chat-1", "start") let token = try await first.value @@ -191,7 +203,7 @@ struct CloudStartSessionTests { let asks = CloudStartSessionAsks(), cards = Cards() cards.install(asks) let ask = Task { try await asks.ask(tab: "chat-2", text: "ask", services: services) } - await Self.settle { cards.shown["chat-2"] != nil } + await cards.shownIn("chat-2") cards.click("chat-2", "notNow") await #expect(throws: ActionFailure(message: CloudStartSessionStrings.declined)) { _ = try await ask.value } #expect(cards.shown["chat-2"] == nil) @@ -206,48 +218,107 @@ struct CloudStartSessionTests { init(_ value: T) { self.value = value } } - @Test func theSameAskWhileARunGoesOnJoinsIt() async throws { + static let key = CloudStartSessionRuns.Key(CloudStartSession.Request(machineID: nil, name: "dev", agent: "codex", cwd: nil), caller: "chat-9") + static let pending: CloudStartSessionRuns.Fields = ["pending": .bool(true)] + /// A caller's wait that never ends by itself (the run's end cancels it). + static let waits: @MainActor (Duration) async -> Void = { _ in try? await Task.sleep(for: .seconds(3600)) } + + @Test func aRunItsCallerSawEndIsNotKept() async throws { + let runs = CloudStartSessionRuns() + runs.sleep = Self.waits + let started = Box(0) + let work: @MainActor () async throws -> CloudStartSessionRuns.Fields = { + started.value += 1 + return ["session_id": .string("s-\(started.value)")] + } + #expect(try await runs.answer(Self.key, within: .seconds(190), pending: Self.pending, work)["session_id"] == .string("s-1")) + #expect(runs.count == 0) + #expect(try await runs.answer(Self.key, within: .seconds(190), pending: Self.pending, work)["session_id"] == .string("s-2"), "a new session, not the last one") + } + + @Test func theSameAskWhileARunGoesOnWaitsForItAndTakesItsEnd() async throws { let runs = CloudStartSessionRuns() - let key = CloudStartSessionRuns.Key(CloudStartSession.Request(machineID: "vm-a", name: nil, agent: "claude", cwd: nil), caller: nil) let (gate, open) = AsyncStream.makeStream() + defer { open.finish() } let started = Box(0) let work: @MainActor () async throws -> CloudStartSessionRuns.Fields = { started.value += 1 for await _ in gate { break } return ["session_id": .string("s-1")] } - let first = Task { try await runs.join(key, answeredWithin: .seconds(190), work) } - await Self.settle { started.value == 1 } - let second = Task { try await runs.join(key, answeredWithin: .seconds(190), work) } - await Self.settle { false } // lets the second join reach the running one - open.yield(()) - #expect(try await first.value["session_id"] == .string("s-1")) - #expect(try await second.value["session_id"] == .string("s-1")) + // The first caller's wait ends at once: it answers pending, and the run goes on. + runs.sleep = { _ in } + #expect(try await runs.answer(Self.key, within: .seconds(190), pending: Self.pending, work) == Self.pending) + // The second caller is waiting when its own wait begins: that lets the run end. + runs.sleep = { _ in + open.yield(()) + try? await Task.sleep(for: .seconds(3600)) + } + #expect(try await runs.answer(Self.key, within: .seconds(190), pending: Self.pending, work)["session_id"] == .string("s-1")) #expect(started.value == 1) - // Its caller saw it end: the next same ask starts a new run. - #expect(runs.count == 0) + #expect(runs.count == 0, "it reached a caller, so it is not kept") } - @Test func aRunThatOutlastedItsCallerIsHandedOutOnce() async throws { + @Test func aRunEveryCallerLeftIsHandedToTheNextSameAskOnce() async throws { let runs = CloudStartSessionRuns() let clock = Box(ContinuousClock.now) runs.now = { clock.value } - let key = CloudStartSessionRuns.Key(CloudStartSession.Request(machineID: nil, name: "dev", agent: "codex", cwd: nil), caller: "chat-9") + let (gate, open) = AsyncStream.makeStream() + defer { open.finish() } let started = Box(0) - let slow: @MainActor () async throws -> CloudStartSessionRuns.Fields = { + let work: @MainActor () async throws -> CloudStartSessionRuns.Fields = { started.value += 1 - clock.value += .seconds(300) // longer than the caller waits + for await _ in gate { break } return ["session_id": .string("s-\(started.value)")] } - #expect(try await runs.join(key, answeredWithin: .seconds(190), slow)["session_id"] == .string("s-1")) - #expect(runs.count == 1, "kept for the caller that got pending") - #expect(try await runs.join(key, answeredWithin: .seconds(190), slow)["session_id"] == .string("s-1")) + runs.sleep = { _ in } + #expect(try await runs.answer(Self.key, within: .seconds(190), pending: Self.pending, work) == Self.pending) + open.yield(()) + await runs.settled(Self.key) + #expect(runs.count == 1, "nobody saw it end") + runs.sleep = Self.waits + #expect(try await runs.answer(Self.key, within: .seconds(190), pending: Self.pending, work)["session_id"] == .string("s-1")) #expect(started.value == 1) - #expect(try await runs.join(key, answeredWithin: .seconds(190), slow)["session_id"] == .string("s-2"), "handed out once") - // Kept at most ten minutes. + #expect(runs.count == 0, "handed out once") + } + + @Test func aKeptRunExpiresForTheSameAsk() async throws { + let runs = CloudStartSessionRuns() + let clock = Box(ContinuousClock.now) + runs.now = { clock.value } + let (gate, open) = AsyncStream.makeStream() + defer { open.finish() } + let slow: @MainActor () async throws -> CloudStartSessionRuns.Fields = { + for await _ in gate { break } + return ["session_id": .string("old")] + } + runs.sleep = { _ in } + _ = try await runs.answer(Self.key, within: .seconds(190), pending: Self.pending, slow) + open.yield(()) + await runs.settled(Self.key) clock.value += CloudStartSessionRuns.kept + .seconds(1) - let other = CloudStartSessionRuns.Key(CloudStartSession.Request(machineID: "vm-b", name: nil, agent: "codex", cwd: nil), caller: nil) - _ = try await runs.join(other, answeredWithin: .seconds(190)) { ["session_id": .string("b")] } + runs.sleep = Self.waits + let fresh = try await runs.answer(Self.key, within: .seconds(190), pending: Self.pending) { ["session_id": .string("new")] } + #expect(fresh["session_id"] == .string("new")) + #expect(runs.count == 0) + } + + @Test func aFailedRunEveryCallerLeftIsHandedOutOnceToo() async throws { + let runs = CloudStartSessionRuns() + let (gate, open) = AsyncStream.makeStream() + defer { open.finish() } + let failing: @MainActor () async throws -> CloudStartSessionRuns.Fields = { + for await _ in gate { break } + throw ActionFailure(message: "Machine `dev` started but its agent didn't (x).") + } + runs.sleep = { _ in } + _ = try await runs.answer(Self.key, within: .seconds(190), pending: Self.pending, failing) + open.yield(()) + await runs.settled(Self.key) + runs.sleep = Self.waits + await #expect(throws: ActionFailure(message: "Machine `dev` started but its agent didn't (x).")) { + _ = try await runs.answer(Self.key, within: .seconds(190), pending: Self.pending, failing) + } #expect(runs.count == 0) } } From 3f6bf32570e586f542d186240d7e8e318d86d904 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 25 Oct 2026 22:01:54 -0700 Subject: [PATCH 05/33] cloud start-session tests: record the issue, then return Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../Tests/CmuxNextAppTests/CloudStartSessionTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index 53f17570a1d4..8ba1af043feb 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -132,7 +132,10 @@ struct CloudStartSessionTests { let invoke = try #require(services.registry.action(for: CloudStartSession.action)?.invoke) let run = services.registry.capturingRun { invoke(Self.invocation(["agent": .string("claude"), "name": .string(name)])) } let task = try #require(run.results.first, "start-session tracked no result") - guard case .failure(let failure) = await task.value else { return Issue.record("a create with no Cloud sign-in went through") } + guard case .failure(let failure) = await task.value else { + Issue.record("a create with no Cloud sign-in went through") + return + } #expect(failure.message.hasPrefix("Couldn't create Cloud machine `\(name)` ("), "\(failure.message)") #expect(failure.message.contains("`cmux cloud start-session --name \(name) --agent claude`")) #expect(!failure.message.contains(".)")) From 7580b096584f0fc4fa63dfdc28003cda38f8daeb Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 25 Oct 2026 22:17:36 -0700 Subject: [PATCH 06/33] cloud start-session: pending answers say pending; agents in a Cloud machine's chat may ask; the keep limit holds when runs end together Review fixes: - The production pending answer carries pending: true (with a test through the race). - Agents in a chat on any machine's store are found. - Kept runs are trimmed when a run is kept, not only when one starts. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextApp/Cloud/CloudMoveAsks.swift | 2 +- .../CmuxNextApp/Cloud/CloudStartSession.swift | 16 +++++- .../Cloud/CloudStartSessionAsks.swift | 19 +++++-- .../CloudStartSessionTests.swift | 51 +++++++++++++++++++ 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveAsks.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveAsks.swift index 7594cb35c9c8..b09c8fa7d239 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveAsks.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveAsks.swift @@ -171,7 +171,7 @@ final class CloudMoveAsks { } /// The local chat tab of agent session `session`, with its pane. - static func chat(_ session: String, services: AppServices) -> (TabModel, PaneModel)? { + private static func chat(_ session: String, services: AppServices) -> (TabModel, PaneModel)? { let tabs = services.agentTabs for pane in services.daemon.store.workspaces.flatMap(\.screens).flatMap(\.panes) { if let tab = pane.tabs.first(where: { (tabs.sessions[tabs.resolve($0.id)] ?? $0.agentSession?.session) == session }) { return (tab, pane) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift index 072113319720..09724ab12bd3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift @@ -49,7 +49,7 @@ struct CloudStartSession { } } guard request.machineID != nil || request.name != nil else { throw ActionFailure(message: CloudStartSessionStrings.needsMachine(agent: request.agent)) } - guard let caller = invocation.callerAgentSession, let (tab, _) = CloudMoveAsks.chat(caller, services: services) else { + guard let caller = invocation.callerAgentSession, let tab = Self.chat(caller, services: services) else { throw ActionFailure(message: CloudStartSessionStrings.notInChat) } let shown = request.machineID.flatMap { services.machines.session($0)?.machine.title } @@ -85,12 +85,24 @@ struct CloudStartSession { static let answerWithin: Duration = ActionDescriptor.personDeadline - .seconds(10) static func pending(_ request: Request) -> [String: ActionResultValue] { - var fields: [String: ActionResultValue] = ["agent": .string(request.agent), "text": .string(CloudStartSessionStrings.stillStarting)] + var fields: [String: ActionResultValue] = ["pending": .bool(true), "agent": .string(request.agent), "text": .string(CloudStartSessionStrings.stillStarting)] if let id = request.machineID { fields["machine_id"] = .string(id) } if let name = request.name { fields["machine_name"] = .string(name) } return fields } + /// The chat tab of agent session `session` on any machine: a chat on this Mac, or one on a + /// Cloud machine (a session this command started there may start another). + static func chat(_ session: String, services: AppServices) -> TabModel? { + let tabs = services.agentTabs + for (workspace, _) in services.machines.allWorkspaces { + for pane in workspace.screens.flatMap(\.panes) { + if let tab = pane.tabs.first(where: { (tabs.sessions[tabs.resolve($0.id)] ?? $0.agentSession?.session) == session }) { return tab } + } + } + return nil + } + /// The run's arguments, checked: `--agent` is a harness id, `--cwd` an absolute folder. static func request(_ invocation: ActionInvocation) throws -> Request { let agent = invocation["agent"]?.stringValue?.trimmingCharacters(in: .whitespaces) ?? "" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift index c020a45c152a..5155c7fb9c3d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift @@ -92,7 +92,7 @@ final class CloudStartSessionAsks { /// goes on waits for that run. A run that ends with a caller still waiting has reached it and is /// dropped; one that ends after every caller answered `pending` is kept for the next same ask, /// which gets its end once. A kept run lasts at most ``kept`` (checked on every ask) and at most -/// ``limit`` are kept, pruned when a run starts; no timer. +/// ``limit`` are kept, trimmed when a run starts and when one is kept; no timer. @MainActor final class CloudStartSessionRuns { struct Key: Hashable { @@ -182,7 +182,12 @@ final class CloudStartSessionRuns { do { outcome = .success(try await work()) } catch { outcome = .failure(error) } run.outcome = outcome if let self, runs[key] === run { - if run.waiting > 0 { runs[key] = nil } else { run.kept = now() } + if run.waiting > 0 { + runs[key] = nil + } else { + run.kept = now() + trim(keeping: Self.limit) + } } return outcome } @@ -194,10 +199,16 @@ final class CloudStartSessionRuns { var count: Int { runs.count } - private func prune() { + /// Drops expired kept runs, then the oldest kept ones beyond `keeping`. + private func trim(keeping: Int) { let cutoff = now() - Self.kept runs = runs.filter { $0.value.kept.map { $0 > cutoff } ?? true } let kept = runs.compactMap { key, run in run.kept.map { (key, $0) } }.sorted { $0.1 < $1.1 } - for (key, _) in kept.prefix(max(0, kept.count - Self.limit + 1)) { runs[key] = nil } + for (key, _) in kept.prefix(max(0, kept.count - keeping)) { runs[key] = nil } } + + /// Before a new run: room for it among the kept ones. + private func prune() { trim(keeping: Self.limit - 1) } + + var keptCount: Int { runs.values.filter { $0.kept != nil }.count } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index 8ba1af043feb..fe504bed61a1 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -3,6 +3,7 @@ import CmuxNextActions import CmuxNextAgentPane @testable import CmuxNextApp import CmuxNextCloud +import CmuxNextDaemon import Foundation import Testing @@ -79,6 +80,36 @@ struct CloudStartSessionTests { #expect(mcp == CloudStartSessionStrings.notInChat) } + /// An agent in a chat on a Cloud machine (one this command started) asks in that chat too. + @Test func anAgentInAChatOnACloudMachineIsFound() throws { + let f = Self.fixture() + var chat = TabSnapshot(surface: SurfaceID(rawValue: 7), kind: .conversation, cwd: nil) + chat.conversation = ConversationTabRef(agentSession: AgentSessionRef(host: "registry:vm-x", session: "remote-session-1", harness: "claude")) + let screen = ScreenSnapshot(id: 1, layout: .leaf(1), panes: [PaneSnapshot(id: 1, tabs: [chat])]) + f.session.daemon.store.apply(snapshot: DaemonTree(workspaces: [WorkspaceSnapshot(id: 1, key: nil, name: "w", screens: [screen])])) + let found = try #require(CloudStartSession.chat("remote-session-1", services: f.services), "the machine's chat was not found") + #expect(found.agentSession?.session == "remote-session-1") + #expect(CloudStartSession.chat("no-such-session", services: f.services) == nil) + } + + @Test func aRunStillStartingAnswersPendingWithWhatToRunNext() async throws { + let request = CloudStartSession.Request(machineID: "vm-a", name: nil, agent: "claude", cwd: nil) + let pending = CloudStartSession.pending(request) + #expect(pending["pending"] == .bool(true)) + #expect(pending["machine_id"] == .string("vm-a")) + #expect(pending["text"] == .string(CloudStartSessionStrings.stillStarting)) + // Through the race: the caller's wait ends before the run. + let runs = CloudStartSessionRuns() + runs.sleep = { _ in } + let (gate, open) = AsyncStream.makeStream() + defer { open.finish() } + let answered = try await runs.answer(CloudStartSessionRuns.Key(request, caller: nil), within: CloudStartSession.answerWithin, pending: pending) { + for await _ in gate { break } + return ["session_id": .string("s")] + } + #expect(answered["pending"] == .bool(true) && answered["session_id"] == nil) + } + @Test func aCardThatCannotShowStartsNothingAndLeavesNoAskOpen() async { let services = Self.fixture().services let asks = CloudStartSessionAsks() @@ -324,4 +355,24 @@ struct CloudStartSessionTests { } #expect(runs.count == 0) } + + @Test func runsThatEndTogetherKeepAtMostTheLimit() async throws { + let runs = CloudStartSessionRuns() + runs.sleep = { _ in } + let total = CloudStartSessionRuns.limit + 8 + let keys = (0...makeStream() } + defer { for gate in gates { gate.continuation.finish() } } + for (key, gate) in zip(keys, gates) { + _ = try await runs.answer(key, within: .seconds(190), pending: Self.pending) { + for await _ in gate.stream { break } + return ["session_id": .string("s")] + } + } + // Every caller answered pending; now every run ends. + for gate in gates { gate.continuation.yield(()) } + for key in keys { await runs.settled(key) } + #expect(runs.keptCount <= CloudStartSessionRuns.limit) + } } From c5b97fcdab23aab655c5f4c6e777f1fd86347222 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 25 Oct 2026 23:00:29 -0700 Subject: [PATCH 07/33] cloud start-session: agent runs follow the outer run's origin; --name reads a fresh machine list and creates once per name; refusals repeat the full command Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextApp/Cloud/CloudStartSession.swift | 98 ++++++++----- .../Cloud/CloudStartSessionAsks.swift | 20 ++- .../Cloud/CloudStartSessionStrings.swift | 41 ++++-- .../Resources/CloudSessions.xcstrings | 135 ++++++++++-------- .../CloudStartSessionTests.swift | 53 +++++-- 5 files changed, 222 insertions(+), 125 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift index 09724ab12bd3..b159c483f2df 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift @@ -29,6 +29,19 @@ struct CloudStartSession { var agent: String /// A folder on the machine; nil starts in the workspace's folder there. var cwd: String? + + /// The command that asks for this again, every word as the shell reads it back. + var command: String { + var words = ["cmux cloud start-session"] + if let machineID { words.append("--target machine:" + CloudStartSession.shellQuoted(machineID)) } else { words.append("--name " + CloudStartSession.shellQuoted(name ?? "")) } + words.append("--agent " + agent) + if let cwd { words.append("--cwd " + CloudStartSession.shellQuoted(cwd)) } + return words.joined(separator: " ") + } + + func asked(machine: String) -> CloudStartSessionStrings.Asked { + CloudStartSessionStrings.Asked(machine: machine, agent: agent, command: command) + } } static func bind(into registry: ActionRegistry, context: AppActionContext, reason: @escaping @MainActor () -> String?) { @@ -36,36 +49,48 @@ struct CloudStartSession { CloudHandlers.bind(action, registry, reason: reason) { invocation in var request = try Self.request(invocation) let start = CloudStartSession(services: services), runs = services.cloud.startSessionRuns - guard CloudMoveAsks.isAgent(invocation) else { - // A person's palette or menu run without a machine means the one they look at. - if request.machineID == nil, request.name == nil, CloudHandlers.origin(of: invocation) == .user { - request.machineID = try CloudHandlers.machine(invocation, context).machineID - } - if request.machineID == nil, request.name == nil { throw ActionFailure(message: CloudStartSessionStrings.needsMachine(agent: request.agent)) } - return CloudHandlers.result("start cloud session", context) { - try await runs.answer(CloudStartSessionRuns.Key(request, caller: nil), within: Self.answerWithin, pending: Self.pending(request)) { - try await start.run(request, invocation, context) - } - } + guard !Self.isAgent(invocation) else { return try Self.agentAsk(request, invocation, context) } + // A person's palette or menu run without a machine means the one they look at. + if request.machineID == nil, request.name == nil, CloudHandlers.origin(of: invocation) == .user { + request.machineID = try CloudHandlers.machine(invocation, context).machineID } - guard request.machineID != nil || request.name != nil else { throw ActionFailure(message: CloudStartSessionStrings.needsMachine(agent: request.agent)) } - guard let caller = invocation.callerAgentSession, let tab = Self.chat(caller, services: services) else { - throw ActionFailure(message: CloudStartSessionStrings.notInChat) - } - let shown = request.machineID.flatMap { services.machines.session($0)?.machine.title } - if let id = request.machineID, shown == nil { throw ActionFailure(message: CloudStartSessionStrings.noSuchMachine(id: id, agent: request.agent)) } - let named = shown ?? request.name ?? "" - let exists = shown != nil || services.machines.cloud.contains { $0.machine.title == named } - let asks = services.cloud.startSessionAsks - let text = exists ? CloudStartSessionStrings.ask(agent: request.agent, machine: named) : CloudStartSessionStrings.askNew(agent: request.agent, machine: named) - // The person's Start is the gesture: the machine is created as theirs, at the agent's ask. - var asPerson = ActionInvocation(target: invocation.target, arguments: invocation.arguments, origin: .user) - asPerson.callerAgentSession = nil - // The same ask again (the agent's run answered pending) joins the one that runs. - let key = CloudStartSessionRuns.Key(request, caller: caller) + if request.machineID == nil, request.name == nil { throw ActionFailure(message: CloudStartSessionStrings.needsMachine(agent: request.agent)) } CloudHandlers.result("start cloud session", context) { - try await runs.answer(key, within: Self.answerWithin, pending: Self.pending(request)) { - let token = try await asks.ask(tab: tab.id, text: text, services: services) + try await runs.answer(CloudStartSessionRuns.Key(request, caller: nil), within: Self.answerWithin, pending: Self.pending(request, services)) { + try await start.run(request, invocation, context) + } + } + } + } + + /// Whether the run is an agent's: a cmux MCP run (by its outermost caller, ``ActionRunScope``), + /// or one naming the agent chat that sent it. + static func isAgent(_ invocation: ActionInvocation) -> Bool { + CloudMoveAsks.isAgent(invocation) || CloudHandlers.origin(of: invocation) == .mcp + } + + /// An agent's run: a card in its own chat, and only the person's Start starts anything. + private static func agentAsk(_ request: Request, _ invocation: ActionInvocation, _ context: AppActionContext) throws { + let services = context.services, start = CloudStartSession(services: services), runs = services.cloud.startSessionRuns + guard request.machineID != nil || request.name != nil else { throw ActionFailure(message: CloudStartSessionStrings.needsMachine(agent: request.agent)) } + let shown = request.machineID.flatMap { services.machines.session($0)?.machine.title } + if let id = request.machineID, shown == nil { throw ActionFailure(message: CloudStartSessionStrings.noSuchMachine(id: id, agent: request.agent)) } + let named = shown ?? request.name ?? "" + let asked = request.asked(machine: named) + guard let caller = invocation.callerAgentSession, let tab = Self.chat(caller, services: services) else { + throw ActionFailure(message: CloudStartSessionStrings.notInChat(asked)) + } + let exists = shown != nil || services.machines.cloud.contains { $0.machine.title == named } + let asks = services.cloud.startSessionAsks + let text = exists ? CloudStartSessionStrings.ask(agent: request.agent, machine: named) : CloudStartSessionStrings.askNew(agent: request.agent, machine: named) + // The person's Start is the gesture: the machine is created as theirs, at the agent's ask. + var asPerson = ActionInvocation(target: invocation.target, arguments: invocation.arguments, origin: .user) + asPerson.callerAgentSession = nil + // The same ask again (the agent's run answered pending) joins the one that runs. + let key = CloudStartSessionRuns.Key(request, caller: caller) + CloudHandlers.result("start cloud session", context) { + try await runs.answer(key, within: Self.answerWithin, pending: Self.pending(request, services)) { + let token = try await asks.ask(tab: tab.id, text: text, asked: asked, services: services) asks.show(tab: tab.id, token: token, .working, CloudStartSessionStrings.starting(agent: request.agent, machine: named), services: services) do { let fields = try await start.run(request, asPerson, context, askedBy: caller, revealing: false) @@ -75,7 +100,6 @@ struct CloudStartSession { asks.finish(tab: tab.id, token: token, failure: (error as? ActionFailure)?.message ?? Self.startFailure(error, name: named, id: request.machineID ?? named, agent: request.agent), services: services) throw error } - } } } } @@ -84,8 +108,10 @@ struct CloudStartSession { /// answers `pending` (the work goes on, and the session's tab opens in the machine's workspace). static let answerWithin: Duration = ActionDescriptor.personDeadline - .seconds(10) - static func pending(_ request: Request) -> [String: ActionResultValue] { - var fields: [String: ActionResultValue] = ["pending": .bool(true), "agent": .string(request.agent), "text": .string(CloudStartSessionStrings.stillStarting)] + static func pending(_ request: Request, _ services: AppServices) -> [String: ActionResultValue] { + let machine = request.machineID.flatMap { services.machines.session($0)?.machine.title } ?? request.name ?? request.machineID ?? "" + var fields: [String: ActionResultValue] = ["pending": .bool(true), "agent": .string(request.agent), + "text": .string(CloudStartSessionStrings.stillStarting(request.asked(machine: machine)))] if let id = request.machineID { fields["machine_id"] = .string(id) } if let name = request.name { fields["machine_name"] = .string(name) } return fields @@ -198,9 +224,17 @@ struct CloudStartSession { return session } let name = request.name ?? "" + let cloud = services.cloud + // A list read just now: a machine by this name made elsewhere is taken, never made again. + await cloud.refresh(ifOlderThan: .seconds(15)) if let named = machines.cloud.first(where: { $0.machine.title == name }) { return named } + guard cloud.hasLoadedMachines else { throw ActionFailure(message: CloudStartSessionStrings.listUnread(request.asked(machine: name))) } do { - return try await services.cloud.createMachine(name: name, startedByPerson: invocation.origin == .user, askedBy: askedBy ?? invocation.callerAgentSession) + return try await cloud.startSessionRuns.creating(name) { [machines] in + // Another ask's creation may have ended first. + if let named = machines.cloud.first(where: { $0.machine.title == name }) { return named } + return try await cloud.createMachine(name: name, startedByPerson: invocation.origin == .user, askedBy: askedBy ?? invocation.callerAgentSession) + } } catch is CancellationError { throw CancellationError() } catch { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift index 5155c7fb9c3d..0973a5ba50ab 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift @@ -19,14 +19,14 @@ final class CloudStartSessionAsks { /// Shows the card in chat tab `tab` and returns its token once the person clicks Start. Throws /// when they choose Not now, when nobody answers within ``wait``, or when the chat has no page. - func ask(tab: String, text: String, services: AppServices) async throws -> UUID { - guard open[tab] == nil else { throw ActionFailure(message: CloudStartSessionStrings.askOpen) } + func ask(tab: String, text: String, asked: CloudStartSessionStrings.Asked, services: AppServices) async throws -> UUID { + guard open[tab] == nil else { throw ActionFailure(message: CloudStartSessionStrings.askOpen(asked)) } let token = UUID() let (answers, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingOldest(1)) guard set([Self.askCard(tab: tab, text: text)], tab: tab, services: services, answer: { _, action, _ in continuation.yield(action == "start") }) else { continuation.finish() _ = set([], tab: tab, services: services) { _, _, _ in } - throw ActionFailure(message: CloudStartSessionStrings.noPage) + throw ActionFailure(message: CloudStartSessionStrings.noPage(asked)) } open[tab] = token // task-owner: the card's one-shot deadline; cancelled below once the person answers. @@ -46,7 +46,7 @@ final class CloudStartSessionAsks { } guard answer == true else { finish(tab: tab, token: token, failure: nil, services: services) - throw ActionFailure(message: answer == false ? CloudStartSessionStrings.declined : CloudStartSessionStrings.expired) + throw ActionFailure(message: answer == false ? CloudStartSessionStrings.declined(asked) : CloudStartSessionStrings.expired(asked)) } return token } @@ -125,6 +125,18 @@ final class CloudStartSessionRuns { static let kept: Duration = .seconds(600) static let limit = 32 private var runs: [Key: Run] = [:] + /// Creations of a machine by name: two asks for one new name create one machine. + private var creations: [String: Task] = [:] + + /// The machine `create` makes for `name`, or the one a creation already going on makes. + func creating(_ name: String, _ create: @escaping @MainActor () async throws -> CloudMachineSession) async throws -> CloudMachineSession { + if let running = creations[name] { return try await running.value } + // task-owner: one creation per name; removed when it ends. + let task = Task { @MainActor in try await create() } + creations[name] = task + defer { if creations[name] == task { creations[name] = nil } } + return try await task.value + } var now: @MainActor () -> ContinuousClock.Instant = { ContinuousClock.now } /// The caller's wait: a one-shot sleep, cancelled when the run ends first. var sleep: @MainActor (Duration) async -> Void = { duration in diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift index b6eef7a3e842..71709fd85315 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift @@ -27,10 +27,6 @@ enum CloudStartSessionStrings { String(format: String(localized: "cloud.startSession.createFailed", defaultValue: "Couldn't create Cloud machine `%1$@` (%4$@), so no agent started. Run `cmux cloud start-session --name %2$@ --agent %3$@` again, or pick a machine from `cmux cloud list-machines`.", table: "CloudSessions", bundle: .module), name, quoted, agent, reason) } - /// A run that answered `pending`: the same command again answers its end. - static var stillStarting: String { - String(localized: "cloud.startSession.stillStarting", defaultValue: "The session is still starting. Run the same `cmux cloud start-session` command again to get its machine_id and session_id.", table: "CloudSessions", bundle: .module) - } static func notConnected(_ name: String, id: String, agent: String) -> String { String(format: String(localized: "cloud.startSession.notConnected", defaultValue: "Machine `%1$@` didn't connect, so no agent started there. Check it with `cmux cloud machine-status --target machine:%2$@`, then run `cmux cloud start-session --target machine:%2$@ --agent %3$@` again.", table: "CloudSessions", bundle: .module), name, id, agent) @@ -62,23 +58,40 @@ enum CloudStartSessionStrings { static var start: String { String(localized: "cloud.startSession.start", defaultValue: "Start", table: "CloudSessions", bundle: .module) } - static var notInChat: String { - String(localized: "cloud.startSession.notInChat", defaultValue: "Couldn't find the chat this agent runs in, so there is nowhere to ask the person. Run `cmux cloud start-session` from an agent's shell in a cmux chat, or ask the person to run it.", table: "CloudSessions", bundle: .module) + /// What a refusal of an agent's ask names: the machine, the agent and the command to run again. + struct Asked: Equatable { + var machine: String + var agent: String + var command: String + } + + /// A run that answered `pending`: the same command again answers its end. + static func stillStarting(_ asked: Asked) -> String { + String(format: String(localized: "cloud.startSession.stillStarting", defaultValue: "%2$@ is still starting on %1$@. Run `%3$@` again to get its machine_id and session_id.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) + } + + static func notInChat(_ asked: Asked) -> String { + String(format: String(localized: "cloud.startSession.notInChat", defaultValue: "Couldn't find the chat this agent runs in, so nobody can be asked to start %2$@ on Cloud machine %1$@. Run `%3$@` from an agent's shell in a cmux chat, or ask the person to run it.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) + } + + static func noPage(_ asked: Asked) -> String { + String(format: String(localized: "cloud.startSession.noPage", defaultValue: "Couldn't show the question in the chat, so %2$@ didn't start on %1$@. Run `%3$@` again once the chat is open.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) } - static var noPage: String { - String(localized: "cloud.startSession.noPage", defaultValue: "Couldn't show the question in the chat, so nothing started. Run `cmux cloud start-session` again once the chat is open.", table: "CloudSessions", bundle: .module) + static func askOpen(_ asked: Asked) -> String { + String(format: String(localized: "cloud.startSession.askOpen", defaultValue: "This chat already asks the person to start a Cloud session. Wait for their answer, then run `%3$@` again if %2$@ still isn't running on %1$@.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) } - static var askOpen: String { - String(localized: "cloud.startSession.askOpen", defaultValue: "This chat already shows a question to start a Cloud session. Wait for the person's answer before you run `cmux cloud start-session` again.", table: "CloudSessions", bundle: .module) + static func declined(_ asked: Asked) -> String { + String(format: String(localized: "cloud.startSession.declined", defaultValue: "The person chose Not now, so %2$@ didn't start on %1$@. Ask them in the chat before you run `%3$@` again.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) } - static var declined: String { - String(localized: "cloud.startSession.declined", defaultValue: "The person chose Not now, so nothing started. Ask them in the chat before you run `cmux cloud start-session` again.", table: "CloudSessions", bundle: .module) + static func expired(_ asked: Asked) -> String { + String(format: String(localized: "cloud.startSession.expired", defaultValue: "Nobody answered the question in the chat within 2 minutes, so %2$@ didn't start on %1$@. Run `%3$@` again when the person is there.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) } - static var expired: String { - String(localized: "cloud.startSession.expired", defaultValue: "Nobody answered the question in the chat within 2 minutes, so nothing started. Run `cmux cloud start-session` again when the person is there.", table: "CloudSessions", bundle: .module) + /// The machine list could not be read, so a machine by that name may exist. + static func listUnread(_ asked: Asked) -> String { + String(format: String(localized: "cloud.startSession.listUnread", defaultValue: "Couldn't read your Cloud machines, so %2$@ didn't start: a machine named %1$@ may already exist. Check `cmux cloud list-machines`, then run `%3$@` again.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings index b3728444c6aa..6dfd42e37bd5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings @@ -77,18 +77,18 @@ } }, "cloud.startSession.stillStarting": { - "comment": "The run answered pending while the session still starts. Keep the command as written; keep machine_id and session_id in English.", + "comment": "The run answered pending while the session still starts. %1$@ machine, %2$@ agent, %3$@ the command (keep it in backticks). Keep machine_id and session_id in English.", "extractionState": "manual", "localizations": { - "en": {"stringUnit": {"state": "translated", "value": "The session is still starting. Run the same `cmux cloud start-session` command again to get its machine_id and session_id."}}, - "ja": {"stringUnit": {"state": "translated", "value": "セッションはまだ開始中です。同じ `cmux cloud start-session` コマンドをもう一度実行すると、machine_id と session_id を取得できます。"}}, - "de": {"stringUnit": {"state": "needs_review", "value": "Die Sitzung startet noch. Führe denselben Befehl `cmux cloud start-session` erneut aus, um machine_id und session_id zu erhalten."}}, - "fr": {"stringUnit": {"state": "needs_review", "value": "La session démarre encore. Relancez la même commande `cmux cloud start-session` pour obtenir son machine_id et son session_id."}}, - "es": {"stringUnit": {"state": "needs_review", "value": "La sesión todavía se está iniciando. Vuelve a ejecutar el mismo comando `cmux cloud start-session` para obtener su machine_id y session_id."}}, - "ar": {"stringUnit": {"state": "needs_review", "value": "لا تزال الجلسة قيد البدء. شغّل الأمر `cmux cloud start-session` نفسه مرة أخرى للحصول على machine_id وsession_id."}}, - "ko": {"stringUnit": {"state": "needs_review", "value": "세션이 아직 시작 중입니다. 같은 `cmux cloud start-session` 명령을 다시 실행하면 machine_id와 session_id를 받을 수 있습니다."}}, - "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "会话仍在启动。请再次运行同一条 `cmux cloud start-session` 命令以获取其 machine_id 和 session_id。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "工作階段仍在啟動。請再次執行同一條 `cmux cloud start-session` 命令以取得其 machine_id 和 session_id。"}} + "en": {"stringUnit": {"state": "translated", "value": "%2$@ is still starting on %1$@. Run `%3$@` again to get its machine_id and session_id."}}, + "ja": {"stringUnit": {"state": "translated", "value": "%1$@ で %2$@ はまだ開始中です。`%3$@` をもう一度実行すると、machine_id と session_id を取得できます。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "%2$@ startet noch auf %1$@. Führe `%3$@` erneut aus, um machine_id und session_id zu erhalten."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "%2$@ démarre encore sur %1$@. Relancez `%3$@` pour obtenir son machine_id et son session_id."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "%2$@ todavía se está iniciando en %1$@. Vuelve a ejecutar `%3$@` para obtener su machine_id y session_id."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "لا يزال %2$@ قيد البدء على %1$@. شغّل `%3$@` مرة أخرى للحصول على machine_id وsession_id."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "%1$@에서 %2$@이(가) 아직 시작 중입니다. `%3$@`을(를) 다시 실행하면 machine_id와 session_id를 받을 수 있습니다."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "%2$@ 仍在 %1$@ 上启动。请再次运行 `%3$@` 以获取其 machine_id 和 session_id。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "%2$@ 仍在 %1$@ 上啟動。請再次執行 `%3$@` 以取得其 machine_id 和 session_id。"}} } }, "cloud.startSession.notConnected": { @@ -197,78 +197,93 @@ } }, "cloud.startSession.notInChat": { - "comment": "An agent's run whose chat cmux can't find. Keep the command as written.", + "comment": "An agent's run whose chat cmux can't find. %1$@ machine, %2$@ agent, %3$@ the command to run (keep it in backticks).", "extractionState": "manual", "localizations": { - "en": {"stringUnit": {"state": "translated", "value": "Couldn't find the chat this agent runs in, so there is nowhere to ask the person. Run `cmux cloud start-session` from an agent's shell in a cmux chat, or ask the person to run it."}}, - "ja": {"stringUnit": {"state": "translated", "value": "このエージェントが動いているチャットが見つからないため、確認できる場所がありません。cmux チャット内のエージェントのシェルから `cmux cloud start-session` を実行するか、本人に実行を依頼してください。"}}, - "de": {"stringUnit": {"state": "needs_review", "value": "Der Chat dieses Agents wurde nicht gefunden, daher gibt es keinen Ort für die Frage. Führe `cmux cloud start-session` aus der Shell eines Agents in einem cmux-Chat aus oder bitte die Person, es auszuführen."}}, - "fr": {"stringUnit": {"state": "needs_review", "value": "Impossible de trouver la discussion de cet agent : il n'y a nulle part où poser la question. Lancez `cmux cloud start-session` depuis le shell d'un agent dans une discussion cmux, ou demandez à la personne de le lancer."}}, - "es": {"stringUnit": {"state": "needs_review", "value": "No se encontró el chat de este agente, así que no hay dónde preguntar. Ejecuta `cmux cloud start-session` desde la shell de un agente en un chat de cmux, o pide a la persona que lo ejecute."}}, - "ar": {"stringUnit": {"state": "needs_review", "value": "تعذّر العثور على الدردشة التي يعمل فيها هذا الوكيل، لذا لا يوجد مكان لطرح السؤال. شغّل `cmux cloud start-session` من صدفة وكيل في دردشة cmux، أو اطلب من الشخص تشغيله."}}, - "ko": {"stringUnit": {"state": "needs_review", "value": "이 에이전트가 실행 중인 채팅을 찾지 못해 물어볼 곳이 없습니다. cmux 채팅 속 에이전트의 셸에서 `cmux cloud start-session`을(를) 실행하거나 사용자에게 실행을 요청하세요."}}, - "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "找不到此代理所在的聊天,因此无处询问。请从 cmux 聊天中代理的 shell 运行 `cmux cloud start-session`,或请对方运行。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "找不到此代理所在的聊天,因此無處詢問。請從 cmux 聊天中代理的 shell 執行 `cmux cloud start-session`,或請對方執行。"}} + "en": {"stringUnit": {"state": "translated", "value": "Couldn't find the chat this agent runs in, so nobody can be asked to start %2$@ on Cloud machine %1$@. Run `%3$@` from an agent's shell in a cmux chat, or ask the person to run it."}}, + "ja": {"stringUnit": {"state": "translated", "value": "このエージェントが動いているチャットが見つからないため、Cloud マシン %1$@ で %2$@ を開始してよいか誰にも確認できません。cmux チャット内のエージェントのシェルから `%3$@` を実行するか、本人に実行を依頼してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Der Chat dieses Agents wurde nicht gefunden, daher kann niemand gefragt werden, ob %2$@ auf der Cloud-Maschine %1$@ starten soll. Führe `%3$@` aus der Shell eines Agents in einem cmux-Chat aus oder bitte die Person, es auszuführen."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Impossible de trouver la discussion de cet agent : personne ne peut être sollicité pour démarrer %2$@ sur la machine Cloud %1$@. Lancez `%3$@` depuis le shell d'un agent dans une discussion cmux, ou demandez à la personne de le lancer."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "No se encontró el chat de este agente, así que no se puede preguntar a nadie si iniciar %2$@ en la máquina de Cloud %1$@. Ejecuta `%3$@` desde la shell de un agente en un chat de cmux, o pide a la persona que lo ejecute."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "تعذّر العثور على الدردشة التي يعمل فيها هذا الوكيل، لذا لا يمكن سؤال أحد عن بدء %2$@ على جهاز Cloud ‏%1$@. شغّل `%3$@` من صدفة وكيل في دردشة cmux، أو اطلب من الشخص تشغيله."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "이 에이전트가 실행 중인 채팅을 찾지 못해 Cloud 머신 %1$@에서 %2$@을(를) 시작할지 물어볼 수 없습니다. cmux 채팅 속 에이전트의 셸에서 `%3$@`을(를) 실행하거나 사용자에게 실행을 요청하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "找不到此代理所在的聊天,因此无法询问是否在 Cloud 机器 %1$@ 上启动 %2$@。请从 cmux 聊天中代理的 shell 运行 `%3$@`,或请对方运行。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "找不到此代理所在的聊天,因此無法詢問是否在 Cloud 機器 %1$@ 上啟動 %2$@。請從 cmux 聊天中代理的 shell 執行 `%3$@`,或請對方執行。"}} } }, "cloud.startSession.noPage": { - "comment": "The card couldn't show in the chat. Keep the command as written.", + "comment": "The card couldn't show in the chat. %1$@ machine, %2$@ agent, %3$@ the command (keep it in backticks).", "extractionState": "manual", "localizations": { - "en": {"stringUnit": {"state": "translated", "value": "Couldn't show the question in the chat, so nothing started. Run `cmux cloud start-session` again once the chat is open."}}, - "ja": {"stringUnit": {"state": "translated", "value": "チャットに確認を表示できなかったため、何も開始されませんでした。チャットを開いてから `cmux cloud start-session` をもう一度実行してください。"}}, - "de": {"stringUnit": {"state": "needs_review", "value": "Die Frage konnte im Chat nicht angezeigt werden, daher wurde nichts gestartet. Führe `cmux cloud start-session` erneut aus, sobald der Chat geöffnet ist."}}, - "fr": {"stringUnit": {"state": "needs_review", "value": "Impossible d'afficher la question dans la discussion : rien n'a démarré. Relancez `cmux cloud start-session` une fois la discussion ouverte."}}, - "es": {"stringUnit": {"state": "needs_review", "value": "No se pudo mostrar la pregunta en el chat, así que no se inició nada. Vuelve a ejecutar `cmux cloud start-session` cuando el chat esté abierto."}}, - "ar": {"stringUnit": {"state": "needs_review", "value": "تعذّر عرض السؤال في الدردشة، لذا لم يبدأ شيء. شغّل `cmux cloud start-session` مرة أخرى بعد فتح الدردشة."}}, - "ko": {"stringUnit": {"state": "needs_review", "value": "채팅에 질문을 표시하지 못해 아무것도 시작되지 않았습니다. 채팅이 열리면 `cmux cloud start-session`을(를) 다시 실행하세요."}}, - "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "无法在聊天中显示问题,因此没有启动任何内容。聊天打开后请再次运行 `cmux cloud start-session`。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "無法在聊天中顯示問題,因此沒有啟動任何內容。聊天開啟後請再次執行 `cmux cloud start-session`。"}} + "en": {"stringUnit": {"state": "translated", "value": "Couldn't show the question in the chat, so %2$@ didn't start on %1$@. Run `%3$@` again once the chat is open."}}, + "ja": {"stringUnit": {"state": "translated", "value": "チャットに確認を表示できなかったため、%1$@ で %2$@ は開始されませんでした。チャットを開いてから `%3$@` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Die Frage konnte im Chat nicht angezeigt werden, daher wurde %2$@ auf %1$@ nicht gestartet. Führe `%3$@` erneut aus, sobald der Chat geöffnet ist."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Impossible d'afficher la question dans la discussion : %2$@ n'a pas démarré sur %1$@. Relancez `%3$@` une fois la discussion ouverte."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "No se pudo mostrar la pregunta en el chat, así que %2$@ no se inició en %1$@. Vuelve a ejecutar `%3$@` cuando el chat esté abierto."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "تعذّر عرض السؤال في الدردشة، لذا لم يبدأ %2$@ على %1$@. شغّل `%3$@` مرة أخرى بعد فتح الدردشة."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "채팅에 질문을 표시하지 못해 %1$@에서 %2$@이(가) 시작되지 않았습니다. 채팅이 열리면 `%3$@`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "无法在聊天中显示问题,因此 %2$@ 没有在 %1$@ 上启动。聊天打开后请再次运行 `%3$@`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "無法在聊天中顯示問題,因此 %2$@ 沒有在 %1$@ 上啟動。聊天開啟後請再次執行 `%3$@`。"}} } }, "cloud.startSession.askOpen": { - "comment": "A second ask while the chat's card is still open. Keep the command as written.", + "comment": "A second ask while the chat's card is still open. %1$@ machine, %2$@ agent, %3$@ the command (keep it in backticks).", "extractionState": "manual", "localizations": { - "en": {"stringUnit": {"state": "translated", "value": "This chat already shows a question to start a Cloud session. Wait for the person's answer before you run `cmux cloud start-session` again."}}, - "ja": {"stringUnit": {"state": "translated", "value": "このチャットにはすでに Cloud セッション開始の確認が表示されています。本人の回答を待ってから `cmux cloud start-session` をもう一度実行してください。"}}, - "de": {"stringUnit": {"state": "needs_review", "value": "Dieser Chat zeigt schon eine Frage zum Starten einer Cloud-Sitzung. Warte auf die Antwort der Person, bevor du `cmux cloud start-session` erneut ausführst."}}, - "fr": {"stringUnit": {"state": "needs_review", "value": "Cette discussion affiche déjà une question pour démarrer une session Cloud. Attendez la réponse de la personne avant de relancer `cmux cloud start-session`."}}, - "es": {"stringUnit": {"state": "needs_review", "value": "Este chat ya muestra una pregunta para iniciar una sesión de Cloud. Espera la respuesta de la persona antes de volver a ejecutar `cmux cloud start-session`."}}, - "ar": {"stringUnit": {"state": "needs_review", "value": "تعرض هذه الدردشة بالفعل سؤالًا لبدء جلسة Cloud. انتظر إجابة الشخص قبل تشغيل `cmux cloud start-session` مرة أخرى."}}, - "ko": {"stringUnit": {"state": "needs_review", "value": "이 채팅에는 이미 Cloud 세션 시작 질문이 표시되어 있습니다. 사용자의 답을 기다린 다음 `cmux cloud start-session`을(를) 다시 실행하세요."}}, - "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "此聊天已显示启动 Cloud 会话的问题。请等对方回答后再运行 `cmux cloud start-session`。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "此聊天已顯示啟動 Cloud 工作階段的問題。請等對方回答後再執行 `cmux cloud start-session`。"}} + "en": {"stringUnit": {"state": "translated", "value": "This chat already asks the person to start a Cloud session. Wait for their answer, then run `%3$@` again if %2$@ still isn't running on %1$@."}}, + "ja": {"stringUnit": {"state": "translated", "value": "このチャットではすでに Cloud セッションの開始を確認しています。回答を待ち、%1$@ で %2$@ がまだ動いていなければ `%3$@` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Dieser Chat fragt die Person schon, ob eine Cloud-Sitzung starten soll. Warte auf ihre Antwort und führe `%3$@` erneut aus, falls %2$@ auf %1$@ dann noch nicht läuft."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Cette discussion demande déjà à la personne de démarrer une session Cloud. Attendez sa réponse, puis relancez `%3$@` si %2$@ ne tourne toujours pas sur %1$@."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Este chat ya pide a la persona iniciar una sesión de Cloud. Espera su respuesta y vuelve a ejecutar `%3$@` si %2$@ sigue sin ejecutarse en %1$@."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "تطلب هذه الدردشة بالفعل من الشخص بدء جلسة Cloud. انتظر إجابته، ثم شغّل `%3$@` مرة أخرى إذا كان %2$@ لا يزال لا يعمل على %1$@."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "이 채팅은 이미 사용자에게 Cloud 세션 시작을 묻고 있습니다. 답을 기다린 뒤 %1$@에서 %2$@이(가) 아직 실행 중이 아니면 `%3$@`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "此聊天已在询问对方是否启动 Cloud 会话。请等待回答;如果 %2$@ 仍未在 %1$@ 上运行,再次运行 `%3$@`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "此聊天已在詢問對方是否啟動 Cloud 工作階段。請等待回答;如果 %2$@ 仍未在 %1$@ 上執行,再次執行 `%3$@`。"}} } }, "cloud.startSession.declined": { - "comment": "The person chose Not now on the card. Keep the command as written.", + "comment": "The person chose Not now on the card. %1$@ machine, %2$@ agent, %3$@ the command (keep it in backticks).", "extractionState": "manual", "localizations": { - "en": {"stringUnit": {"state": "translated", "value": "The person chose Not now, so nothing started. Ask them in the chat before you run `cmux cloud start-session` again."}}, - "ja": {"stringUnit": {"state": "translated", "value": "「後で」が選ばれたため、何も開始されませんでした。`cmux cloud start-session` をもう一度実行する前に、チャットで確認してください。"}}, - "de": {"stringUnit": {"state": "needs_review", "value": "Die Person hat „Nicht jetzt“ gewählt, daher wurde nichts gestartet. Frag sie im Chat, bevor du `cmux cloud start-session` erneut ausführst."}}, - "fr": {"stringUnit": {"state": "needs_review", "value": "La personne a choisi Pas maintenant : rien n'a démarré. Demandez-lui dans la discussion avant de relancer `cmux cloud start-session`."}}, - "es": {"stringUnit": {"state": "needs_review", "value": "La persona eligió Ahora no, así que no se inició nada. Pregúntale en el chat antes de volver a ejecutar `cmux cloud start-session`."}}, - "ar": {"stringUnit": {"state": "needs_review", "value": "اختار الشخص «ليس الآن»، لذا لم يبدأ شيء. اسأله في الدردشة قبل تشغيل `cmux cloud start-session` مرة أخرى."}}, - "ko": {"stringUnit": {"state": "needs_review", "value": "사용자가 나중에를 선택해 아무것도 시작되지 않았습니다. `cmux cloud start-session`을(를) 다시 실행하기 전에 채팅에서 물어보세요."}}, - "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "对方选择了“以后再说”,因此没有启动任何内容。再次运行 `cmux cloud start-session` 前请先在聊天中询问。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "對方選擇了「以後再說」,因此沒有啟動任何內容。再次執行 `cmux cloud start-session` 前請先在聊天中詢問。"}} + "en": {"stringUnit": {"state": "translated", "value": "The person chose Not now, so %2$@ didn't start on %1$@. Ask them in the chat before you run `%3$@` again."}}, + "ja": {"stringUnit": {"state": "translated", "value": "「後で」が選ばれたため、%1$@ で %2$@ は開始されませんでした。`%3$@` をもう一度実行する前に、チャットで確認してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Die Person hat „Nicht jetzt“ gewählt, daher wurde %2$@ auf %1$@ nicht gestartet. Frag sie im Chat, bevor du `%3$@` erneut ausführst."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "La personne a choisi Pas maintenant : %2$@ n'a pas démarré sur %1$@. Demandez-lui dans la discussion avant de relancer `%3$@`."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "La persona eligió Ahora no, así que %2$@ no se inició en %1$@. Pregúntale en el chat antes de volver a ejecutar `%3$@`."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "اختار الشخص «ليس الآن»، لذا لم يبدأ %2$@ على %1$@. اسأله في الدردشة قبل تشغيل `%3$@` مرة أخرى."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "사용자가 나중에를 선택해 %1$@에서 %2$@이(가) 시작되지 않았습니다. `%3$@`을(를) 다시 실행하기 전에 채팅에서 물어보세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "对方选择了“以后再说”,因此 %2$@ 没有在 %1$@ 上启动。再次运行 `%3$@` 前请先在聊天中询问。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "對方選擇了「以後再說」,因此 %2$@ 沒有在 %1$@ 上啟動。再次執行 `%3$@` 前請先在聊天中詢問。"}} } }, "cloud.startSession.expired": { - "comment": "Nobody answered the card in time. Keep the command as written.", + "comment": "Nobody answered the card in time. %1$@ machine, %2$@ agent, %3$@ the command (keep it in backticks).", "extractionState": "manual", "localizations": { - "en": {"stringUnit": {"state": "translated", "value": "Nobody answered the question in the chat within 2 minutes, so nothing started. Run `cmux cloud start-session` again when the person is there."}}, - "ja": {"stringUnit": {"state": "translated", "value": "2 分以内にチャットの確認に回答がなかったため、何も開始されませんでした。本人がいるときに `cmux cloud start-session` をもう一度実行してください。"}}, - "de": {"stringUnit": {"state": "needs_review", "value": "Niemand hat die Frage im Chat innerhalb von 2 Minuten beantwortet, daher wurde nichts gestartet. Führe `cmux cloud start-session` erneut aus, wenn die Person da ist."}}, - "fr": {"stringUnit": {"state": "needs_review", "value": "Personne n'a répondu à la question dans la discussion en 2 minutes : rien n'a démarré. Relancez `cmux cloud start-session` quand la personne est là."}}, - "es": {"stringUnit": {"state": "needs_review", "value": "Nadie respondió a la pregunta del chat en 2 minutos, así que no se inició nada. Vuelve a ejecutar `cmux cloud start-session` cuando la persona esté."}}, - "ar": {"stringUnit": {"state": "needs_review", "value": "لم يُجب أحد عن السؤال في الدردشة خلال دقيقتين، لذا لم يبدأ شيء. شغّل `cmux cloud start-session` مرة أخرى عندما يكون الشخص موجودًا."}}, - "ko": {"stringUnit": {"state": "needs_review", "value": "2분 안에 채팅의 질문에 답한 사람이 없어 아무것도 시작되지 않았습니다. 사용자가 있을 때 `cmux cloud start-session`을(를) 다시 실행하세요."}}, - "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "2 分钟内没有人回答聊天中的问题,因此没有启动任何内容。对方在时请再次运行 `cmux cloud start-session`。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "2 分鐘內沒有人回答聊天中的問題,因此沒有啟動任何內容。對方在時請再次執行 `cmux cloud start-session`。"}} + "en": {"stringUnit": {"state": "translated", "value": "Nobody answered the question in the chat within 2 minutes, so %2$@ didn't start on %1$@. Run `%3$@` again when the person is there."}}, + "ja": {"stringUnit": {"state": "translated", "value": "2 分以内にチャットの確認に回答がなかったため、%1$@ で %2$@ は開始されませんでした。本人がいるときに `%3$@` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Niemand hat die Frage im Chat innerhalb von 2 Minuten beantwortet, daher wurde %2$@ auf %1$@ nicht gestartet. Führe `%3$@` erneut aus, wenn die Person da ist."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Personne n'a répondu à la question dans la discussion en 2 minutes : %2$@ n'a pas démarré sur %1$@. Relancez `%3$@` quand la personne est là."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Nadie respondió a la pregunta del chat en 2 minutos, así que %2$@ no se inició en %1$@. Vuelve a ejecutar `%3$@` cuando la persona esté."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "لم يُجب أحد عن السؤال في الدردشة خلال دقيقتين، لذا لم يبدأ %2$@ على %1$@. شغّل `%3$@` مرة أخرى عندما يكون الشخص موجودًا."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "2분 안에 채팅의 질문에 답한 사람이 없어 %1$@에서 %2$@이(가) 시작되지 않았습니다. 사용자가 있을 때 `%3$@`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "2 分钟内没有人回答聊天中的问题,因此 %2$@ 没有在 %1$@ 上启动。对方在时请再次运行 `%3$@`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "2 分鐘內沒有人回答聊天中的問題,因此 %2$@ 沒有在 %1$@ 上啟動。對方在時請再次執行 `%3$@`。"}} + } + }, + "cloud.startSession.listUnread": { + "comment": "The machine list couldn't be read, so a machine by that name may exist. %1$@ machine name, %2$@ agent, %3$@ the command (keep it in backticks).", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Couldn't read your Cloud machines, so %2$@ didn't start: a machine named %1$@ may already exist. Check `cmux cloud list-machines`, then run `%3$@` again."}}, + "ja": {"stringUnit": {"state": "translated", "value": "Cloud マシンの一覧を読み込めなかったため、%2$@ は開始されませんでした。%1$@ という名前のマシンがすでにある可能性があります。`cmux cloud list-machines` で確認してから、`%3$@` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Deine Cloud-Maschinen konnten nicht gelesen werden, daher wurde %2$@ nicht gestartet: Eine Maschine namens %1$@ gibt es vielleicht schon. Prüfe `cmux cloud list-machines` und führe dann `%3$@` erneut aus."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Impossible de lire vos machines Cloud : %2$@ n'a pas démarré, car une machine nommée %1$@ existe peut-être déjà. Vérifiez `cmux cloud list-machines`, puis relancez `%3$@`."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "No se pudieron leer tus máquinas de Cloud, así que %2$@ no se inició: puede que ya exista una máquina llamada %1$@. Revisa `cmux cloud list-machines` y vuelve a ejecutar `%3$@`."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "تعذّرت قراءة أجهزة Cloud الخاصة بك، لذا لم يبدأ %2$@: قد يكون هناك جهاز باسم %1$@ بالفعل. افحص `cmux cloud list-machines`، ثم شغّل `%3$@` مرة أخرى."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "Cloud 머신 목록을 읽지 못해 %2$@이(가) 시작되지 않았습니다. %1$@이라는 머신이 이미 있을 수 있습니다. `cmux cloud list-machines`로 확인한 다음 `%3$@`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "无法读取你的 Cloud 机器,因此 %2$@ 没有启动:可能已有名为 %1$@ 的机器。请检查 `cmux cloud list-machines`,然后再次运行 `%3$@`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "無法讀取你的 Cloud 機器,因此 %2$@ 沒有啟動:可能已有名為 %1$@ 的機器。請檢查 `cmux cloud list-machines`,然後再次執行 `%3$@`。"}} } } }, diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index fe504bed61a1..a62770ec11ac 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -18,6 +18,8 @@ struct CloudStartSessionTests { return (services, MachineIconTests.cloudMachine(services, id: "vm-start-\(UUID().uuidString.prefix(8))")) } + static let asked = CloudStartSession.Request(machineID: "vm-x", name: nil, agent: "claude", cwd: nil).asked(machine: "dev") + static func invocation(target: String? = nil, _ arguments: [String: ActionValue], origin: ActionOrigin = .cli, agent: String? = nil) -> ActionInvocation { var invocation = ActionInvocation(target: target.map { ActionTargetRef(kind: .machine, id: $0) }, arguments: arguments, origin: origin) invocation.callerAgentSession = agent @@ -75,9 +77,28 @@ struct CloudStartSessionTests { @Test func anAgentOutsideAChatIsRefusedBeforeAnyCard() throws { let f = Self.fixture() let refusal = try Self.refusal(f.services, Self.invocation(target: f.session.machineID, ["agent": .string("claude")], agent: "session-not-here")) - #expect(refusal == CloudStartSessionStrings.notInChat) - let mcp = try Self.refusal(f.services, Self.invocation(["agent": .string("claude"), "name": .string("dev")], origin: .mcp)) - #expect(mcp == CloudStartSessionStrings.notInChat) + let byID = CloudStartSession.Request(machineID: f.session.machineID, name: nil, agent: "claude", cwd: nil) + #expect(refusal == CloudStartSessionStrings.notInChat(byID.asked(machine: f.session.machine.title))) + #expect(refusal?.contains("`cmux cloud start-session --target machine:\(f.session.machineID) --agent claude`") == true) + let mcp = try Self.refusal(f.services, Self.invocation(["agent": .string("claude"), "name": .string("my dev")], origin: .mcp)) + let byName = CloudStartSession.Request(machineID: nil, name: "my dev", agent: "claude", cwd: nil) + #expect(mcp == CloudStartSessionStrings.notInChat(byName.asked(machine: "my dev"))) + #expect(mcp?.contains("`cmux cloud start-session --name 'my dev' --agent claude`") == true) + } + + /// A nested run inside an MCP run is the agent's too: it asks, never starts. + @Test func aNestedRunOfAnMCPRunIsAnAgentsRun() { + let inner = ActionInvocation(target: ActionTargetRef(kind: .machine, id: "vm-a"), arguments: ["agent": .string("claude")]) + #expect(!CloudStartSession.isAgent(inner)) + ActionRunScope.$current.withValue(ActionRunScope(origin: .mcp, allowsViewChange: false)) { + #expect(CloudStartSession.isAgent(inner)) + } + } + + @Test func theCommandToRunAgainKeepsEveryArgument() { + #expect(CloudStartSession.Request(machineID: "vm-a", name: nil, agent: "codex", cwd: "/home/cmux/my work").command + == "cmux cloud start-session --target machine:vm-a --agent codex --cwd '/home/cmux/my work'") + #expect(CloudStartSession.Request(machineID: nil, name: "dev", agent: "claude", cwd: nil).command == "cmux cloud start-session --name dev --agent claude") } /// An agent in a chat on a Cloud machine (one this command started) asks in that chat too. @@ -94,10 +115,11 @@ struct CloudStartSessionTests { @Test func aRunStillStartingAnswersPendingWithWhatToRunNext() async throws { let request = CloudStartSession.Request(machineID: "vm-a", name: nil, agent: "claude", cwd: nil) - let pending = CloudStartSession.pending(request) + let pending = CloudStartSession.pending(request, Self.fixture().services) #expect(pending["pending"] == .bool(true)) #expect(pending["machine_id"] == .string("vm-a")) - #expect(pending["text"] == .string(CloudStartSessionStrings.stillStarting)) + #expect(pending["text"] == .string(CloudStartSessionStrings.stillStarting(request.asked(machine: "vm-a")))) + #expect(pending["text"]?.stringValue?.contains("`cmux cloud start-session --target machine:vm-a --agent claude`") == true) // Through the race: the caller's wait ends before the run. let runs = CloudStartSessionRuns() runs.sleep = { _ in } @@ -114,8 +136,8 @@ struct CloudStartSessionTests { let services = Self.fixture().services let asks = CloudStartSessionAsks() for _ in 0..<2 { - await #expect(throws: ActionFailure(message: CloudStartSessionStrings.noPage)) { - try await asks.ask(tab: "tab-without-page", text: "ask", services: services) + await #expect(throws: ActionFailure(message: CloudStartSessionStrings.noPage(Self.asked))) { + try await asks.ask(tab: "tab-without-page", text: "ask", asked: Self.asked, services: services) } } } @@ -141,11 +163,12 @@ struct CloudStartSessionTests { let services = Self.fixture().services let messages = [ CloudStartSessionStrings.needsMachine(agent: "claude"), CloudStartSessionStrings.noSuchMachine(id: "vm-x", agent: "claude"), - CloudStartSessionStrings.agentInvalid, CloudStartSessionStrings.cwdInvalid, CloudStartSessionStrings.stillStarting, + CloudStartSessionStrings.agentInvalid, CloudStartSessionStrings.cwdInvalid, CloudStartSessionStrings.stillStarting(Self.asked), + CloudStartSessionStrings.listUnread(Self.asked), CloudStartSessionStrings.createFailed("dev", quoted: "dev", agent: "claude", reason: "x"), CloudStartSessionStrings.notConnected("dev", id: "vm-x", agent: "claude"), CloudStartSessionStrings.agentFailed("dev", id: "vm-x", agent: "claude", reason: "x"), CloudStartSessionStrings.needsFolder("dev", id: "vm-x", agent: "claude"), - CloudStartSessionStrings.notInChat, CloudStartSessionStrings.noPage, CloudStartSessionStrings.askOpen, - CloudStartSessionStrings.declined, CloudStartSessionStrings.expired, + CloudStartSessionStrings.notInChat(Self.asked), CloudStartSessionStrings.noPage(Self.asked), CloudStartSessionStrings.askOpen(Self.asked), + CloudStartSessionStrings.declined(Self.asked), CloudStartSessionStrings.expired(Self.asked), ] let offered = Set(services.registry.descriptors.filter(\.cli).map(\.cliName)) for message in messages { @@ -211,15 +234,15 @@ struct CloudStartSessionTests { let services = Self.fixture().services let asks = CloudStartSessionAsks(), cards = Cards() cards.install(asks) - let first = Task { try await asks.ask(tab: "chat-1", text: "first", services: services) } + let first = Task { try await asks.ask(tab: "chat-1", text: "first", asked: Self.asked, services: services) } await cards.shownIn("chat-1") #expect(cards.shown["chat-1"]?.first?.state == .ask) cards.click("chat-1", "start") let token = try await first.value // Started: the run goes on, and the chat's card is still the first ask's. asks.show(tab: "chat-1", token: token, .working, "starting", services: services) - await #expect(throws: ActionFailure(message: CloudStartSessionStrings.askOpen)) { - _ = try await asks.ask(tab: "chat-1", text: "second", services: services) + await #expect(throws: ActionFailure(message: CloudStartSessionStrings.askOpen(Self.asked))) { + _ = try await asks.ask(tab: "chat-1", text: "second", asked: Self.asked, services: services) } #expect(cards.shown["chat-1"]?.first?.text == "starting") // Another ask's token changes nothing. @@ -236,10 +259,10 @@ struct CloudStartSessionTests { let services = Self.fixture().services let asks = CloudStartSessionAsks(), cards = Cards() cards.install(asks) - let ask = Task { try await asks.ask(tab: "chat-2", text: "ask", services: services) } + let ask = Task { try await asks.ask(tab: "chat-2", text: "ask", asked: Self.asked, services: services) } await cards.shownIn("chat-2") cards.click("chat-2", "notNow") - await #expect(throws: ActionFailure(message: CloudStartSessionStrings.declined)) { _ = try await ask.value } + await #expect(throws: ActionFailure(message: CloudStartSessionStrings.declined(Self.asked))) { _ = try await ask.value } #expect(cards.shown["chat-2"] == nil) #expect(asks.open["chat-2"] == nil) } From bbff450f3d05785334c22f95335256bdfac88874 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 25 Oct 2026 23:07:59 -0700 Subject: [PATCH 08/33] cloud start-session: all 21 languages; the pending test reads the text without stringValue Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CloudSessionActions.xcstrings | 56 +++- .../Resources/CloudSessions.xcstrings | 266 ++++++++++++++++-- .../CloudStartSessionTests.swift | 3 +- 3 files changed, 301 insertions(+), 24 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/CloudSessionActions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/CloudSessionActions.xcstrings index 77ece6811faa..df275e74e978 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/CloudSessionActions.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/CloudSessionActions.xcstrings @@ -13,7 +13,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "بدء جلسة وكيل على جهاز Cloud…"}}, "ko": {"stringUnit": {"state": "needs_review", "value": "Cloud 머신에서 에이전트 세션 시작…"}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "在 Cloud 机器上启动代理会话…"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "在 Cloud 機器上啟動代理工作階段…"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "在 Cloud 機器上啟動代理工作階段…"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Pokreni sesiju agenta na Cloud mašini…"}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Start agentsession på Cloud-maskine…"}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Avvia sessione agente sulla macchina Cloud…"}}, + "km": {"stringUnit": {"state": "needs_review", "value": "ចាប់ផ្ដើមវគ្គភ្នាក់ងារនៅលើម៉ាស៊ីន Cloud…"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Start agentøkt på Cloud-maskin…"}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Uruchom sesję agenta na maszynie Cloud…"}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Iniciar Sessão de Agente na Máquina do Cloud…"}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Запустить сеанс агента на машине Cloud…"}}, + "th": {"stringUnit": {"state": "needs_review", "value": "เริ่มเซสชันเอเจนต์บนเครื่อง Cloud…"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Cloud Makinesinde Ajan Oturumu Başlat…"}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Запустити сеанс агента на машині Cloud…"}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Bắt đầu phiên tác tử trên máy Cloud…"}} } }, "argument.cloudStartSession.agent": { @@ -28,7 +40,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "الوكيل"}}, "ko": {"stringUnit": {"state": "needs_review", "value": "에이전트"}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "代理"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "代理"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "代理"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Agent"}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Agent"}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Agente"}}, + "km": {"stringUnit": {"state": "needs_review", "value": "ភ្នាក់ងារ"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Agent"}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Agent"}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Agente"}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Агент"}}, + "th": {"stringUnit": {"state": "needs_review", "value": "เอเจนต์"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Ajan"}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Агент"}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Tác tử"}} } }, "argument.cloudStartSession.name": { @@ -43,7 +67,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "اسم الجهاز"}}, "ko": {"stringUnit": {"state": "needs_review", "value": "머신 이름"}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "机器名称"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "機器名稱"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "機器名稱"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Naziv mašine"}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Maskinnavn"}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Nome della macchina"}}, + "km": {"stringUnit": {"state": "needs_review", "value": "ឈ្មោះម៉ាស៊ីន"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Maskinnavn"}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Nazwa maszyny"}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Nome da Máquina"}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Имя машины"}}, + "th": {"stringUnit": {"state": "needs_review", "value": "ชื่อเครื่อง"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Makine Adı"}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Назва машини"}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Tên máy"}} } }, "argument.cloudStartSession.cwd": { @@ -58,7 +94,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "المجلد على الجهاز"}}, "ko": {"stringUnit": {"state": "needs_review", "value": "머신의 폴더"}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "机器上的文件夹"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "機器上的資料夾"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "機器上的資料夾"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Folder na mašini"}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Mappe på maskinen"}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Cartella sulla macchina"}}, + "km": {"stringUnit": {"state": "needs_review", "value": "ថតនៅលើម៉ាស៊ីន"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Mappe på maskinen"}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Folder na maszynie"}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Pasta na Máquina"}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Папка на машине"}}, + "th": {"stringUnit": {"state": "needs_review", "value": "โฟลเดอร์บนเครื่อง"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Makinedeki Klasör"}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Папка на машині"}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Thư mục trên máy"}} } } }, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings index 6dfd42e37bd5..0bfa76388c97 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings @@ -13,7 +13,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "حدّد جهاز Cloud الذي يبدأ عليه %1$@: `cmux cloud start-session --target machine: --agent %1$@`، أو `--name ` لاستخدام ذلك الجهاز أو إنشائه. يعرض `cmux cloud list-machines` أجهزتك."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "%1$@을(를) 시작할 Cloud 머신을 지정하세요: `cmux cloud start-session --target machine: --agent %1$@`, 또는 `--name `으로 그 머신을 사용하거나 만듭니다. `cmux cloud list-machines`로 머신 목록을 볼 수 있습니다."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "请指定在哪台 Cloud 机器上启动 %1$@:`cmux cloud start-session --target machine: --agent %1$@`,或用 `--name ` 使用或创建该机器。`cmux cloud list-machines` 会列出你的机器。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "請指定在哪台 Cloud 機器上啟動 %1$@:`cmux cloud start-session --target machine: --agent %1$@`,或用 `--name ` 使用或建立該機器。`cmux cloud list-machines` 會列出你的機器。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "請指定在哪台 Cloud 機器上啟動 %1$@:`cmux cloud start-session --target machine: --agent %1$@`,或用 `--name ` 使用或建立該機器。`cmux cloud list-machines` 會列出你的機器。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Navedite na kojoj Cloud mašini treba pokrenuti %1$@: `cmux cloud start-session --target machine: --agent %1$@` ili `--name ` da uzmete tu mašinu ili je kreirate. `cmux cloud list-machines` prikazuje vaše mašine."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Angiv, hvilken Cloud-maskine %1$@ skal startes på: `cmux cloud start-session --target machine: --agent %1$@`, eller `--name ` for at bruge den maskine eller oprette den. `cmux cloud list-machines` viser dine maskiner."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Indica su quale macchina Cloud avviare %1$@: `cmux cloud start-session --target machine: --agent %1$@`, oppure `--name ` per usare quella macchina o crearla. `cmux cloud list-machines` elenca le tue macchine."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "បញ្ជាក់ម៉ាស៊ីន Cloud ដែលត្រូវចាប់ផ្ដើម %1$@៖ `cmux cloud start-session --target machine: --agent %1$@` ឬ `--name ` ដើម្បីយកម៉ាស៊ីននោះ ឬបង្កើតវា។ `cmux cloud list-machines` បង្ហាញម៉ាស៊ីនរបស់អ្នក។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Angi hvilken Cloud-maskin %1$@ skal startes på: `cmux cloud start-session --target machine: --agent %1$@`, eller `--name ` for å bruke den maskinen eller opprette den. `cmux cloud list-machines` viser maskinene dine."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Wskaż, na której maszynie Cloud uruchomić %1$@: `cmux cloud start-session --target machine: --agent %1$@` lub `--name `, aby użyć tej maszyny albo ją utworzyć. `cmux cloud list-machines` wyświetla Twoje maszyny."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Diga em qual máquina do Cloud iniciar %1$@: `cmux cloud start-session --target machine: --agent %1$@`, ou `--name ` para usar essa máquina ou criá-la. `cmux cloud list-machines` lista suas máquinas."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Укажите, на какой машине Cloud запустить %1$@: `cmux cloud start-session --target machine: --agent %1$@` или `--name `, чтобы взять эту машину или создать её. `cmux cloud list-machines` показывает ваши машины."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "ระบุเครื่อง Cloud ที่จะเริ่ม %1$@: `cmux cloud start-session --target machine: --agent %1$@` หรือ `--name ` เพื่อใช้เครื่องนั้นหรือสร้างขึ้นใหม่ `cmux cloud list-machines` แสดงรายการเครื่องของคุณ"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "%1$@ ajanının hangi Cloud makinesinde başlatılacağını belirtin: `cmux cloud start-session --target machine: --agent %1$@` veya o makineyi kullanmak ya da oluşturmak için `--name `. `cmux cloud list-machines` makinelerinizi listeler."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Вкажіть, на якій машині Cloud запустити %1$@: `cmux cloud start-session --target machine: --agent %1$@` або `--name `, щоб узяти цю машину чи створити її. `cmux cloud list-machines` показує ваші машини."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Hãy cho biết máy Cloud nào để khởi động %1$@: `cmux cloud start-session --target machine: --agent %1$@`, hoặc `--name ` để dùng máy đó hoặc tạo máy mới. `cmux cloud list-machines` liệt kê các máy của bạn."}} } }, "cloud.startSession.noSuchMachine": { @@ -28,7 +40,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "لا يوجد جهاز Cloud باسم `%1$@` في قائمة هذا الـ Mac. يعرضها `cmux cloud list-machines`، وينشئ `cmux cloud start-session --name --agent %2$@` جهازًا جديدًا."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "이 Mac에 Cloud 머신 `%1$@`이(가) 없습니다. `cmux cloud list-machines`로 목록을 보고 `cmux cloud start-session --name --agent %2$@`로 만들 수 있습니다."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "这台 Mac 上没有 Cloud 机器 `%1$@`。`cmux cloud list-machines` 会列出机器,`cmux cloud start-session --name --agent %2$@` 会创建一台。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "這台 Mac 上沒有 Cloud 機器 `%1$@`。`cmux cloud list-machines` 會列出機器,`cmux cloud start-session --name --agent %2$@` 會建立一台。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "這台 Mac 上沒有 Cloud 機器 `%1$@`。`cmux cloud list-machines` 會列出機器,`cmux cloud start-session --name --agent %2$@` 會建立一台。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Nijedna Cloud mašina `%1$@` nije navedena na ovom Mac računaru. `cmux cloud list-machines` ih prikazuje, a `cmux cloud start-session --name --agent %2$@` kreira novu."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Ingen Cloud-maskine `%1$@` er på listen på denne Mac. `cmux cloud list-machines` viser dem, og `cmux cloud start-session --name --agent %2$@` opretter en."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Nessuna macchina Cloud `%1$@` è elencata su questo Mac. `cmux cloud list-machines` le elenca e `cmux cloud start-session --name --agent %2$@` ne crea una."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "គ្មានម៉ាស៊ីន Cloud `%1$@` ក្នុងបញ្ជីនៅលើ Mac នេះទេ។ `cmux cloud list-machines` បង្ហាញបញ្ជីម៉ាស៊ីន ហើយ `cmux cloud start-session --name --agent %2$@` បង្កើតម៉ាស៊ីនថ្មី។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Ingen Cloud-maskin `%1$@` er oppført på denne Mac-en. `cmux cloud list-machines` viser dem, og `cmux cloud start-session --name --agent %2$@` oppretter en."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Na tym komputerze Mac nie ma na liście maszyny Cloud `%1$@`. `cmux cloud list-machines` wyświetla listę maszyn, a `cmux cloud start-session --name --agent %2$@` tworzy nową."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Nenhuma máquina do Cloud `%1$@` está listada neste Mac. `cmux cloud list-machines` lista as máquinas, e `cmux cloud start-session --name --agent %2$@` cria uma."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "На этом Mac в списке нет машины Cloud `%1$@`. `cmux cloud list-machines` показывает их, а `cmux cloud start-session --name --agent %2$@` создаёт новую."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "ไม่มีเครื่อง Cloud `%1$@` ในรายการบน Mac เครื่องนี้ `cmux cloud list-machines` แสดงรายการเครื่อง และ `cmux cloud start-session --name --agent %2$@` จะสร้างเครื่องใหม่"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Bu Mac'te listelenen `%1$@` adında bir Cloud makinesi yok. `cmux cloud list-machines` makineleri listeler, `cmux cloud start-session --name --agent %2$@` ise yeni bir makine oluşturur."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "На цьому Mac у списку немає машини Cloud `%1$@`. `cmux cloud list-machines` показує їх, а `cmux cloud start-session --name --agent %2$@` створює нову."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Không có máy Cloud `%1$@` nào trong danh sách trên máy Mac này. `cmux cloud list-machines` liệt kê các máy, còn `cmux cloud start-session --name --agent %2$@` sẽ tạo một máy."}} } }, "cloud.startSession.agentInvalid": { @@ -43,7 +67,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "مرّر الوكيل المراد بدؤه باستخدام ‎--agent، مثل `cmux cloud start-session --name dev --agent claude`."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "--agent로 시작할 에이전트를 지정하세요(예: `cmux cloud start-session --name dev --agent claude`)."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "请用 --agent 指定要启动的代理,例如 `cmux cloud start-session --name dev --agent claude`。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "請用 --agent 指定要啟動的代理,例如 `cmux cloud start-session --name dev --agent claude`。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "請用 --agent 指定要啟動的代理,例如 `cmux cloud start-session --name dev --agent claude`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Navedite agenta za pokretanje pomoću --agent, na primjer `cmux cloud start-session --name dev --agent claude`."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Angiv den agent, der skal startes, med --agent, for eksempel `cmux cloud start-session --name dev --agent claude`."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Indica l'agente da avviare con --agent, per esempio `cmux cloud start-session --name dev --agent claude`."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "បញ្ជាក់ភ្នាក់ងារដែលត្រូវចាប់ផ្ដើមដោយប្រើ --agent ឧទាហរណ៍ `cmux cloud start-session --name dev --agent claude`។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Angi agenten som skal startes med --agent, for eksempel `cmux cloud start-session --name dev --agent claude`."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Podaj agenta do uruchomienia za pomocą --agent, na przykład `cmux cloud start-session --name dev --agent claude`."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Informe o agente a iniciar com --agent, por exemplo `cmux cloud start-session --name dev --agent claude`."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Укажите агента для запуска через --agent, например `cmux cloud start-session --name dev --agent claude`."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "ระบุเอเจนต์ที่จะเริ่มด้วย --agent เช่น `cmux cloud start-session --name dev --agent claude`"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Başlatılacak ajanı --agent ile belirtin, örneğin `cmux cloud start-session --name dev --agent claude`."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Вкажіть агента для запуску через --agent, наприклад `cmux cloud start-session --name dev --agent claude`."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Truyền tác tử cần khởi động bằng --agent, ví dụ `cmux cloud start-session --name dev --agent claude`."}} } }, "cloud.startSession.cwdInvalid": { @@ -58,7 +94,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "مرّر ‎--cwd كمجلد بمسار مطلق على الجهاز، مثل `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "--cwd에는 머신의 절대 경로 폴더를 지정하세요(예: `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`)."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "请将 --cwd 设为机器上的绝对路径文件夹,例如 `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "請將 --cwd 設為機器上的絕對路徑資料夾,例如 `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "請將 --cwd 設為機器上的絕對路徑資料夾,例如 `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Navedite --cwd kao apsolutnu putanju foldera na mašini, na primjer `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Angiv --cwd som en absolut mappe på maskinen, for eksempel `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Indica --cwd come cartella assoluta sulla macchina, per esempio `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "បញ្ជាក់ --cwd ជាផ្លូវថតពេញលេញនៅលើម៉ាស៊ីន ឧទាហរណ៍ `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Angi --cwd som en absolutt mappe på maskinen, for eksempel `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Podaj --cwd jako bezwzględną ścieżkę folderu na maszynie, na przykład `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Informe --cwd como uma pasta absoluta na máquina, por exemplo `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Укажите в --cwd абсолютный путь к папке на машине, например `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "ระบุ --cwd เป็นพาธแบบเต็มของโฟลเดอร์บนเครื่อง เช่น `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "--cwd değerini makinedeki mutlak bir klasör yolu olarak verin, örneğin `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Вкажіть у --cwd абсолютний шлях до папки на машині, наприклад `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Truyền --cwd là đường dẫn tuyệt đối tới thư mục trên máy, ví dụ `cmux cloud start-session --target machine: --agent claude --cwd /home/cmux/work`."}} } }, "cloud.startSession.createFailed": { @@ -73,7 +121,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "تعذّر إنشاء جهاز Cloud ‏`%1$@` (%4$@)، لذا لم يبدأ أي وكيل. شغّل `cmux cloud start-session --name %2$@ --agent %3$@` مرة أخرى، أو اختر جهازًا من `cmux cloud list-machines`."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "Cloud 머신 `%1$@`을(를) 만들지 못해(%4$@) 에이전트가 시작되지 않았습니다. `cmux cloud start-session --name %2$@ --agent %3$@`을(를) 다시 실행하거나 `cmux cloud list-machines`에서 머신을 고르세요."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "无法创建 Cloud 机器 `%1$@`(%4$@),因此没有启动代理。请再次运行 `cmux cloud start-session --name %2$@ --agent %3$@`,或从 `cmux cloud list-machines` 中选一台机器。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "無法建立 Cloud 機器 `%1$@`(%4$@),因此沒有啟動代理。請再次執行 `cmux cloud start-session --name %2$@ --agent %3$@`,或從 `cmux cloud list-machines` 中選一台機器。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "無法建立 Cloud 機器 `%1$@`(%4$@),因此沒有啟動代理。請再次執行 `cmux cloud start-session --name %2$@ --agent %3$@`,或從 `cmux cloud list-machines` 中選一台機器。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Nije moguće kreirati Cloud mašinu `%1$@` (%4$@), pa nijedan agent nije pokrenut. Ponovo pokrenite `cmux cloud start-session --name %2$@ --agent %3$@` ili odaberite mašinu iz `cmux cloud list-machines`."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Kunne ikke oprette Cloud-maskinen `%1$@` (%4$@), så ingen agent startede. Kør `cmux cloud start-session --name %2$@ --agent %3$@` igen, eller vælg en maskine fra `cmux cloud list-machines`."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Impossibile creare la macchina Cloud `%1$@` (%4$@), quindi nessun agente è stato avviato. Esegui di nuovo `cmux cloud start-session --name %2$@ --agent %3$@` oppure scegli una macchina da `cmux cloud list-machines`."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "មិនអាចបង្កើតម៉ាស៊ីន Cloud `%1$@` បានទេ (%4$@) ដូច្នេះគ្មានភ្នាក់ងារណាបានចាប់ផ្ដើមទេ។ ដំណើរការ `cmux cloud start-session --name %2$@ --agent %3$@` ម្ដងទៀត ឬជ្រើសម៉ាស៊ីនពី `cmux cloud list-machines`។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Kunne ikke opprette Cloud-maskinen `%1$@` (%4$@), så ingen agent startet. Kjør `cmux cloud start-session --name %2$@ --agent %3$@` på nytt, eller velg en maskin fra `cmux cloud list-machines`."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Nie można utworzyć maszyny Cloud `%1$@` (%4$@), więc żaden agent nie został uruchomiony. Uruchom ponownie `cmux cloud start-session --name %2$@ --agent %3$@` lub wybierz maszynę z `cmux cloud list-machines`."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Não foi possível criar a máquina do Cloud `%1$@` (%4$@), então nenhum agente foi iniciado. Execute `cmux cloud start-session --name %2$@ --agent %3$@` novamente ou escolha uma máquina em `cmux cloud list-machines`."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Не удалось создать машину Cloud `%1$@` (%4$@), поэтому агент не запущен. Снова выполните `cmux cloud start-session --name %2$@ --agent %3$@` или выберите машину из `cmux cloud list-machines`."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "สร้างเครื่อง Cloud `%1$@` ไม่ได้ (%4$@) จึงไม่มีเอเจนต์เริ่มทำงาน เรียกใช้ `cmux cloud start-session --name %2$@ --agent %3$@` อีกครั้ง หรือเลือกเครื่องจาก `cmux cloud list-machines`"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "`%1$@` Cloud makinesi oluşturulamadı (%4$@), bu yüzden hiçbir ajan başlatılmadı. `cmux cloud start-session --name %2$@ --agent %3$@` komutunu yeniden çalıştırın veya `cmux cloud list-machines` listesinden bir makine seçin."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Не вдалося створити машину Cloud `%1$@` (%4$@), тому агент не запущено. Знову виконайте `cmux cloud start-session --name %2$@ --agent %3$@` або виберіть машину з `cmux cloud list-machines`."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Không thể tạo máy Cloud `%1$@` (%4$@) nên không có tác tử nào được khởi động. Chạy lại `cmux cloud start-session --name %2$@ --agent %3$@`, hoặc chọn một máy từ `cmux cloud list-machines`."}} } }, "cloud.startSession.stillStarting": { @@ -88,7 +148,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "لا يزال %2$@ قيد البدء على %1$@. شغّل `%3$@` مرة أخرى للحصول على machine_id وsession_id."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "%1$@에서 %2$@이(가) 아직 시작 중입니다. `%3$@`을(를) 다시 실행하면 machine_id와 session_id를 받을 수 있습니다."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "%2$@ 仍在 %1$@ 上启动。请再次运行 `%3$@` 以获取其 machine_id 和 session_id。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "%2$@ 仍在 %1$@ 上啟動。請再次執行 `%3$@` 以取得其 machine_id 和 session_id。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "%2$@ 仍在 %1$@ 上啟動。請再次執行 `%3$@` 以取得其 machine_id 和 session_id。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "%2$@ se još pokreće na %1$@. Ponovo pokrenite `%3$@` da dobijete njegove machine_id i session_id."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "%2$@ starter stadig på %1$@. Kør `%3$@` igen for at få dens machine_id og session_id."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "%2$@ si sta ancora avviando su %1$@. Esegui di nuovo `%3$@` per ottenere machine_id e session_id."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "%2$@ នៅតែកំពុងចាប់ផ្ដើមនៅលើ %1$@។ ដំណើរការ `%3$@` ម្ដងទៀត ដើម្បីទទួលបាន machine_id និង session_id របស់វា។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "%2$@ starter fortsatt på %1$@. Kjør `%3$@` på nytt for å få machine_id og session_id."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "%2$@ wciąż się uruchamia na %1$@. Uruchom ponownie `%3$@`, aby uzyskać machine_id i session_id."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "%2$@ ainda está iniciando em %1$@. Execute `%3$@` novamente para obter o machine_id e o session_id."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "%2$@ ещё запускается на %1$@. Снова выполните `%3$@`, чтобы получить machine_id и session_id."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "%2$@ ยังกำลังเริ่มทำงานบน %1$@ เรียกใช้ `%3$@` อีกครั้งเพื่อรับ machine_id และ session_id"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "%2$@ hâlâ %1$@ üzerinde başlatılıyor. machine_id ve session_id değerlerini almak için `%3$@` komutunu yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "%2$@ ще запускається на %1$@. Знову виконайте `%3$@`, щоб отримати machine_id і session_id."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "%2$@ vẫn đang khởi động trên %1$@. Chạy lại `%3$@` để lấy machine_id và session_id."}} } }, "cloud.startSession.notConnected": { @@ -103,7 +175,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "لم يتصل الجهاز `%1$@`، لذا لم يبدأ أي وكيل عليه. افحصه باستخدام `cmux cloud machine-status --target machine:%2$@`، ثم شغّل `cmux cloud start-session --target machine:%2$@ --agent %3$@` مرة أخرى."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "머신 `%1$@`이(가) 연결되지 않아 에이전트가 시작되지 않았습니다. `cmux cloud machine-status --target machine:%2$@`로 확인한 다음 `cmux cloud start-session --target machine:%2$@ --agent %3$@`을(를) 다시 실행하세요."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "机器 `%1$@` 未连接,因此没有启动代理。请用 `cmux cloud machine-status --target machine:%2$@` 检查,然后再次运行 `cmux cloud start-session --target machine:%2$@ --agent %3$@`。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "機器 `%1$@` 未連線,因此沒有啟動代理。請用 `cmux cloud machine-status --target machine:%2$@` 檢查,然後再次執行 `cmux cloud start-session --target machine:%2$@ --agent %3$@`。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "機器 `%1$@` 未連線,因此沒有啟動代理。請用 `cmux cloud machine-status --target machine:%2$@` 檢查,然後再次執行 `cmux cloud start-session --target machine:%2$@ --agent %3$@`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Mašina `%1$@` se nije povezala, pa na njoj nije pokrenut nijedan agent. Provjerite je pomoću `cmux cloud machine-status --target machine:%2$@`, a zatim ponovo pokrenite `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Maskinen `%1$@` oprettede ikke forbindelse, så ingen agent startede der. Tjek den med `cmux cloud machine-status --target machine:%2$@`, og kør derefter `cmux cloud start-session --target machine:%2$@ --agent %3$@` igen."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "La macchina `%1$@` non si è connessa, quindi nessun agente è stato avviato lì. Controllala con `cmux cloud machine-status --target machine:%2$@`, poi esegui di nuovo `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "ម៉ាស៊ីន `%1$@` មិនបានភ្ជាប់ទេ ដូច្នេះគ្មានភ្នាក់ងារណាបានចាប់ផ្ដើមនៅទីនោះទេ។ ពិនិត្យវាដោយប្រើ `cmux cloud machine-status --target machine:%2$@` រួចដំណើរការ `cmux cloud start-session --target machine:%2$@ --agent %3$@` ម្ដងទៀត។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Maskinen `%1$@` koblet seg ikke til, så ingen agent startet der. Sjekk den med `cmux cloud machine-status --target machine:%2$@`, og kjør deretter `cmux cloud start-session --target machine:%2$@ --agent %3$@` på nytt."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Maszyna `%1$@` nie połączyła się, więc żaden agent nie został na niej uruchomiony. Sprawdź ją poleceniem `cmux cloud machine-status --target machine:%2$@`, a następnie uruchom ponownie `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "A máquina `%1$@` não se conectou, então nenhum agente foi iniciado nela. Verifique-a com `cmux cloud machine-status --target machine:%2$@` e execute `cmux cloud start-session --target machine:%2$@ --agent %3$@` novamente."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Машина `%1$@` не подключилась, поэтому агент на ней не запущен. Проверьте её командой `cmux cloud machine-status --target machine:%2$@`, затем снова выполните `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "เครื่อง `%1$@` ไม่ได้เชื่อมต่อ จึงไม่มีเอเจนต์เริ่มทำงานบนเครื่องนั้น ตรวจสอบด้วย `cmux cloud machine-status --target machine:%2$@` แล้วเรียกใช้ `cmux cloud start-session --target machine:%2$@ --agent %3$@` อีกครั้ง"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "`%1$@` makinesi bağlanmadı, bu yüzden orada hiçbir ajan başlatılmadı. Makineyi `cmux cloud machine-status --target machine:%2$@` ile kontrol edin, ardından `cmux cloud start-session --target machine:%2$@ --agent %3$@` komutunu yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Машина `%1$@` не під'єдналася, тому агент на ній не запущено. Перевірте її командою `cmux cloud machine-status --target machine:%2$@`, потім знову виконайте `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Máy `%1$@` không kết nối được nên không có tác tử nào được khởi động trên đó. Kiểm tra máy bằng `cmux cloud machine-status --target machine:%2$@`, rồi chạy lại `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}} } }, "cloud.startSession.agentFailed": { @@ -118,7 +202,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "بدأ الجهاز `%1$@` لكن وكيله لم يبدأ (%4$@). شغّل `cmux cloud start-session --target machine:%2$@ --agent %3$@` مرة أخرى."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "머신 `%1$@`은(는) 시작됐지만 에이전트는 시작되지 않았습니다(%4$@). `cmux cloud start-session --target machine:%2$@ --agent %3$@`을(를) 다시 실행하세요."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "机器 `%1$@` 已启动,但其代理未启动(%4$@)。请再次运行 `cmux cloud start-session --target machine:%2$@ --agent %3$@`。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "機器 `%1$@` 已啟動,但其代理未啟動(%4$@)。請再次執行 `cmux cloud start-session --target machine:%2$@ --agent %3$@`。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "機器 `%1$@` 已啟動,但其代理未啟動(%4$@)。請再次執行 `cmux cloud start-session --target machine:%2$@ --agent %3$@`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Mašina `%1$@` se pokrenula, ali njen agent nije (%4$@). Ponovo pokrenite `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Maskinen `%1$@` startede, men dens agent gjorde ikke (%4$@). Kør `cmux cloud start-session --target machine:%2$@ --agent %3$@` igen."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "La macchina `%1$@` si è avviata, ma il suo agente no (%4$@). Esegui di nuovo `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "ម៉ាស៊ីន `%1$@` បានចាប់ផ្ដើម ប៉ុន្តែភ្នាក់ងាររបស់វាមិនបានចាប់ផ្ដើមទេ (%4$@)។ ដំណើរការ `cmux cloud start-session --target machine:%2$@ --agent %3$@` ម្ដងទៀត។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Maskinen `%1$@` startet, men agenten på den gjorde ikke det (%4$@). Kjør `cmux cloud start-session --target machine:%2$@ --agent %3$@` på nytt."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Maszyna `%1$@` uruchomiła się, ale jej agent nie (%4$@). Uruchom ponownie `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "A máquina `%1$@` iniciou, mas o agente dela não (%4$@). Execute `cmux cloud start-session --target machine:%2$@ --agent %3$@` novamente."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Машина `%1$@` запустилась, а её агент нет (%4$@). Снова выполните `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "เครื่อง `%1$@` เริ่มทำงานแล้ว แต่เอเจนต์ของเครื่องไม่เริ่ม (%4$@) เรียกใช้ `cmux cloud start-session --target machine:%2$@ --agent %3$@` อีกครั้ง"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "`%1$@` makinesi başladı ama ajanı başlamadı (%4$@). `cmux cloud start-session --target machine:%2$@ --agent %3$@` komutunu yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Машина `%1$@` запустилася, а її агент ні (%4$@). Знову виконайте `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Máy `%1$@` đã khởi động nhưng tác tử của máy thì không (%4$@). Chạy lại `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}} } }, "cloud.startSession.needsFolder": { @@ -133,7 +229,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "بدأ الجهاز `%1$@` لكن وكيله لم يبدأ: يحتاج إلى مجلد على ذلك الجهاز ليبدأ فيه. شغّل `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` مرة أخرى مع مجلد هناك."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "머신 `%1$@`은(는) 시작됐지만 에이전트는 시작되지 않았습니다. 그 머신에서 시작할 폴더가 필요합니다. 그 머신의 폴더로 `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work`을(를) 다시 실행하세요."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "机器 `%1$@` 已启动,但其代理未启动:它需要在该机器上的一个文件夹中启动。请用该机器上的文件夹再次运行 `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work`。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "機器 `%1$@` 已啟動,但其代理未啟動:它需要在該機器上的一個資料夾中啟動。請用該機器上的資料夾再次執行 `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work`。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "機器 `%1$@` 已啟動,但其代理未啟動:它需要在該機器上的一個資料夾中啟動。請用該機器上的資料夾再次執行 `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Mašina `%1$@` se pokrenula, ali njen agent nije: treba mu folder na toj mašini u kojem će se pokrenuti. Ponovo pokrenite `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` s folderom koji postoji tamo."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Maskinen `%1$@` startede, men dens agent gjorde ikke: den skal have en mappe på maskinen at starte i. Kør `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` igen med en mappe, der findes der."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "La macchina `%1$@` si è avviata, ma il suo agente no: gli serve una cartella su quella macchina in cui avviarsi. Esegui di nuovo `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` con una cartella presente lì."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "ម៉ាស៊ីន `%1$@` បានចាប់ផ្ដើម ប៉ុន្តែភ្នាក់ងាររបស់វាមិនបានចាប់ផ្ដើមទេ៖ វាត្រូវការថតមួយនៅលើម៉ាស៊ីននោះដើម្បីចាប់ផ្ដើម។ ដំណើរការ `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` ម្ដងទៀតជាមួយថតដែលមាននៅទីនោះ។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Maskinen `%1$@` startet, men agenten på den gjorde ikke det: den trenger en mappe på maskinen å starte i. Kjør `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` på nytt med en mappe som finnes der."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Maszyna `%1$@` uruchomiła się, ale jej agent nie: potrzebuje folderu na tej maszynie, w którym się uruchomi. Uruchom ponownie `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` z folderem, który tam istnieje."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "A máquina `%1$@` iniciou, mas o agente dela não: ele precisa de uma pasta nessa máquina para iniciar. Execute `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` novamente com uma pasta que exista lá."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Машина `%1$@` запустилась, а её агент нет: ему нужна папка на этой машине, в которой он запустится. Снова выполните `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work`, указав папку на ней."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "เครื่อง `%1$@` เริ่มทำงานแล้ว แต่เอเจนต์ของเครื่องไม่เริ่ม: เอเจนต์ต้องมีโฟลเดอร์บนเครื่องนั้นเพื่อเริ่มทำงาน เรียกใช้ `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` อีกครั้งโดยระบุโฟลเดอร์ที่อยู่บนเครื่องนั้น"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "`%1$@` makinesi başladı ama ajanı başlamadı: başlamak için o makinede bir klasöre ihtiyacı var. `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` komutunu oradaki bir klasörle yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Машина `%1$@` запустилася, а її агент ні: йому потрібна папка на цій машині, у якій він запуститься. Знову виконайте `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work`, указавши папку на ній."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Máy `%1$@` đã khởi động nhưng tác tử của máy thì không: tác tử cần một thư mục trên máy đó để khởi động. Chạy lại `cmux cloud start-session --target machine:%2$@ --agent %3$@ --cwd /home/cmux/work` với một thư mục có trên máy đó."}} } }, "cloud.startSession.ask": { @@ -148,7 +256,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "يطلب الوكيل بدء جلسة %1$@ على جهاز Cloud ‏%2$@."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "에이전트가 Cloud 머신 %2$@에서 %1$@ 세션을 시작하려고 합니다."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "代理请求在 Cloud 机器 %2$@ 上启动 %1$@ 会话。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "代理請求在 Cloud 機器 %2$@ 上啟動 %1$@ 工作階段。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "代理請求在 Cloud 機器 %2$@ 上啟動 %1$@ 工作階段。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Agent traži da se pokrene %1$@ sesija na Cloud mašini %2$@."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Agenten beder om at starte en %1$@-session på Cloud-maskinen %2$@."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "L’agente chiede di avviare una sessione %1$@ sulla macchina Cloud %2$@."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "ភ្នាក់ងារសុំចាប់ផ្ដើមវគ្គ %1$@ នៅលើម៉ាស៊ីន Cloud %2$@។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Agenten ber om å starte en %1$@-økt på Cloud-maskinen %2$@."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Agent prosi o uruchomienie sesji %1$@ na maszynie Cloud %2$@."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "O agente pede para iniciar uma sessão de %1$@ na máquina do Cloud %2$@."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Агент просит запустить сеанс %1$@ на машине Cloud %2$@."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "เอเจนต์ขอเริ่มเซสชัน %1$@ บนเครื่อง Cloud %2$@"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Ajan, %2$@ Cloud makinesinde bir %1$@ oturumu başlatmak istiyor."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Агент просить запустити сеанс %1$@ на машині Cloud %2$@."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Tác tử muốn bắt đầu một phiên %1$@ trên máy Cloud %2$@."}} } }, "cloud.startSession.askNew": { @@ -163,7 +283,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "يطلب الوكيل إنشاء جهاز Cloud ‏%2$@ وبدء جلسة %1$@ عليه."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "에이전트가 Cloud 머신 %2$@을(를) 만들고 그곳에서 %1$@ 세션을 시작하려고 합니다."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "代理请求创建 Cloud 机器 %2$@ 并在其上启动 %1$@ 会话。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "代理請求建立 Cloud 機器 %2$@ 並在其上啟動 %1$@ 工作階段。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "代理請求建立 Cloud 機器 %2$@ 並在其上啟動 %1$@ 工作階段。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Agent traži da se kreira Cloud mašina %2$@ i na njoj pokrene %1$@ sesija."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Agenten beder om at oprette Cloud-maskinen %2$@ og starte en %1$@-session der."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "L’agente chiede di creare la macchina Cloud %2$@ e di avviarvi una sessione %1$@."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "ភ្នាក់ងារសុំបង្កើតម៉ាស៊ីន Cloud %2$@ ហើយចាប់ផ្ដើមវគ្គ %1$@ នៅទីនោះ។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Agenten ber om å opprette Cloud-maskinen %2$@ og starte en %1$@-økt der."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Agent prosi o utworzenie maszyny Cloud %2$@ i uruchomienie na niej sesji %1$@."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "O agente pede para criar a máquina do Cloud %2$@ e iniciar uma sessão de %1$@ nela."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Агент просит создать машину Cloud %2$@ и запустить на ней сеанс %1$@."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "เอเจนต์ขอสร้างเครื่อง Cloud %2$@ และเริ่มเซสชัน %1$@ บนเครื่องนั้น"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Ajan, %2$@ Cloud makinesini oluşturmak ve orada bir %1$@ oturumu başlatmak istiyor."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Агент просить створити машину Cloud %2$@ і запустити на ній сеанс %1$@."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Tác tử muốn tạo máy Cloud %2$@ và bắt đầu một phiên %1$@ trên đó."}} } }, "cloud.startSession.starting": { @@ -178,7 +310,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "جارٍ بدء %1$@ على %2$@…"}}, "ko": {"stringUnit": {"state": "needs_review", "value": "%2$@에서 %1$@ 시작 중…"}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "正在 %2$@ 上启动 %1$@…"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "正在 %2$@ 上啟動 %1$@…"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "正在 %2$@ 上啟動 %1$@…"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Pokretanje %1$@ na %2$@…"}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Starter %1$@ på %2$@…"}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Avvio di %1$@ su %2$@…"}}, + "km": {"stringUnit": {"state": "needs_review", "value": "កំពុងចាប់ផ្ដើម %1$@ នៅលើ %2$@…"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Starter %1$@ på %2$@…"}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Uruchamianie %1$@ na %2$@…"}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Iniciando %1$@ em %2$@…"}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Запуск %1$@ на %2$@…"}}, + "th": {"stringUnit": {"state": "needs_review", "value": "กำลังเริ่ม %1$@ บน %2$@…"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "%1$@, %2$@ üzerinde başlatılıyor…"}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Запуск %1$@ на %2$@…"}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Đang khởi động %1$@ trên %2$@…"}} } }, "cloud.startSession.start": { @@ -193,7 +337,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "بدء"}}, "ko": {"stringUnit": {"state": "needs_review", "value": "시작"}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "启动"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "啟動"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "啟動"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Pokreni"}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Start"}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Avvia"}}, + "km": {"stringUnit": {"state": "needs_review", "value": "ចាប់ផ្ដើម"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Start"}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Uruchom"}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Iniciar"}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Запустить"}}, + "th": {"stringUnit": {"state": "needs_review", "value": "เริ่ม"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Başlat"}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Запустити"}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Bắt đầu"}} } }, "cloud.startSession.notInChat": { @@ -208,7 +364,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "تعذّر العثور على الدردشة التي يعمل فيها هذا الوكيل، لذا لا يمكن سؤال أحد عن بدء %2$@ على جهاز Cloud ‏%1$@. شغّل `%3$@` من صدفة وكيل في دردشة cmux، أو اطلب من الشخص تشغيله."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "이 에이전트가 실행 중인 채팅을 찾지 못해 Cloud 머신 %1$@에서 %2$@을(를) 시작할지 물어볼 수 없습니다. cmux 채팅 속 에이전트의 셸에서 `%3$@`을(를) 실행하거나 사용자에게 실행을 요청하세요."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "找不到此代理所在的聊天,因此无法询问是否在 Cloud 机器 %1$@ 上启动 %2$@。请从 cmux 聊天中代理的 shell 运行 `%3$@`,或请对方运行。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "找不到此代理所在的聊天,因此無法詢問是否在 Cloud 機器 %1$@ 上啟動 %2$@。請從 cmux 聊天中代理的 shell 執行 `%3$@`,或請對方執行。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "找不到此代理所在的聊天,因此無法詢問是否在 Cloud 機器 %1$@ 上啟動 %2$@。請從 cmux 聊天中代理的 shell 執行 `%3$@`,或請對方執行。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Nije moguće pronaći razgovor u kojem ovaj agent radi, pa se nikoga ne može pitati da pokrene %2$@ na Cloud mašini %1$@. Pokrenite `%3$@` iz ljuske agenta u cmux razgovoru ili zamolite osobu da ga pokrene."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Kunne ikke finde den chat, som denne agent kører i, så ingen kan blive spurgt om at starte %2$@ på Cloud-maskinen %1$@. Kør `%3$@` fra en agents shell i en cmux-chat, eller bed personen om at køre den."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Impossibile trovare la chat in cui viene eseguito questo agente, quindi non si può chiedere a nessuno di avviare %2$@ sulla macchina Cloud %1$@. Esegui `%3$@` dalla shell di un agente in una chat di cmux, oppure chiedi alla persona di eseguirlo."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "រកមិនឃើញការជជែកដែលភ្នាក់ងារនេះដំណើរការនៅក្នុងទេ ដូច្នេះមិនអាចសុំឱ្យនរណាម្នាក់ចាប់ផ្ដើម %2$@ នៅលើម៉ាស៊ីន Cloud %1$@ បានទេ។ ដំណើរការ `%3$@` ពីសែលរបស់ភ្នាក់ងារក្នុងការជជែក cmux ឬសុំឱ្យមនុស្សនោះដំណើរការវា។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Fant ikke chatten denne agenten kjører i, så ingen kan bli spurt om å starte %2$@ på Cloud-maskinen %1$@. Kjør `%3$@` fra en agents skall i en cmux-chat, eller be personen kjøre den."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Nie można znaleźć czatu, w którym działa ten agent, więc nikogo nie można poprosić o uruchomienie %2$@ na maszynie Cloud %1$@. Uruchom `%3$@` z powłoki agenta w czacie cmux lub poproś użytkownika, aby to uruchomił."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Não foi possível encontrar o chat em que este agente roda, então não há a quem pedir para iniciar %2$@ na máquina do Cloud %1$@. Execute `%3$@` no shell de um agente em um chat do cmux ou peça à pessoa para executá-lo."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Не удалось найти чат, в котором работает этот агент, поэтому некого попросить запустить %2$@ на машине Cloud %1$@. Выполните `%3$@` из оболочки агента в чате cmux или попросите пользователя выполнить эту команду."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "ไม่พบแชทที่เอเจนต์นี้ทำงานอยู่ จึงไม่มีใครให้ขอเริ่ม %2$@ บนเครื่อง Cloud %1$@ ได้ เรียกใช้ `%3$@` จากเชลล์ของเอเจนต์ในแชท cmux หรือขอให้ผู้ใช้เรียกใช้คำสั่งนี้"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Bu ajanın çalıştığı sohbet bulunamadı, bu yüzden kimseden %2$@ ajanını %1$@ Cloud makinesinde başlatması istenemiyor. `%3$@` komutunu bir cmux sohbetindeki ajan kabuğundan çalıştırın veya kişiden çalıştırmasını isteyin."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Не вдалося знайти чат, у якому працює цей агент, тому немає кого попросити запустити %2$@ на машині Cloud %1$@. Виконайте `%3$@` з оболонки агента в чаті cmux або попросіть користувача виконати цю команду."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Không tìm thấy cuộc trò chuyện mà tác tử này đang chạy, nên không thể hỏi ai để khởi động %2$@ trên máy Cloud %1$@. Chạy `%3$@` từ shell của một tác tử trong cuộc trò chuyện cmux, hoặc nhờ người dùng chạy lệnh đó."}} } }, "cloud.startSession.noPage": { @@ -223,7 +391,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "تعذّر عرض السؤال في الدردشة، لذا لم يبدأ %2$@ على %1$@. شغّل `%3$@` مرة أخرى بعد فتح الدردشة."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "채팅에 질문을 표시하지 못해 %1$@에서 %2$@이(가) 시작되지 않았습니다. 채팅이 열리면 `%3$@`을(를) 다시 실행하세요."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "无法在聊天中显示问题,因此 %2$@ 没有在 %1$@ 上启动。聊天打开后请再次运行 `%3$@`。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "無法在聊天中顯示問題,因此 %2$@ 沒有在 %1$@ 上啟動。聊天開啟後請再次執行 `%3$@`。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "無法在聊天中顯示問題,因此 %2$@ 沒有在 %1$@ 上啟動。聊天開啟後請再次執行 `%3$@`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Nije moguće prikazati pitanje u razgovoru, pa se %2$@ nije pokrenuo na %1$@. Ponovo pokrenite `%3$@` kada razgovor bude otvoren."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Kunne ikke vise spørgsmålet i chatten, så %2$@ startede ikke på %1$@. Kør `%3$@` igen, når chatten er åben."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Impossibile mostrare la domanda nella chat, quindi %2$@ non è stato avviato su %1$@. Esegui di nuovo `%3$@` quando la chat è aperta."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "មិនអាចបង្ហាញសំណួរក្នុងការជជែកបានទេ ដូច្នេះ %2$@ មិនបានចាប់ផ្ដើមនៅលើ %1$@ ទេ។ ដំណើរការ `%3$@` ម្ដងទៀតនៅពេលការជជែកបើក។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Kunne ikke vise spørsmålet i chatten, så %2$@ startet ikke på %1$@. Kjør `%3$@` på nytt når chatten er åpen."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Nie można pokazać pytania w czacie, więc %2$@ nie uruchomił się na %1$@. Uruchom ponownie `%3$@`, gdy czat będzie otwarty."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Não foi possível mostrar a pergunta no chat, então %2$@ não iniciou em %1$@. Execute `%3$@` novamente quando o chat estiver aberto."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Не удалось показать вопрос в чате, поэтому %2$@ не запущен на %1$@. Снова выполните `%3$@`, когда чат будет открыт."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "แสดงคำถามในแชทไม่ได้ %2$@ จึงไม่ได้เริ่มบน %1$@ เรียกใช้ `%3$@` อีกครั้งเมื่อแชทเปิดอยู่"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Soru sohbette gösterilemedi, bu yüzden %2$@ %1$@ üzerinde başlamadı. Sohbet açıkken `%3$@` komutunu yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Не вдалося показати запитання в чаті, тому %2$@ не запущено на %1$@. Знову виконайте `%3$@`, коли чат буде відкрито."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Không thể hiển thị câu hỏi trong cuộc trò chuyện nên %2$@ chưa khởi động trên %1$@. Chạy lại `%3$@` khi cuộc trò chuyện đang mở."}} } }, "cloud.startSession.askOpen": { @@ -238,7 +418,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "تطلب هذه الدردشة بالفعل من الشخص بدء جلسة Cloud. انتظر إجابته، ثم شغّل `%3$@` مرة أخرى إذا كان %2$@ لا يزال لا يعمل على %1$@."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "이 채팅은 이미 사용자에게 Cloud 세션 시작을 묻고 있습니다. 답을 기다린 뒤 %1$@에서 %2$@이(가) 아직 실행 중이 아니면 `%3$@`을(를) 다시 실행하세요."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "此聊天已在询问对方是否启动 Cloud 会话。请等待回答;如果 %2$@ 仍未在 %1$@ 上运行,再次运行 `%3$@`。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "此聊天已在詢問對方是否啟動 Cloud 工作階段。請等待回答;如果 %2$@ 仍未在 %1$@ 上執行,再次執行 `%3$@`。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "此聊天已在詢問對方是否啟動 Cloud 工作階段。請等待回答;如果 %2$@ 仍未在 %1$@ 上執行,再次執行 `%3$@`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Ovaj razgovor već traži od osobe da pokrene Cloud sesiju. Sačekajte odgovor, a zatim ponovo pokrenite `%3$@` ako %2$@ još ne radi na %1$@."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Denne chat spørger allerede personen om at starte en Cloud-session. Vent på svaret, og kør derefter `%3$@` igen, hvis %2$@ stadig ikke kører på %1$@."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Questa chat chiede già alla persona di avviare una sessione Cloud. Attendi la risposta, poi esegui di nuovo `%3$@` se %2$@ non è ancora in esecuzione su %1$@."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "ការជជែកនេះកំពុងសុំឱ្យមនុស្សនោះចាប់ផ្ដើមវគ្គ Cloud រួចហើយ។ រង់ចាំចម្លើយរបស់គេ រួចដំណើរការ `%3$@` ម្ដងទៀត ប្រសិនបើ %2$@ នៅតែមិនដំណើរការនៅលើ %1$@។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Denne chatten spør allerede personen om å starte en Cloud-økt. Vent på svaret, og kjør deretter `%3$@` på nytt hvis %2$@ fortsatt ikke kjører på %1$@."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Ten czat już prosi użytkownika o uruchomienie sesji Cloud. Poczekaj na odpowiedź, a następnie uruchom ponownie `%3$@`, jeśli %2$@ nadal nie działa na %1$@."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Este chat já está pedindo à pessoa para iniciar uma sessão do Cloud. Aguarde a resposta e execute `%3$@` novamente se %2$@ ainda não estiver rodando em %1$@."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Этот чат уже просит пользователя запустить сеанс Cloud. Дождитесь ответа, затем снова выполните `%3$@`, если %2$@ всё ещё не работает на %1$@."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "แชทนี้กำลังขอให้ผู้ใช้เริ่มเซสชัน Cloud อยู่แล้ว รอคำตอบ แล้วเรียกใช้ `%3$@` อีกครั้งหาก %2$@ ยังไม่ทำงานบน %1$@"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Bu sohbet kişiden zaten bir Cloud oturumu başlatmasını istiyor. Yanıtını bekleyin, %2$@ hâlâ %1$@ üzerinde çalışmıyorsa `%3$@` komutunu yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Цей чат уже просить користувача запустити сеанс Cloud. Дочекайтеся відповіді, потім знову виконайте `%3$@`, якщо %2$@ досі не працює на %1$@."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Cuộc trò chuyện này đã đang hỏi người dùng để bắt đầu một phiên Cloud. Hãy chờ câu trả lời, rồi chạy lại `%3$@` nếu %2$@ vẫn chưa chạy trên %1$@."}} } }, "cloud.startSession.declined": { @@ -253,7 +445,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "اختار الشخص «ليس الآن»، لذا لم يبدأ %2$@ على %1$@. اسأله في الدردشة قبل تشغيل `%3$@` مرة أخرى."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "사용자가 나중에를 선택해 %1$@에서 %2$@이(가) 시작되지 않았습니다. `%3$@`을(를) 다시 실행하기 전에 채팅에서 물어보세요."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "对方选择了“以后再说”,因此 %2$@ 没有在 %1$@ 上启动。再次运行 `%3$@` 前请先在聊天中询问。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "對方選擇了「以後再說」,因此 %2$@ 沒有在 %1$@ 上啟動。再次執行 `%3$@` 前請先在聊天中詢問。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "對方選擇了「以後再說」,因此 %2$@ 沒有在 %1$@ 上啟動。再次執行 `%3$@` 前請先在聊天中詢問。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Osoba je odabrala „Ne sada“, pa se %2$@ nije pokrenuo na %1$@. Pitajte je u razgovoru prije nego što ponovo pokrenete `%3$@`."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Personen valgte »Ikke nu«, så %2$@ startede ikke på %1$@. Spørg vedkommende i chatten, før du kører `%3$@` igen."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "La persona ha scelto «Non ora», quindi %2$@ non è stato avviato su %1$@. Chiedile nella chat prima di eseguire di nuovo `%3$@`."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "មនុស្សនោះបានជ្រើស «មិនមែនឥឡូវនេះ» ដូច្នេះ %2$@ មិនបានចាប់ផ្ដើមនៅលើ %1$@ ទេ។ សួរគេក្នុងការជជែក មុនពេលអ្នកដំណើរការ `%3$@` ម្ដងទៀត។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Personen valgte «Ikke nå», så %2$@ startet ikke på %1$@. Spør vedkommende i chatten før du kjører `%3$@` på nytt."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Użytkownik wybrał „Nie teraz”, więc %2$@ nie uruchomił się na %1$@. Zapytaj go w czacie, zanim ponownie uruchomisz `%3$@`."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "A pessoa escolheu “Agora não”, então %2$@ não iniciou em %1$@. Pergunte a ela no chat antes de executar `%3$@` novamente."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Пользователь выбрал «Не сейчас», поэтому %2$@ не запущен на %1$@. Спросите его в чате, прежде чем снова выполнять `%3$@`."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "ผู้ใช้เลือก “ไม่ใช่ตอนนี้” %2$@ จึงไม่ได้เริ่มบน %1$@ ถามผู้ใช้ในแชทก่อนเรียกใช้ `%3$@` อีกครั้ง"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Kişi “Şimdi değil” seçeneğini seçti, bu yüzden %2$@ %1$@ üzerinde başlamadı. `%3$@` komutunu yeniden çalıştırmadan önce sohbette ona sorun."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Користувач вибрав «Не зараз», тому %2$@ не запущено на %1$@. Запитайте його в чаті, перш ніж знову виконувати `%3$@`."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Người dùng đã chọn “Để sau” nên %2$@ chưa khởi động trên %1$@. Hãy hỏi họ trong cuộc trò chuyện trước khi chạy lại `%3$@`."}} } }, "cloud.startSession.expired": { @@ -268,7 +472,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "لم يُجب أحد عن السؤال في الدردشة خلال دقيقتين، لذا لم يبدأ %2$@ على %1$@. شغّل `%3$@` مرة أخرى عندما يكون الشخص موجودًا."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "2분 안에 채팅의 질문에 답한 사람이 없어 %1$@에서 %2$@이(가) 시작되지 않았습니다. 사용자가 있을 때 `%3$@`을(를) 다시 실행하세요."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "2 分钟内没有人回答聊天中的问题,因此 %2$@ 没有在 %1$@ 上启动。对方在时请再次运行 `%3$@`。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "2 分鐘內沒有人回答聊天中的問題,因此 %2$@ 沒有在 %1$@ 上啟動。對方在時請再次執行 `%3$@`。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "2 分鐘內沒有人回答聊天中的問題,因此 %2$@ 沒有在 %1$@ 上啟動。對方在時請再次執行 `%3$@`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Niko nije odgovorio na pitanje u razgovoru u roku od 2 minute, pa se %2$@ nije pokrenuo na %1$@. Ponovo pokrenite `%3$@` kada osoba bude tu."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Ingen svarede på spørgsmålet i chatten inden for 2 minutter, så %2$@ startede ikke på %1$@. Kør `%3$@` igen, når personen er der."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Nessuno ha risposto alla domanda nella chat entro 2 minuti, quindi %2$@ non è stato avviato su %1$@. Esegui di nuovo `%3$@` quando la persona è presente."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "គ្មាននរណាឆ្លើយសំណួរក្នុងការជជែកក្នុងរយៈពេល 2 នាទីទេ ដូច្នេះ %2$@ មិនបានចាប់ផ្ដើមនៅលើ %1$@ ទេ។ ដំណើរការ `%3$@` ម្ដងទៀតនៅពេលមនុស្សនោះនៅទីនោះ។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Ingen svarte på spørsmålet i chatten innen 2 minutter, så %2$@ startet ikke på %1$@. Kjør `%3$@` på nytt når personen er til stede."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Nikt nie odpowiedział na pytanie w czacie w ciągu 2 minut, więc %2$@ nie uruchomił się na %1$@. Uruchom ponownie `%3$@`, gdy użytkownik będzie dostępny."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Ninguém respondeu à pergunta no chat em 2 minutos, então %2$@ não iniciou em %1$@. Execute `%3$@` novamente quando a pessoa estiver presente."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Никто не ответил на вопрос в чате за 2 минуты, поэтому %2$@ не запущен на %1$@. Снова выполните `%3$@`, когда пользователь будет на месте."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "ไม่มีใครตอบคำถามในแชทภายใน 2 นาที %2$@ จึงไม่ได้เริ่มบน %1$@ เรียกใช้ `%3$@` อีกครั้งเมื่อผู้ใช้อยู่"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Sohbetteki soruyu 2 dakika içinde kimse yanıtlamadı, bu yüzden %2$@ %1$@ üzerinde başlamadı. Kişi oradayken `%3$@` komutunu yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Ніхто не відповів на запитання в чаті протягом 2 хвилин, тому %2$@ не запущено на %1$@. Знову виконайте `%3$@`, коли користувач буде на місці."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Không ai trả lời câu hỏi trong cuộc trò chuyện trong vòng 2 phút nên %2$@ chưa khởi động trên %1$@. Chạy lại `%3$@` khi người dùng có mặt."}} } }, "cloud.startSession.listUnread": { @@ -283,7 +499,19 @@ "ar": {"stringUnit": {"state": "needs_review", "value": "تعذّرت قراءة أجهزة Cloud الخاصة بك، لذا لم يبدأ %2$@: قد يكون هناك جهاز باسم %1$@ بالفعل. افحص `cmux cloud list-machines`، ثم شغّل `%3$@` مرة أخرى."}}, "ko": {"stringUnit": {"state": "needs_review", "value": "Cloud 머신 목록을 읽지 못해 %2$@이(가) 시작되지 않았습니다. %1$@이라는 머신이 이미 있을 수 있습니다. `cmux cloud list-machines`로 확인한 다음 `%3$@`을(를) 다시 실행하세요."}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "无法读取你的 Cloud 机器,因此 %2$@ 没有启动:可能已有名为 %1$@ 的机器。请检查 `cmux cloud list-machines`,然后再次运行 `%3$@`。"}}, - "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "無法讀取你的 Cloud 機器,因此 %2$@ 沒有啟動:可能已有名為 %1$@ 的機器。請檢查 `cmux cloud list-machines`,然後再次執行 `%3$@`。"}} + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "無法讀取你的 Cloud 機器,因此 %2$@ 沒有啟動:可能已有名為 %1$@ 的機器。請檢查 `cmux cloud list-machines`,然後再次執行 `%3$@`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Nije moguće pročitati vaše Cloud mašine, pa se %2$@ nije pokrenuo: mašina s nazivom %1$@ možda već postoji. Provjerite `cmux cloud list-machines`, a zatim ponovo pokrenite `%3$@`."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Kunne ikke læse dine Cloud-maskiner, så %2$@ startede ikke: en maskine med navnet %1$@ findes måske allerede. Tjek `cmux cloud list-machines`, og kør derefter `%3$@` igen."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Impossibile leggere le tue macchine Cloud, quindi %2$@ non è stato avviato: una macchina chiamata %1$@ potrebbe già esistere. Controlla `cmux cloud list-machines`, poi esegui di nuovo `%3$@`."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "មិនអាចអានម៉ាស៊ីន Cloud របស់អ្នកបានទេ ដូច្នេះ %2$@ មិនបានចាប់ផ្ដើមទេ៖ ម៉ាស៊ីនឈ្មោះ %1$@ ប្រហែលជាមានរួចហើយ។ ពិនិត្យ `cmux cloud list-machines` រួចដំណើរការ `%3$@` ម្ដងទៀត។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Kunne ikke lese Cloud-maskinene dine, så %2$@ startet ikke: en maskin med navnet %1$@ finnes kanskje allerede. Sjekk `cmux cloud list-machines`, og kjør deretter `%3$@` på nytt."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Nie można odczytać Twoich maszyn Cloud, więc %2$@ nie uruchomił się: maszyna o nazwie %1$@ może już istnieć. Sprawdź `cmux cloud list-machines`, a następnie uruchom ponownie `%3$@`."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Não foi possível ler suas máquinas do Cloud, então %2$@ não iniciou: uma máquina chamada %1$@ pode já existir. Verifique `cmux cloud list-machines` e execute `%3$@` novamente."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Не удалось прочитать ваши машины Cloud, поэтому %2$@ не запущен: машина с именем %1$@ может уже существовать. Проверьте `cmux cloud list-machines`, затем снова выполните `%3$@`."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "อ่านรายการเครื่อง Cloud ของคุณไม่ได้ %2$@ จึงไม่ได้เริ่ม: อาจมีเครื่องชื่อ %1$@ อยู่แล้ว ตรวจสอบ `cmux cloud list-machines` แล้วเรียกใช้ `%3$@` อีกครั้ง"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Cloud makineleriniz okunamadı, bu yüzden %2$@ başlamadı: %1$@ adında bir makine zaten var olabilir. `cmux cloud list-machines` ile kontrol edin, ardından `%3$@` komutunu yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Не вдалося прочитати ваші машини Cloud, тому %2$@ не запущено: машина з назвою %1$@ може вже існувати. Перевірте `cmux cloud list-machines`, потім знову виконайте `%3$@`."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Không thể đọc danh sách máy Cloud của bạn nên %2$@ chưa khởi động: có thể đã có một máy tên %1$@. Kiểm tra `cmux cloud list-machines`, rồi chạy lại `%3$@`."}} } } }, diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index a62770ec11ac..a9b6f404b06f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -119,7 +119,8 @@ struct CloudStartSessionTests { #expect(pending["pending"] == .bool(true)) #expect(pending["machine_id"] == .string("vm-a")) #expect(pending["text"] == .string(CloudStartSessionStrings.stillStarting(request.asked(machine: "vm-a")))) - #expect(pending["text"]?.stringValue?.contains("`cmux cloud start-session --target machine:vm-a --agent claude`") == true) + guard case .string(let text)? = pending["text"] else { Issue.record("pending has no text"); return } + #expect(text.contains("`cmux cloud start-session --target machine:vm-a --agent claude`")) // Through the race: the caller's wait ends before the run. let runs = CloudStartSessionRuns() runs.sleep = { _ in } From 0e9b0fa181542127a6a5abccb3b4351362fcb59f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 25 Oct 2026 23:13:55 -0700 Subject: [PATCH 09/33] cmux next: regenerate action exports with cloud start-session Base's exports predate its auto-archive actions; these are the export script's output for this tree. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- plans/cmux-next/action-surfaces.json | 264 +++++++++++++++++- plans/cmux-next/actions.md | 18 +- .../next-docs/cli-reference.generated.json | 28 +- 3 files changed, 284 insertions(+), 26 deletions(-) diff --git a/plans/cmux-next/action-surfaces.json b/plans/cmux-next/action-surfaces.json index 7b0766c43ced..9db9c3e5a801 100644 --- a/plans/cmux-next/action-surfaces.json +++ b/plans/cmux-next/action-surfaces.json @@ -26156,6 +26156,34 @@ "title_key" : "action.remote.openLocalBrowserTab", "title_table" : "RemoteActions" }, + { + "cli" : "offered", + "cli_name" : "cloud start-session", + "context_menu" : "offered", + "context_menus" : [ + "cloudMachine" + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "cloudStartSession", + "main_menu" : null, + "mcp" : "offered", + "palette" : "offered", + "palette_section" : { + "id" : "category.cloud", + "order" : 110, + "title" : "Cloud and Account", + "title_key" : "category.cloud", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Start Agent Session on Cloud Machine…", + "title_key" : "action.cloudStartSession", + "title_table" : "CloudSessionActions" + }, { "cli" : "offered", "cli_name" : "machine set-icon", @@ -29333,6 +29361,118 @@ "title_key" : "action.wakeTab", "title_table" : "HibernationActions" }, + { + "cli" : "offered", + "cli_name" : "workspace archive-idle", + "context_menu" : "offered", + "context_menus" : [ + "sidebarBackground" + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "workspace.archiveIdle", + "main_menu" : null, + "mcp" : "offered", + "palette" : "offered", + "palette_section" : { + "id" : "category.workspace", + "order" : 101, + "title" : "Workspace", + "title_key" : "category.workspace", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Archive Idle Workspaces", + "title_key" : "action.autoArchive.archiveIdle", + "title_table" : "WorkspaceActions" + }, + { + "cli" : "offered", + "cli_name" : "workspace restore-archived", + "context_menu" : "offered", + "context_menus" : [ + "sidebarBackground" + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "workspace.restoreArchived", + "main_menu" : null, + "mcp" : "offered", + "palette" : "offered", + "palette_section" : { + "id" : "category.workspace", + "order" : 101, + "title" : "Workspace", + "title_key" : "category.workspace", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Restore Archived Workspace…", + "title_key" : "action.autoArchive.restoreArchived", + "title_table" : "WorkspaceActions" + }, + { + "cli" : "offered", + "cli_name" : "workspace-group set-auto-archive", + "context_menu" : "offered", + "context_menus" : [ + "workspaceGroup" + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "workspaceGroup.setAutoArchive", + "main_menu" : null, + "mcp" : "offered", + "palette" : "offered", + "palette_section" : { + "id" : "category.workspace", + "order" : 101, + "title" : "Workspace", + "title_key" : "category.workspace", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Auto-Archive Group Workspaces", + "title_key" : "action.autoArchive.setGroupRule", + "title_table" : "WorkspaceActions" + }, + { + "cli" : "devOnly", + "cli_name" : "action debug-auto-archive-age", + "context_menu" : "noObject", + "context_menus" : [ + + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "debug.autoArchive.age", + "main_menu" : null, + "mcp" : "devOnly", + "palette" : "offered", + "palette_section" : { + "id" : "category.workspace", + "order" : 101, + "title" : "Workspace", + "title_key" : "category.workspace", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Age Workspaces for Auto-Archive", + "title_key" : "action.autoArchive.debugAge", + "title_table" : "WorkspaceActions" + }, { "cli" : "offered", "cli_name" : "tab move-to-new-split", @@ -37876,15 +38016,28 @@ ] } }, + { + "enabled_when" : "can_perform", + "id" : "cloudStartSession", + "kind" : "action", + "order" : 11, + "visible_when" : { + "debug_only" : false, + "feature" : "cloud", + "requires" : [ + + ] + } + }, { "kind" : "separator", - "order" : 11 + "order" : 12 }, { "enabled_when" : "can_perform", "id" : "palette.cloud.deleteSnapshot", "kind" : "action", - "order" : 12, + "order" : 13, "visible_when" : { "debug_only" : false, "feature" : "cloud", @@ -40948,7 +41101,7 @@ }, { "enabled_when" : "can_perform", - "id" : "sidebar.groupBy.none", + "id" : "workspace.archiveIdle", "kind" : "action", "order" : 2, "visible_when" : { @@ -40961,7 +41114,7 @@ }, { "enabled_when" : "can_perform", - "id" : "sidebar.groupBy.folder", + "id" : "workspace.restoreArchived", "kind" : "action", "order" : 3, "visible_when" : { @@ -40969,6 +41122,32 @@ "feature" : null, "requires" : [ + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.groupBy.none", + "kind" : "action", + "order" : 4, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, + { + "enabled_when" : "can_perform", + "id" : "sidebar.groupBy.folder", + "kind" : "action", + "order" : 5, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + ] } } @@ -44440,11 +44619,66 @@ ] } }, + { + "choices" : { + "argument" : "after", + "more_opens_palette" : false, + "values" : [ + { + "title" : "Use the Default", + "title_key" : null, + "title_table" : null, + "value" : "default" + }, + { + "title" : "Never", + "title_key" : null, + "title_table" : null, + "value" : "never" + }, + { + "title" : "After 1 Idle Day", + "title_key" : null, + "title_table" : null, + "value" : "24" + }, + { + "title" : "After 3 Idle Days", + "title_key" : null, + "title_table" : null, + "value" : "72" + }, + { + "title" : "After 1 Idle Week", + "title_key" : null, + "title_table" : null, + "value" : "168" + }, + { + "title" : "After 30 Idle Days", + "title_key" : null, + "title_table" : null, + "value" : "720" + } + ] + }, + "enabled_when" : "can_perform", + "id" : "workspaceGroup.setAutoArchive", + "kind" : "choices", + "order" : 5, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + + ] + } + }, { "enabled_when" : "can_perform", "id" : "workspaceGroup.markRead", "kind" : "action", - "order" : 5, + "order" : 6, "visible_when" : { "debug_only" : false, "feature" : null, @@ -44457,7 +44691,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.markUnread", "kind" : "action", - "order" : 6, + "order" : 7, "visible_when" : { "debug_only" : false, "feature" : null, @@ -44470,7 +44704,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.clearNotifications", "kind" : "action", - "order" : 7, + "order" : 8, "visible_when" : { "debug_only" : false, "feature" : null, @@ -44483,7 +44717,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.collapseAll", "kind" : "action", - "order" : 8, + "order" : 9, "visible_when" : { "debug_only" : false, "feature" : null, @@ -44496,7 +44730,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.expandAll", "kind" : "action", - "order" : 9, + "order" : 10, "visible_when" : { "debug_only" : false, "feature" : null, @@ -44510,7 +44744,7 @@ "id" : "workspaceGroup.collapseOthers", "kind" : "action", "label" : "Collapse Others", - "order" : 10, + "order" : 11, "visible_when" : { "debug_only" : false, "feature" : null, @@ -44523,7 +44757,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.editConfig", "kind" : "action", - "order" : 11, + "order" : 12, "visible_when" : { "debug_only" : false, "feature" : null, @@ -44536,7 +44770,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.duplicate", "kind" : "action", - "order" : 12, + "order" : 13, "visible_when" : { "debug_only" : false, "feature" : null, @@ -44549,7 +44783,7 @@ "enabled_when" : "can_perform", "id" : "workspaceGroup.copyID", "kind" : "action", - "order" : 13, + "order" : 14, "visible_when" : { "debug_only" : false, "feature" : null, @@ -44560,13 +44794,13 @@ }, { "kind" : "separator", - "order" : 14 + "order" : 15 }, { "enabled_when" : "can_perform", "id" : "workspaceGroup.stash", "kind" : "action", - "order" : 15, + "order" : 16, "visible_when" : { "debug_only" : false, "feature" : null, diff --git a/plans/cmux-next/actions.md b/plans/cmux-next/actions.md index 3331f30f4330..d3d73d47ef58 100644 --- a/plans/cmux-next/actions.md +++ b/plans/cmux-next/actions.md @@ -36,7 +36,7 @@ Declare its placements (menu, group, rank) and whether the CLI offers it, or nam ## Catalog counts - `window`: 106 -- `workspace`: 166 +- `workspace`: 170 - `pane`: 86 - `screen`: 65 - `tab`: 105 @@ -45,13 +45,13 @@ Declare its placements (menu, group, rank) and whether the CLI offers it, or nam - `sidebar`: 87 - `notifications`: 21 - `agents`: 93 -- `cloud`: 55 +- `cloud`: 56 - `remote`: 18 - `settings`: 123 -## Counts (1204 actions) +## Counts (1209 actions) -Palette 1154, CLI verbs 689, right-click 574, MCP tools 607. +Palette 1159, CLI verbs 693, right-click 578, MCP tools 611. ## Menus @@ -61,14 +61,14 @@ Palette 1154, CLI verbs 689, right-click 574, MCP tools 607. - **screenGroup**: screenGroup.newScreen | screenGroup.rename [appearance] > (screenGroup.setColor > (screenGroup.color.grey screenGroup.color.blue screenGroup.color.red screenGroup.color.yellow screenGroup.color.green screenGroup.color.pink screenGroup.color.purple screenGroup.color.cyan screenGroup.color.orange) screenGroup.setIcon screenGroup.clearIcon) | screenGroup.toggleCollapsed [group] > (screenGroup.save screenGroup.unsave) | [move] > (screenGroup.moveLeft screenGroup.moveRight screenGroup.moveToWorkspace screenGroup.moveToNewWorkspace screenGroup.moveToNewWindow) | screenGroup.ungroup screenGroup.close - **pane**: splitRight splitDown [split] > (splitLeft splitUp) toggleSplitZoom renamePane [more] > (newColumn newRow splitBrowserRight splitBrowserDown remote.openTerminalHere newPaneAutoLayout remote.openRemote | palette.newSimulatorPane | pane.setBackground[choices] pane.setTint[choices] pane.clearBackground | pane.moveToNewWorkspace swapPaneLeft swapPaneRight swapPaneUp swapPaneDown pane.moveToNewWindow column.moveLeft column.moveRight | [layout] > (equalizeSplits toggleCanvasLayout pane.toggleTabBar | column.dock column.undock column.float column.dockLeft column.dockRight column.dockTop column.dockBottom | column.widthOneThird column.widthHalf column.widthTwoThirds column.widthFull column.center agentPaneZoomIn agentPaneZoomOut agentPaneZoomReset) | palette.swapWithSession reconnectPane | triggerFlash agentPane.togglePointerInspector | palette.copyPaneID palette.copyPaneLink pane.screenshot | pane.closeOthers) | closePane - **workspaceRow**: renameWorkspace palette.toggleWorkspacePin palette.workspaceColor[choices] workspace.newBelow workspace.duplicate home.messageAbout [move] > (palette.moveWorkspaceToTop moveWorkspaceUp moveWorkspaceDown workspace.moveToBottom moveWorkspaceToWindow moveWorkspaceToNewWindow workspace.toggleTop workspace.moveToSpace[choices] workspace.duplicateToSpace[choices] workspace.mergeInto) | moveWorkspaceToGroup[choices] groupSelectedWorkspaces removeWorkspaceFromGroup | [more] > (workspace.newLikeThis workspace.newAbove workspace.newInNewGroup workspace.duplicateTerminalsOnly | palette.openFolderInVSCodeInline palette.openWorkspaceTodoPane palette.layout.open saveLayoutTemplate | workspace.toggleArchive | palette.resetWorkspaceColor workspace.removeAllColors workspace.setIcon workspace.clearIcon workspace.setTheme[choices] workspace.clearTheme workspace.setTint[choices] | [options] > (palette.clearWorkspaceName editWorkspaceDescription palette.clearWorkspaceDescription palette.workspaceStatus markWorkspaceDone palette.markWorkspaceRead notifications.dismissHighlight palette.markWorkspaceUnread notifications.toggleWorkspaceMute clearWorkspaceNotifications workspace.toggleAudioMute palette.addWorkspaceChecklistItem | browserProfile.setWorkspaceDefault browserProfile.clearWorkspaceDefault | reconnectWorkspace disconnectWorkspace workspace.setRemoteLocalhost[choices] | sidebar.workspaceTabs.toggle sidebar.density.compact sidebar.density.comfortable sidebar.density.expanded) | agentPane.togglePointerInspector | workspace.copyPath palette.copyWorkspaceID palette.copyWorkspaceLink palette.copyWorkspaceIDAndRef copyWorkspaceSSHError agentChat.copyIdentifier | workspace.showResources revealWorkspaceInFinder palette.openWorkspacePullRequests | palette.closeOtherWorkspaces workspace.closeOthersInGroup palette.closeWorkspacesBelow palette.closeWorkspacesAbove workspace.closeAll reopenClosedWorkspace workspace.stash window.stash) | closeWorkspace -- **workspaceGroup**: workspaceGroup.newWorkspace | workspaceGroup.rename workspaceGroup.setColor > (workspaceGroup.color.grey workspaceGroup.color.blue workspaceGroup.color.red workspaceGroup.color.yellow workspaceGroup.color.green workspaceGroup.color.pink workspaceGroup.color.purple workspaceGroup.color.cyan workspaceGroup.color.orange | workspaceGroup.clearColor) workspaceGroup.togglePin [options] > (workspaceGroup.setIcon workspaceGroup.clearIcon workspace.removeAllColors workspaceGroup.toggleArchive workspaceGroup.newSubgroup workspaceGroup.markRead workspaceGroup.markUnread workspaceGroup.clearNotifications workspaceGroup.collapseAll workspaceGroup.expandAll workspaceGroup.collapseOthers workspaceGroup.editConfig workspaceGroup.duplicate workspaceGroup.copyID | workspaceGroup.stash) | toggleFocusedWorkspaceGroupCollapsed | workspaceGroup.moveToTop [move] > (workspaceGroup.moveUp workspaceGroup.moveDown workspaceGroup.moveToBottom workspaceGroup.moveToNewWindow workspaceGroup.moveToWindow workspaceGroup.moveToSpace[choices] workspaceGroup.addToSection workspaceGroup.sortByName workspaceGroup.sortByLastUsed workspaceGroup.sortByDirectory) | workspaceGroup.ungroup workspaceGroup.closeWorkspaces workspaceGroup.closeOthers | workspaceGroup.delete -- **sidebarBackground**: newTab newBrowserWorkspace openFolder newWorkspaceGroup [new] > (workspace.newAtTop workspace.newAtBottom workspace.newOnMachine space.new reopenClosedWorkspace reopenPreviousSession | newCloudWorkspace remote.newGroup remote.connect device.add | sidebar.home.add sidebar.item.add sidebar.section.add) | [options] > (sidebar.edit sidebar.workspaceTabs.toggle palette.enableMinimalMode palette.disableMinimalMode sidebar.density.compact sidebar.density.comfortable sidebar.density.expanded | sidebar.layout.reset) | [group] > (workspaceGroup.collapseAll workspaceGroup.expandAll sidebar.groupBy.none sidebar.groupBy.folder) | [sort] > (workspace.sortByName workspace.sortByLastUsed workspace.sortByDirectory) | sidebar.projects.hide sidebar.sections.showHidden | toggleSidebar appStore.show +- **workspaceGroup**: workspaceGroup.newWorkspace | workspaceGroup.rename workspaceGroup.setColor > (workspaceGroup.color.grey workspaceGroup.color.blue workspaceGroup.color.red workspaceGroup.color.yellow workspaceGroup.color.green workspaceGroup.color.pink workspaceGroup.color.purple workspaceGroup.color.cyan workspaceGroup.color.orange | workspaceGroup.clearColor) workspaceGroup.togglePin [options] > (workspaceGroup.setIcon workspaceGroup.clearIcon workspace.removeAllColors workspaceGroup.toggleArchive workspaceGroup.newSubgroup workspaceGroup.setAutoArchive[choices] workspaceGroup.markRead workspaceGroup.markUnread workspaceGroup.clearNotifications workspaceGroup.collapseAll workspaceGroup.expandAll workspaceGroup.collapseOthers workspaceGroup.editConfig workspaceGroup.duplicate workspaceGroup.copyID | workspaceGroup.stash) | toggleFocusedWorkspaceGroupCollapsed | workspaceGroup.moveToTop [move] > (workspaceGroup.moveUp workspaceGroup.moveDown workspaceGroup.moveToBottom workspaceGroup.moveToNewWindow workspaceGroup.moveToWindow workspaceGroup.moveToSpace[choices] workspaceGroup.addToSection workspaceGroup.sortByName workspaceGroup.sortByLastUsed workspaceGroup.sortByDirectory) | workspaceGroup.ungroup workspaceGroup.closeWorkspaces workspaceGroup.closeOthers | workspaceGroup.delete +- **sidebarBackground**: newTab newBrowserWorkspace openFolder newWorkspaceGroup [new] > (workspace.newAtTop workspace.newAtBottom workspace.newOnMachine space.new reopenClosedWorkspace reopenPreviousSession | newCloudWorkspace remote.newGroup remote.connect device.add | sidebar.home.add sidebar.item.add sidebar.section.add) | [options] > (sidebar.edit sidebar.workspaceTabs.toggle palette.enableMinimalMode palette.disableMinimalMode sidebar.density.compact sidebar.density.comfortable sidebar.density.expanded | sidebar.layout.reset) | [group] > (workspaceGroup.collapseAll workspaceGroup.expandAll workspace.archiveIdle workspace.restoreArchived sidebar.groupBy.none sidebar.groupBy.folder) | [sort] > (workspace.sortByName workspace.sortByLastUsed workspace.sortByDirectory) | sidebar.projects.hide sidebar.sections.showHidden | toggleSidebar appStore.show - **terminalSelection**: terminalCopy terminalPaste terminal.selectAll useSelectionForFind terminal.sendText home.sendSelectionToChief home.sendSelectionTo | [split] > (splitRight splitDown splitLeft splitUp) [agent] > (palette.forkAgentConversationRight palette.forkAgentConversationNewTab palette.forkAgentConversationLeft palette.forkAgentConversationTop palette.forkAgentConversationBottom palette.forkAgentConversationNewWorkspace palette.forkAgentConversationNewWindow) | [options] > (clearScreenKeepScrollback resetTerminal terminal.clear) | toggleSplitZoom | palette.terminalOpenDirectory - **browserPage**: browserBack browserForward browserReload browserHardReload browser.copyURL browser.openLocally | browserTheme[choices] [options] > (toggleBrowserFocusMode toggleBrowserDesignMode toggleReactGrab) | browserPrint | bookmark.addPage | toggleBrowserDeveloperTools [tools] > (palette.browserOpenDefault browserScreenshotPage browserScreenshotSection browserDevice browserFitDesktop browserFitDesktop1280 browserFit[choices] | browser.pageInfo > (browser.pageInfo browser.pageInfo.connection browser.pageInfo.certificate browser.pageInfo.reenableCertificateWarnings | browser.pageInfo.setPermission browser.pageInfo.resetPermissions | browser.pageInfo.cookies browser.pageInfo.manageSiteData browser.pageInfo.deleteSiteData | browser.pageInfo.siteSettings browser.pageInfo.aboutThisPage) showBrowserJavaScriptConsole inspectBrowserElement | browser.extensions.menu browser.extensions.manage browser.extensions.webStore browser.extensions.loadUnpacked) - **browserLink**: browser.link.openInNewTab browser.link.openInNewWindow browser.link.openInNewSpace browser.link.openInNewWorkspace browser.link.openInSplit openLinkInDefaultBrowser | browser.link.openInIncognitoWindow | browser.link.saveAs browser.link.copy browser.link.copyText - **browserImage**: browser.image.openInNewTab | browser.image.saveAs browser.image.copy browser.image.copyAddress - **browserSelection**: browser.selection.copy home.sendSelectionToChief home.sendSelectionTo | browser.selection.search browser.selection.lookUp -- **cloudMachine**: cloudNewTerminal cloudOpenMachine cloudSSH cloudSetupGitHub | cloudRenameMachine | [connection] > (cloudResizeMachine palette.cloud.status palette.cloud.snapshot palette.cloud.fork palette.cloud.handoff palette.cloud.ports palette.cloud.promoteTemplate palette.cloud.restore cloudPauseMachine cloudResumeMachine cloudSetIdlePause | palette.cloud.deleteSnapshot) | [copy] > (cloudCopyMachineID cloudCopyLink cloudCopyPort) [tools] > (cloudFilesList cloudFileRead cloudFileWrite cloudFileMkdir cloudFileRemove cloudFileStat cloudPrepareSCP cloudTunnelAttach cloudTunnelDetach cloudTunnelRotateKey cloudNetworkList cloudFirewallList cloudFirewallGet cloudFirewallCreate cloudFirewallDelete | palette.cloud.tools) | cloudKillMachine +- **cloudMachine**: cloudNewTerminal cloudOpenMachine cloudSSH cloudSetupGitHub | cloudRenameMachine | [connection] > (cloudResizeMachine palette.cloud.status palette.cloud.snapshot palette.cloud.fork palette.cloud.handoff palette.cloud.ports palette.cloud.promoteTemplate palette.cloud.restore cloudPauseMachine cloudResumeMachine cloudSetIdlePause cloudStartSession | palette.cloud.deleteSnapshot) | [copy] > (cloudCopyMachineID cloudCopyLink cloudCopyPort) [tools] > (cloudFilesList cloudFileRead cloudFileWrite cloudFileMkdir cloudFileRemove cloudFileStat cloudPrepareSCP cloudTunnelAttach cloudTunnelDetach cloudTunnelRotateKey cloudNetworkList cloudFirewallList cloudFirewallGet cloudFirewallCreate cloudFirewallDelete | palette.cloud.tools) | cloudKillMachine - **sshMachine**: remote.newWorkspace | remote.reconnect remote.disconnect remote.install machine.doctor | remote.copyError | remote.forget - **machineSection**: [appearance] > (machine.setIcon machine.clearIcon) | machine.reconnect | remote.removeGroup - **newTab**: newSurface openBrowser.chromium palette.newAgentChat macScreen.open | browserProfile.newTab newTab.page | agentPane.togglePointerInspector @@ -108,7 +108,7 @@ Palette 1154, CLI verbs 689, right-click 574, MCP tools 607. **paletteInternal** (2): `commandPaletteNext`, `commandPalettePrevious` -**devOnly** (10): `home.saveFlightRecording`, `remote.openBrowserTab`, `remote.openLocalBrowserTab`, `openDebugSettings`, `appearance.glass.lens.toggle`, `debug.actionLatencyHUD`, `debug.blockingCalls.reset`, `server.makeThisMacAServer`, `server.stopServing`, `server.showHealth` +**devOnly** (11): `home.saveFlightRecording`, `remote.openBrowserTab`, `remote.openLocalBrowserTab`, `openDebugSettings`, `appearance.glass.lens.toggle`, `debug.actionLatencyHUD`, `debug.blockingCalls.reset`, `debug.autoArchive.age`, `server.makeThisMacAServer`, `server.stopServing`, `server.showHealth` **focusMove** (60): `showHideAllWindows`, `goToWorkspace`, `search.chats`, `showMainWindow`, `nextSidebarTab`, `prevSidebarTab`, `nextSidebarTabInGroup`, `prevSidebarTabInGroup`, `selectWorkspaceByNumber`, `workspace.selectFirst`, `workspace.selectLast`, `workspace.selectLastUsed`, `nextWorkspaceGroup`, `prevWorkspaceGroup`, `space.next`, `space.previous`, `space.selectByNumber`, `focusLeft`, `focusRight`, `focusUp`, `focusDown`, `focusPreviousPane`, `focusNextPane`, `focusPaneByNumber`, `canvasRevealFocusedPane`, `focusLocation`, `navigate.next`, `navigate.previous`, `nextSurface`, `prevSurface`, `selectSurfaceByNumber`, `home.show`, `home.previousConversation`, `home.nextConversation`, `home.selectConversationByNumber`, `home.openConversation`, `screen.next`, `screen.previous`, `screen.select`, `screen.selectLast`, `focusTextBoxInput`, `focusBrowserAddressBar`, `browser.focusSearch`, `focusSidebar`, `focusRightSidebar`, `vaultFocusSession`, `jumpToUnread`, `jumpToLastPrompt`, `markOldestUnreadAndJumpNext`, `notificationOpen`, `computerUseFocus`, `computerUseFocusCallingTerminal`, `column.focusLeft`, `column.focusRight`, `focusHistoryBack`, `focusHistoryForward`, `focusHistoryLast`, `history.goTo`, `chief.openSubagent`, `sidebar.section.toggleCollapsed` @@ -140,7 +140,7 @@ Palette 1154, CLI verbs 689, right-click 574, MCP tools 607. **guiOnly** (38): `browser.media.show`, `browser.downloads.show`, `browser.profile.choose`, `browser.overflow.menu`, `browser.download.openBlockedSiteSettings`, `agentPane.stopTurn`, `agentPane.continueIn`, `agentPane.switchModel`, `agentPane.createCheckpoint`, `agentPane.searchFiles`, `agentPane.copyLastCodeBlock`, `agentPane.permission.allowOnce`, `agentPane.permission.allowChat`, `agentPane.permission.deny`, `agentPane.permission.expand`, `agentPane.permission.retry`, `agentPane.permission.revoke`, `agentPane.permission.refresh`, `agentPane.toggleDisplaySettings`, `agentPane.replayConversation`, `agentPane.editInstructions`, `agentPane.addProjectFiles`, `agentPane.markUnread`, `agentPane.moveToFloatingChat`, `agentPane.toggleMinimap`, `agentPane.revealCwdInFinder`, `agentPane.openCwdInTerminal`, `agentPane.previousTurn`, `agentPane.nextTurn`, `agentPane.previousBookmarkedPrompt`, `agentPane.nextBookmarkedPrompt`, `agentChat.copyName`, `agentChat.copyHarnessSessionID`, `agentChat.copyConversation`, `agentChat.copySessionDetails`, `agentChat.copyWorkingDirectory`, `agentChat.copyHost`, `sidebar.priorityView.toggle` -**noObject** (278): `openSettings`, `newWindow`, `newIncognitoWindow`, `closeWindow`, `window.closeOthers`, `closeAllWindows`, `zoomWindow`, `selectNextWindow`, `selectPreviousWindow`, `minimizeWindow`, `toggleFullScreen`, `quit`, `quitKeepSessions`, `quitEndSessions`, `quitEndEverything`, `globalSearch`, `palette.open`, `commandPalette`, `palette.openTaskManager`, `palette.sleepyMode`, `keepMacAwake`, `about`, `workspace.closeAllInAllWindows`, `manageLayouts`, `palette.findWork`, `workspace.importClassic`, `palette.openFilesPane`, `palette.openFindPane`, `palette.openVaultPane`, `palette.openCloudPane`, `macScreen.stopSharing`, `macScreen.revokeViewer`, `reopenClosedBrowserPanel`, `home.show`, `home.openChief`, `home.tab`, `home.channels`, `home.switchView`, `home.enableChannels`, `home.disableChannels`, `home.toggleChiefPet`, `home.messageChiefPet`, `home.messageChiefFromAnyApp`, `home.attachFiles`, `home.newMessage`, `home.invite`, `home.newChief`, `home.createGroup`, `home.stopAllAgents`, `home.saveFlightRecording`, `home.messageTo`, `palette.browserClearHistory`, `importFromBrowser`, `password.importCSV`, `passwords.open`, `palette.enableBrowser`, `palette.disableBrowser`, `browser.allowAgentWithExtensions`, `browser.importFromFiles`, `browser.importCookies`, `screenshots.open`, `fileEditor.importEditorSettings`, `sidebar.profileMenu`, `browser.downloads.showFolder`, `toggleRightSidebar`, `switchRightSidebarToFiles`, `switchRightSidebarToFind`, `switchRightSidebarToSessions`, `switchRightSidebarToFeed`, `switchRightSidebarToDock`, `switchRightSidebarToMachines`, `palette.toggleMatchTerminalBackground`, `feed.show`, `showNotifications`, `markAllNotificationsRead`, `clearAllNotifications`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `palette.quickAgentChat`, `palette.startAgentFromAnyApp`, `palette.addHarness`, `stats.show`, `palette.openTerminalChatView`, `palette.launchClaudeTeams`, `palette.launchCodexTeams`, `palette.computerUse.setup`, `palette.computerUse.accessibility`, `palette.computerUse.screenRecording`, `agent.openSessionWorkspace`, `agent.openSessionTab`, `home.toggleChiefSettings`, `home.stopChief`, `chief.openMemoryInspector`, `chief.toggleMemoryInspector`, `chief.moveTo`, `chief.importMemories`, `chief.importMemories.undo`, `chief.openUsage`, `chief.openViews`, `agentActivity.open`, `computerUseStop`, `agentChat.view`, `agent.harness.add`, `agent.harness.addFromRegistry`, `agent.harness.remove`, `agent.harness.restore`, `agent.harness.doctor`, `agent.harness.updateAll`, `newCloudMachine`, `cloudDiagnostics`, `openTeamPicker`, `palette.auth.signIn`, `palette.auth.signOut`, `palette.mobileConnect`, `accounts.show`, `accounts.refresh`, `accounts.reauthenticate`, `accounts.connect`, `accounts.remove`, `machine.add`, `remote.openBrowserTab`, `remote.openLocalBrowserTab`, `reloadConfiguration`, `palette.openCmuxSettingsFile`, `palette.openGhosttySettings`, `ghostty.showDiagnostics`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.searchShortcuts`, `keybindings.open`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.restartServer`, `palette.checkForUpdates`, `updates.whatsNew`, `announcements.show`, `announcements.hide`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `updates.rollback`, `palette.pro.upgrade`, `sendFeedback`, `help.showCrashLogs`, `help.featureFlags`, `help.documentation`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `appearance.animations.newTab.toggle`, `appearance.animations.newWorkspace.toggle`, `appearance.animations.toggle`, `appearance.animations.sidebar.toggle`, `appearance.animations.tabs.toggle`, `appearance.animations.panes.toggle`, `appearance.animations.overlays.toggle`, `appearance.animations.hoverCards.toggle`, `appearance.animations.home.toggle`, `appearance.animations.cursor.toggle`, `appearance.animations.indicators.toggle`, `appearance.animations.feedback.toggle`, `appearance.animations.web.toggle`, `appearance.animations.bounce.subtle`, `appearance.animations.bounce.none`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `tabs.overflow.scroll`, `tabs.overflow.shrink`, `links.openInCmuxBrowser`, `links.openInDefaultBrowser`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.glass.lens.toggle`, `debug.actionLatencyHUD`, `debug.blockingCalls.reset`, `appearance.customize`, `appearance.density.ultraCompact`, `appearance.density.compact`, `appearance.density.cozy`, `appearance.density.comfortable`, `appearance.density.spacious`, `appearance.density.set`, `appearance.density.create`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `appearance.chromeStyle.floating`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `browser.keepNeighborTabsAwake.0`, `browser.keepNeighborTabsAwake.1`, `browser.keepNeighborTabsAwake.2`, `browser.keepNeighborTabsAwake.3`, `layout.toggleStripScrollbar`, `recentlyFocused`, `recentlyClosed`, `history.commands`, `history.show`, `agentHistory.show`, `browserShowHistory`, `history.search`, `history.resumeAgentSession`, `history.reopen`, `history.open`, `history.clear`, `layout.undo`, `stash.show`, `stash.importOneTab`, `music.open`, `music.stop`, `appStore.showInstalled`, `app.hide`, `app.unhide`, `app.open`, `app.command.run`, `tasks.show`, `tasks.new`, `tasks.showMine`, `phone.show`, `phone.boot`, `phone.screenshot`, `phone.recordStart`, `phone.recordStop`, `phone.sendFeedback`, `phone.openURL`, `phone.paste`, `phone.list`, `phone.shutdown`, `phone.tap`, `phone.swipe`, `phone.type`, `phone.button`, `phone.ui`, `phone.appearance`, `phone.location`, `phone.locale`, `phone.install`, `phone.launch`, `phone.rotate`, `phone.logs`, `files.open`, `link.open`, `app.shareCmux`, `server.makeThisMacAServer`, `server.stopServing`, `server.addServer`, `server.showPanel`, `server.showHealth`, `import.list`, `import.card`, `import.suggest`, `import.open`, `diagnostics.viewHangReport` +**noObject** (279): `openSettings`, `newWindow`, `newIncognitoWindow`, `closeWindow`, `window.closeOthers`, `closeAllWindows`, `zoomWindow`, `selectNextWindow`, `selectPreviousWindow`, `minimizeWindow`, `toggleFullScreen`, `quit`, `quitKeepSessions`, `quitEndSessions`, `quitEndEverything`, `globalSearch`, `palette.open`, `commandPalette`, `palette.openTaskManager`, `palette.sleepyMode`, `keepMacAwake`, `about`, `workspace.closeAllInAllWindows`, `manageLayouts`, `palette.findWork`, `workspace.importClassic`, `palette.openFilesPane`, `palette.openFindPane`, `palette.openVaultPane`, `palette.openCloudPane`, `macScreen.stopSharing`, `macScreen.revokeViewer`, `reopenClosedBrowserPanel`, `home.show`, `home.openChief`, `home.tab`, `home.channels`, `home.switchView`, `home.enableChannels`, `home.disableChannels`, `home.toggleChiefPet`, `home.messageChiefPet`, `home.messageChiefFromAnyApp`, `home.attachFiles`, `home.newMessage`, `home.invite`, `home.newChief`, `home.createGroup`, `home.stopAllAgents`, `home.saveFlightRecording`, `home.messageTo`, `palette.browserClearHistory`, `importFromBrowser`, `password.importCSV`, `passwords.open`, `palette.enableBrowser`, `palette.disableBrowser`, `browser.allowAgentWithExtensions`, `browser.importFromFiles`, `browser.importCookies`, `screenshots.open`, `fileEditor.importEditorSettings`, `sidebar.profileMenu`, `browser.downloads.showFolder`, `toggleRightSidebar`, `switchRightSidebarToFiles`, `switchRightSidebarToFind`, `switchRightSidebarToSessions`, `switchRightSidebarToFeed`, `switchRightSidebarToDock`, `switchRightSidebarToMachines`, `palette.toggleMatchTerminalBackground`, `feed.show`, `showNotifications`, `markAllNotificationsRead`, `clearAllNotifications`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `palette.quickAgentChat`, `palette.startAgentFromAnyApp`, `palette.addHarness`, `stats.show`, `palette.openTerminalChatView`, `palette.launchClaudeTeams`, `palette.launchCodexTeams`, `palette.computerUse.setup`, `palette.computerUse.accessibility`, `palette.computerUse.screenRecording`, `agent.openSessionWorkspace`, `agent.openSessionTab`, `home.toggleChiefSettings`, `home.stopChief`, `chief.openMemoryInspector`, `chief.toggleMemoryInspector`, `chief.moveTo`, `chief.importMemories`, `chief.importMemories.undo`, `chief.openUsage`, `chief.openViews`, `agentActivity.open`, `computerUseStop`, `agentChat.view`, `agent.harness.add`, `agent.harness.addFromRegistry`, `agent.harness.remove`, `agent.harness.restore`, `agent.harness.doctor`, `agent.harness.updateAll`, `newCloudMachine`, `cloudDiagnostics`, `openTeamPicker`, `palette.auth.signIn`, `palette.auth.signOut`, `palette.mobileConnect`, `accounts.show`, `accounts.refresh`, `accounts.reauthenticate`, `accounts.connect`, `accounts.remove`, `machine.add`, `remote.openBrowserTab`, `remote.openLocalBrowserTab`, `reloadConfiguration`, `palette.openCmuxSettingsFile`, `palette.openGhosttySettings`, `ghostty.showDiagnostics`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.searchShortcuts`, `keybindings.open`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.restartServer`, `palette.checkForUpdates`, `updates.whatsNew`, `announcements.show`, `announcements.hide`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `updates.rollback`, `palette.pro.upgrade`, `sendFeedback`, `help.showCrashLogs`, `help.featureFlags`, `help.documentation`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `appearance.animations.newTab.toggle`, `appearance.animations.newWorkspace.toggle`, `appearance.animations.toggle`, `appearance.animations.sidebar.toggle`, `appearance.animations.tabs.toggle`, `appearance.animations.panes.toggle`, `appearance.animations.overlays.toggle`, `appearance.animations.hoverCards.toggle`, `appearance.animations.home.toggle`, `appearance.animations.cursor.toggle`, `appearance.animations.indicators.toggle`, `appearance.animations.feedback.toggle`, `appearance.animations.web.toggle`, `appearance.animations.bounce.subtle`, `appearance.animations.bounce.none`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `tabs.overflow.scroll`, `tabs.overflow.shrink`, `links.openInCmuxBrowser`, `links.openInDefaultBrowser`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.glass.lens.toggle`, `debug.actionLatencyHUD`, `debug.blockingCalls.reset`, `appearance.customize`, `appearance.density.ultraCompact`, `appearance.density.compact`, `appearance.density.cozy`, `appearance.density.comfortable`, `appearance.density.spacious`, `appearance.density.set`, `appearance.density.create`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `appearance.chromeStyle.floating`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `browser.keepNeighborTabsAwake.0`, `browser.keepNeighborTabsAwake.1`, `browser.keepNeighborTabsAwake.2`, `browser.keepNeighborTabsAwake.3`, `debug.autoArchive.age`, `layout.toggleStripScrollbar`, `recentlyFocused`, `recentlyClosed`, `history.commands`, `history.show`, `agentHistory.show`, `browserShowHistory`, `history.search`, `history.resumeAgentSession`, `history.reopen`, `history.open`, `history.clear`, `layout.undo`, `stash.show`, `stash.importOneTab`, `music.open`, `music.stop`, `appStore.showInstalled`, `app.hide`, `app.unhide`, `app.open`, `app.command.run`, `tasks.show`, `tasks.new`, `tasks.showMine`, `phone.show`, `phone.boot`, `phone.screenshot`, `phone.recordStart`, `phone.recordStop`, `phone.sendFeedback`, `phone.openURL`, `phone.paste`, `phone.list`, `phone.shutdown`, `phone.tap`, `phone.swipe`, `phone.type`, `phone.button`, `phone.ui`, `phone.appearance`, `phone.location`, `phone.locale`, `phone.install`, `phone.launch`, `phone.rotate`, `phone.logs`, `files.open`, `link.open`, `app.shareCmux`, `server.makeThisMacAServer`, `server.stopServing`, `server.addServer`, `server.showPanel`, `server.showHealth`, `import.list`, `import.card`, `import.suggest`, `import.open`, `diagnostics.viewHangReport` **noTargetSurface** (96): `window.screenshot`, `taskManager.killProcess`, `toggleChecklistItemComplete`, `canvasOverview`, `canvasTidy`, `canvasAlignLeft`, `canvasAlignRight`, `canvasAlignTop`, `canvasAlignBottom`, `canvasEqualizeWidths`, `canvasEqualizeHeights`, `canvasDistributeHorizontally`, `canvasDistributeVertically`, `simulatorHome`, `simulatorRotateLeft`, `simulatorRotateRight`, `simulatorToggleAppearance`, `simulatorToggleSoftwareKeyboard`, `file.open`, `home.archiveChief`, `home.deleteChief`, `home.conversationInfo`, `home.renameConversation`, `home.addMembers`, `home.removeMember`, `home.leaveConversation`, `home.muteConversation`, `home.unmuteConversation`, `home.setChiefReplies`, `home.togglePin`, `home.sendMessage`, `tabGroup.deleteSaved`, `tabGroup.reopenSaved`, `screenGroup.reopenSaved`, `screenGroup.deleteSaved`, `resumeCommandSet`, `resumeCommandEdit`, `resumeCommandClear`, `saveFilePreview`, `toggleFileEditorWordWrap`, `filePreviewOpenWith`, `filePreviewOpenExternally`, `filePreviewRevealInFinder`, `filePreviewRevealInFiles`, `diffViewerNextLine`, `diffViewerPreviousLine`, `diffViewerHalfPageDown`, `diffViewerHalfPageUp`, `diffViewerNextHunk`, `diffViewerPreviousHunk`, `diffViewerGoToBottom`, `diffViewerGoToTop`, `diffViewerSearch`, `diffViewerNextFile`, `diffViewerPreviousFile`, `palette.vscodeServeWebStop`, `palette.vscodeServeWebRestart`, `browser.openOnMachine`, `openDiffViewer`, `palette.openDirectoryDiffViewer`, `openMarkdownFile`, `fileEditorGotoLine`, `fileEditorReplace`, `fileEditorAction`, `browser.certificateWarning.proceed`, `browser.certificateWarning.goBack`, `browserProfile.openLink`, `fileExplorerOpenSelection`, `fileExplorerOpenSelectionFinderAlias`, `vaultOpenSession`, `vaultResumeInNewWorkspace`, `vaultCopyResumeCommand`, `vaultOpenPullRequest`, `checklistEditItem`, `checklistMarkInProgress`, `checklistCompleteItem`, `checklistRemoveItem`, `checklistOpenAsPane`, `checklistAttachImages`, `agentPane.git.commit`, `agentPane.git.push`, `agentPane.searchChats`, `agentPane.setModel`, `agentPane.setEffort`, `agentPane.setMode`, `cloudExec`, `cloudDomainList`, `cloudPublicationList`, `cloudListMachines`, `markdownSave`, `markdownLink`, `markdownBack`, `markdownForward`, `markdownReload`, `markdownResetTableWidths`, `markdownGoToTop` diff --git a/web/data/next-docs/cli-reference.generated.json b/web/data/next-docs/cli-reference.generated.json index 12a9b6f0721d..612ce4820e45 100644 --- a/web/data/next-docs/cli-reference.generated.json +++ b/web/data/next-docs/cli-reference.generated.json @@ -1,8 +1,8 @@ { "version": 1, "counts": { - "verbs": 689, - "mcp": 607 + "verbs": 693, + "mcp": 611 }, "nouns": [ { @@ -1120,6 +1120,12 @@ "id": "cloudSSH", "mcp": true }, + { + "command": "cmux cloud start-session", + "title": "Start Agent Session on Cloud Machine…", + "id": "cloudStartSession", + "mcp": true + }, { "command": "cmux cloud tunnel-attach", "title": "Attach Cloud Tunnel Network", @@ -3752,6 +3758,12 @@ "id": "palette.addWorkspaceChecklistItem", "mcp": true }, + { + "command": "cmux workspace archive-idle", + "title": "Archive Idle Workspaces", + "id": "workspace.archiveIdle", + "mcp": true + }, { "command": "cmux workspace archive-unarchive", "title": "Archive/Unarchive Workspace", @@ -4046,6 +4058,12 @@ "id": "palette.resetWorkspaceColor", "mcp": true }, + { + "command": "cmux workspace restore-archived", + "title": "Restore Archived Workspace…", + "id": "workspace.restoreArchived", + "mcp": true + }, { "command": "cmux workspace restore-previous-app-launch", "title": "Restore Previous App Launch", @@ -4303,6 +4321,12 @@ "id": "workspaceGroup.rename", "mcp": true }, + { + "command": "cmux workspace-group set-auto-archive", + "title": "Auto-Archive Group Workspaces", + "id": "workspaceGroup.setAutoArchive", + "mcp": true + }, { "command": "cmux workspace-group set-color", "title": "Set Workspace Group Color…", From 43e9ba14abd7a9a37d20fda9d5abf33b8684966d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 25 Oct 2026 23:16:48 -0700 Subject: [PATCH 10/33] cloud start-session: test the unread-list refusal end to end and one creation per name Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CloudStartSessionTests.swift | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index a9b6f404b06f..c5f29735c946 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -181,19 +181,51 @@ struct CloudStartSessionTests { // MARK: A machine that can't be created - @Test func aMachineThatCannotBeCreatedSaysSoWithTheCommand() async throws { + /// With no machine list read (no Cloud sign-in), `--name` refuses rather than make a second + /// machine of a name that may exist, and says what to check and run. + @Test func aNameWithAnUnreadMachineListCreatesNothingAndSaysWhatToCheck() async throws { let services = Self.fixture().services let name = "fresh-\(UUID().uuidString.prefix(6).lowercased())" let invoke = try #require(services.registry.action(for: CloudStartSession.action)?.invoke) let run = services.registry.capturingRun { invoke(Self.invocation(["agent": .string("claude"), "name": .string(name)])) } let task = try #require(run.results.first, "start-session tracked no result") guard case .failure(let failure) = await task.value else { - Issue.record("a create with no Cloud sign-in went through") + Issue.record("a --name run with no machine list went through") return } - #expect(failure.message.hasPrefix("Couldn't create Cloud machine `\(name)` ("), "\(failure.message)") + #expect(failure.message.hasPrefix("Couldn't read your Cloud machines, so claude didn't start: a machine named \(name) may already exist."), "\(failure.message)") + #expect(failure.message.contains("`cmux cloud list-machines`")) #expect(failure.message.contains("`cmux cloud start-session --name \(name) --agent claude`")) - #expect(!failure.message.contains(".)")) + #expect(!services.machines.cloud.contains { $0.machine.title == name }) + } + + /// Two asks for one new name share one creation. + @Test func twoRunsForOneNewNameCreateOnce() async throws { + let f = Self.fixture() + let runs = CloudStartSessionRuns() + let (gate, open) = AsyncStream.makeStream() + let (entered, enter) = AsyncStream.makeStream() + defer { open.finish(); enter.finish() } + var creates = 0 + let create: @MainActor () async throws -> CloudMachineSession = { + creates += 1 + enter.yield() + for await _ in gate { break } + return f.session + } + let first = Task { @MainActor in try await runs.creating("dev", create) } + for await _ in entered { break } + let second = Task { @MainActor in try await runs.creating("dev", create) } + // The main actor runs `second` up to its wait on the first creation before this resumes. + await Task.yield() + await Task.yield() + // Two gate values: a second creation (the bug) gets its own and fails the count, never hangs. + open.yield() + open.yield() + let one = try await first.value + let two = try await second.value + #expect(creates == 1) + #expect(one === f.session && two === f.session) } @Test func theRetryCommandKeepsTheNameOneShellWord() { From b7ae8de998c805aca48953dc3c522a4805f7e9d3 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 25 Oct 2026 23:17:13 -0700 Subject: [PATCH 11/33] cloud start-session: count creations in a Box Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../Tests/CmuxNextAppTests/CloudStartSessionTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index c5f29735c946..71f1ccac5173 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -206,9 +206,9 @@ struct CloudStartSessionTests { let (gate, open) = AsyncStream.makeStream() let (entered, enter) = AsyncStream.makeStream() defer { open.finish(); enter.finish() } - var creates = 0 + let creates = Box(0) let create: @MainActor () async throws -> CloudMachineSession = { - creates += 1 + creates.value += 1 enter.yield() for await _ in gate { break } return f.session @@ -224,7 +224,7 @@ struct CloudStartSessionTests { open.yield() let one = try await first.value let two = try await second.value - #expect(creates == 1) + #expect(creates.value == 1) #expect(one === f.session && two === f.session) } From 8c0c22af52c29f09aa67e6f77bb8bd4b3009f9a9 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 25 Oct 2026 23:37:49 -0700 Subject: [PATCH 12/33] cloud start-session: --name needs this account's list read just now; runs and creations are per account and end with it Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextApp/Cloud/CloudService.swift | 20 ++++ .../CmuxNextApp/Cloud/CloudStartSession.swift | 16 ++-- .../Cloud/CloudStartSessionAsks.swift | 48 +++++++--- .../CloudStartSessionTests.swift | 96 +++++++++++++++---- 4 files changed, 143 insertions(+), 37 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift index 208c4cbb4242..2b05a2c175ff 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift @@ -80,6 +80,8 @@ final class CloudService { @ObservationIgnored let startSessionAsks = CloudStartSessionAsks() /// `cmux cloud start-session` runs, joined by a repeat of the same ask (``CloudStartSessionRuns``). @ObservationIgnored let startSessionRuns = CloudStartSessionRuns() + /// When the loaded list landed (``hasCurrentList(within:)``). + @ObservationIgnored private var listLoadedAt: ContinuousClock.Instant? /// Moved chats' file requests for this Mac, asked in their chats. @ObservationIgnored let accessAsks = CloudAccessAsks() /// Files the person drops or pastes into a moved chat, sent to its machine (``CloudUploads``). @@ -351,6 +353,7 @@ final class CloudService { reconcile(list) hasLoadedMachines = true loadedAccount = account + listLoadedAt = .now } catch { guard listGeneration == generation, auth.isSignedIn, listAccount == account else { return } lastError = CloudStrings.errorText(error) @@ -368,6 +371,20 @@ final class CloudService { /// client sends (``CloudAuth/teamID``). private var listAccount: [String?] { [auth.user?.id, auth.teamID] } + /// The user and team signed in now (``CloudStartSessionRuns`` keeps runs per account). + var account: [String?] { listAccount } + + /// Whether the store holds a list the server answered, within `age`, for the user and team + /// signed in now: a failed or superseded refresh, or another team's list, is not one. + func hasCurrentList(within age: Duration) -> Bool { + Self.listIsCurrent(loadedAt: hasLoadedMachines ? listLoadedAt : nil, loadedFor: loadedAccount, account: listAccount, now: .now, within: age) + } + + nonisolated static func listIsCurrent(loadedAt: ContinuousClock.Instant?, loadedFor: [String?]?, account: [String?], now: ContinuousClock.Instant, within age: Duration) -> Bool { + guard let loadedAt, loadedFor == account else { return false } + return loadedAt.duration(to: now) < age + } + private func reconcile(_ list: [CloudMachine]) { // A list that arrives after Cloud was turned off connects nothing. guard !policyDisabled else { return } @@ -428,6 +445,9 @@ final class CloudService { for session in machines.cloud { machines.remove(session.machineID)?.disconnect() } machines.cloudLimits = nil hasLoadedMachines = false + listLoadedAt = nil + // Start-session runs and creations of the account that ended end with it. + startSessionRuns.forgetAll() } // MARK: Mutations diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift index b159c483f2df..8ab1739feb83 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift @@ -56,7 +56,7 @@ struct CloudStartSession { } if request.machineID == nil, request.name == nil { throw ActionFailure(message: CloudStartSessionStrings.needsMachine(agent: request.agent)) } CloudHandlers.result("start cloud session", context) { - try await runs.answer(CloudStartSessionRuns.Key(request, caller: nil), within: Self.answerWithin, pending: Self.pending(request, services)) { + try await runs.answer(CloudStartSessionRuns.Key(request, caller: nil, account: services.cloud.account), within: Self.answerWithin, pending: Self.pending(request, services)) { try await start.run(request, invocation, context) } } @@ -87,7 +87,7 @@ struct CloudStartSession { var asPerson = ActionInvocation(target: invocation.target, arguments: invocation.arguments, origin: .user) asPerson.callerAgentSession = nil // The same ask again (the agent's run answered pending) joins the one that runs. - let key = CloudStartSessionRuns.Key(request, caller: caller) + let key = CloudStartSessionRuns.Key(request, caller: caller, account: services.cloud.account) CloudHandlers.result("start cloud session", context) { try await runs.answer(key, within: Self.answerWithin, pending: Self.pending(request, services)) { let token = try await asks.ask(tab: tab.id, text: text, asked: asked, services: services) @@ -216,6 +216,9 @@ struct CloudStartSession { return text } + /// How recent the machine list must be before `--name` takes or creates a machine. + static let listFresh: Duration = .seconds(15) + /// The machine named by id, else by name, else a new one with that name. private func machine(_ request: Request, _ invocation: ActionInvocation, askedBy: String?) async throws -> CloudMachineSession { let machines = services.machines @@ -225,12 +228,13 @@ struct CloudStartSession { } let name = request.name ?? "" let cloud = services.cloud - // A list read just now: a machine by this name made elsewhere is taken, never made again. - await cloud.refresh(ifOlderThan: .seconds(15)) + // A list the server answered just now for this account: a machine by this name made + // elsewhere is taken, never made again. A failed or superseded read refuses. + if !cloud.hasCurrentList(within: Self.listFresh) { await cloud.refresh() } + guard cloud.hasCurrentList(within: Self.listFresh) else { throw ActionFailure(message: CloudStartSessionStrings.listUnread(request.asked(machine: name))) } if let named = machines.cloud.first(where: { $0.machine.title == name }) { return named } - guard cloud.hasLoadedMachines else { throw ActionFailure(message: CloudStartSessionStrings.listUnread(request.asked(machine: name))) } do { - return try await cloud.startSessionRuns.creating(name) { [machines] in + return try await cloud.startSessionRuns.creating(name, account: cloud.account) { [machines] in // Another ask's creation may have ended first. if let named = machines.cloud.first(where: { $0.machine.title == name }) { return named } return try await cloud.createMachine(name: name, startedByPerson: invocation.origin == .user, askedBy: askedBy ?? invocation.callerAgentSession) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift index 0973a5ba50ab..15cbb106e452 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift @@ -92,7 +92,9 @@ final class CloudStartSessionAsks { /// goes on waits for that run. A run that ends with a caller still waiting has reached it and is /// dropped; one that ends after every caller answered `pending` is kept for the next same ask, /// which gets its end once. A kept run lasts at most ``kept`` (checked on every ask) and at most -/// ``limit`` are kept, trimmed when a run starts and when one is kept; no timer. +/// ``limit`` are kept, trimmed when a run starts and when one is kept; no timer. Runs and +/// creations are per account (user and team): another account's ask never joins or takes them, +/// and a sign-out or account change forgets them (``forgetAll()``). @MainActor final class CloudStartSessionRuns { struct Key: Hashable { @@ -101,15 +103,23 @@ final class CloudStartSessionRuns { var cwd: String? /// The agent chat that asked; nil for a person's or a script's run. var caller: String? + /// The user and team signed in when it was asked (``CloudService/account``). + var account: [String?] - init(_ request: CloudStartSession.Request, caller: String?) { + init(_ request: CloudStartSession.Request, caller: String?, account: [String?]) { machine = request.machineID.map { "id:" + $0 } ?? "name:" + (request.name ?? "") agent = request.agent cwd = request.cwd self.caller = caller + self.account = account } } + private struct Creation: Hashable { + var name: String + var account: [String?] + } + typealias Fields = [String: ActionResultValue] typealias Outcome = Result @@ -125,18 +135,34 @@ final class CloudStartSessionRuns { static let kept: Duration = .seconds(600) static let limit = 32 private var runs: [Key: Run] = [:] - /// Creations of a machine by name: two asks for one new name create one machine. - private var creations: [String: Task] = [:] - - /// The machine `create` makes for `name`, or the one a creation already going on makes. - func creating(_ name: String, _ create: @escaping @MainActor () async throws -> CloudMachineSession) async throws -> CloudMachineSession { - if let running = creations[name] { return try await running.value } - // task-owner: one creation per name; removed when it ends. + /// Creations of a machine by name and account: two asks for one new name create one machine. + private var creations: [Creation: Task] = [:] + /// Called when an ask joins a creation already going on (tests wait on it). + var joinedCreation: @MainActor (String) -> Void = { _ in } + + /// The machine `create` makes for `name` in `account`, or the one a creation already going on + /// for both makes. + func creating(_ name: String, account: [String?], _ create: @escaping @MainActor () async throws -> CloudMachineSession) async throws -> CloudMachineSession { + let key = Creation(name: name, account: account) + if let running = creations[key] { + joinedCreation(name) + return try await running.value + } + // task-owner: one creation per name and account; removed when it ends or by forgetAll(). let task = Task { @MainActor in try await create() } - creations[name] = task - defer { if creations[name] == task { creations[name] = nil } } + creations[key] = task + defer { if creations[key] == task { creations[key] = nil } } return try await task.value } + + /// Forgets every run and creation: the account they were asked in ended. Runs going on are + /// cancelled; their callers get their end (a cancellation) or have already answered pending. + func forgetAll() { + for run in runs.values { run.task?.cancel() } + for creation in creations.values { creation.cancel() } + runs = [:] + creations = [:] + } var now: @MainActor () -> ContinuousClock.Instant = { ContinuousClock.now } /// The caller's wait: a one-shot sleep, cancelled when the run ends first. var sleep: @MainActor (Duration) async -> Void = { duration in diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index 71f1ccac5173..79b46d4cfc86 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -18,6 +18,8 @@ struct CloudStartSessionTests { return (services, MachineIconTests.cloudMachine(services, id: "vm-start-\(UUID().uuidString.prefix(8))")) } + static let account: [String?] = ["user-1", "team-1"] + static let asked = CloudStartSession.Request(machineID: "vm-x", name: nil, agent: "claude", cwd: nil).asked(machine: "dev") static func invocation(target: String? = nil, _ arguments: [String: ActionValue], origin: ActionOrigin = .cli, agent: String? = nil) -> ActionInvocation { @@ -126,7 +128,7 @@ struct CloudStartSessionTests { runs.sleep = { _ in } let (gate, open) = AsyncStream.makeStream() defer { open.finish() } - let answered = try await runs.answer(CloudStartSessionRuns.Key(request, caller: nil), within: CloudStartSession.answerWithin, pending: pending) { + let answered = try await runs.answer(CloudStartSessionRuns.Key(request, caller: nil, account: Self.account), within: CloudStartSession.answerWithin, pending: pending) { for await _ in gate { break } return ["session_id": .string("s")] } @@ -199,33 +201,87 @@ struct CloudStartSessionTests { #expect(!services.machines.cloud.contains { $0.machine.title == name }) } - /// Two asks for one new name share one creation. + /// Two asks for one new name share one creation; another account's ask makes its own. @Test func twoRunsForOneNewNameCreateOnce() async throws { let f = Self.fixture() let runs = CloudStartSessionRuns() - let (gate, open) = AsyncStream.makeStream() - let (entered, enter) = AsyncStream.makeStream() - defer { open.finish(); enter.finish() } + let (joins, join) = AsyncStream.makeStream() + runs.joinedCreation = { join.yield($0) } + // One gate per creation, each finished at the end: a second creation (the bug) never shares one. + var gates: [AsyncStream.Continuation] = [] + defer { for gate in gates { gate.finish() }; join.finish() } + let (entered, enter) = AsyncStream.makeStream() + defer { enter.finish() } let creates = Box(0) - let create: @MainActor () async throws -> CloudMachineSession = { - creates.value += 1 - enter.yield() - for await _ in gate { break } - return f.session + func create() -> @MainActor () async throws -> CloudMachineSession { + let (gate, open) = AsyncStream.makeStream() + gates.append(open) + return { + creates.value += 1 + enter.yield(creates.value) + for await _ in gate { break } + return f.session + } } - let first = Task { @MainActor in try await runs.creating("dev", create) } + let first = Task { @MainActor in try await runs.creating("dev", account: Self.account, create()) } for await _ in entered { break } - let second = Task { @MainActor in try await runs.creating("dev", create) } - // The main actor runs `second` up to its wait on the first creation before this resumes. - await Task.yield() - await Task.yield() - // Two gate values: a second creation (the bug) gets its own and fails the count, never hangs. - open.yield() - open.yield() + let second = Task { @MainActor in try await runs.creating("dev", account: Self.account, create()) } + for await name in joins { #expect(name == "dev"); break } + gates[0].yield() let one = try await first.value let two = try await second.value #expect(creates.value == 1) #expect(one === f.session && two === f.session) + // Another team's ask for the same name is its own creation. + let other = Task { @MainActor in try await runs.creating("dev", account: ["user-1", "team-2"], create()) } + for await count in entered { #expect(count == 2); break } + gates[2].yield() + _ = try await other.value + #expect(creates.value == 2) + } + + /// Another account's same ask never takes a kept run's end, and a sign-out forgets them all. + @Test func runsArePerAccountAndASignOutForgetsThem() async throws { + let runs = CloudStartSessionRuns() + let request = CloudStartSession.Request(machineID: nil, name: "dev", agent: "claude", cwd: nil) + let mine = CloudStartSessionRuns.Key(request, caller: nil, account: Self.account) + let theirs = CloudStartSessionRuns.Key(request, caller: nil, account: ["user-2", nil]) + #expect(mine != theirs) + let (gate, open) = AsyncStream.makeStream() + defer { open.finish() } + runs.sleep = { _ in } + // Mine answers pending, then ends with nobody waiting: kept for my next ask. + _ = try await runs.answer(mine, within: .seconds(1), pending: Self.pending) { + for await _ in gate { break } + return ["session_id": .string("mine")] + } + open.yield() + await runs.settled(mine) + #expect(runs.keptCount == 1) + runs.sleep = Self.waits + let started = Box(0) + let fields = try await runs.answer(theirs, within: .seconds(190), pending: Self.pending) { + started.value += 1 + return ["session_id": .string("theirs")] + } + #expect(fields["session_id"] == .string("theirs")) + #expect(started.value == 1, "another account's ask ran its own start") + #expect(runs.keptCount == 1, "mine is still kept for me") + runs.forgetAll() + #expect(runs.count == 0) + } + + /// `--name` takes or creates only from a list the server answered just now for this account. + @Test func onlyAFreshListOfThisAccountIsCurrent() { + let now = ContinuousClock.now + let account = Self.account + #expect(CloudService.listIsCurrent(loadedAt: now - .seconds(5), loadedFor: account, account: account, now: now, within: CloudStartSession.listFresh)) + // A failed or superseded refresh leaves the last landing time: an old list is not current. + #expect(!CloudService.listIsCurrent(loadedAt: now - .seconds(60), loadedFor: account, account: account, now: now, within: CloudStartSession.listFresh)) + // Never loaded, or dropped at a sign-out. + #expect(!CloudService.listIsCurrent(loadedAt: nil, loadedFor: account, account: account, now: now, within: CloudStartSession.listFresh)) + // A team switch keeps the user: the previous team's list is not this team's. + #expect(!CloudService.listIsCurrent(loadedAt: now, loadedFor: ["user-1", "team-0"], account: account, now: now, within: CloudStartSession.listFresh)) } @Test func theRetryCommandKeepsTheNameOneShellWord() { @@ -308,7 +364,7 @@ struct CloudStartSessionTests { init(_ value: T) { self.value = value } } - static let key = CloudStartSessionRuns.Key(CloudStartSession.Request(machineID: nil, name: "dev", agent: "codex", cwd: nil), caller: "chat-9") + static let key = CloudStartSessionRuns.Key(CloudStartSession.Request(machineID: nil, name: "dev", agent: "codex", cwd: nil), caller: "chat-9", account: Self.account) static let pending: CloudStartSessionRuns.Fields = ["pending": .bool(true)] /// A caller's wait that never ends by itself (the run's end cancels it). static let waits: @MainActor (Duration) async -> Void = { _ in try? await Task.sleep(for: .seconds(3600)) } @@ -416,7 +472,7 @@ struct CloudStartSessionTests { let runs = CloudStartSessionRuns() runs.sleep = { _ in } let total = CloudStartSessionRuns.limit + 8 - let keys = (0...makeStream() } defer { for gate in gates { gate.continuation.finish() } } From cc14c9fe4e923f4e381398bd2f78aae1794f97bb Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 00:37:48 -0700 Subject: [PATCH 13/33] cloud start-session: a run checks the account it was asked in before every step and after every wait; dedup tests can't hang Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextApp/Cloud/CloudStartSession.swift | 48 +++++++++++-- .../Cloud/CloudStartSessionStrings.swift | 4 ++ .../Resources/CloudSessions.xcstrings | 27 +++++++ .../CloudStartSessionTests.swift | 72 ++++++++++++++----- 4 files changed, 126 insertions(+), 25 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift index 8ab1739feb83..15c039e78f97 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift @@ -55,9 +55,10 @@ struct CloudStartSession { request.machineID = try CloudHandlers.machine(invocation, context).machineID } if request.machineID == nil, request.name == nil { throw ActionFailure(message: CloudStartSessionStrings.needsMachine(agent: request.agent)) } + let key = CloudStartSessionRuns.Key(request, caller: nil, account: services.cloud.account) CloudHandlers.result("start cloud session", context) { - try await runs.answer(CloudStartSessionRuns.Key(request, caller: nil, account: services.cloud.account), within: Self.answerWithin, pending: Self.pending(request, services)) { - try await start.run(request, invocation, context) + try await runs.answer(key, within: Self.answerWithin, pending: Self.pending(request, services)) { + try await start.run(request, invocation, context, account: key.account) } } } @@ -93,7 +94,8 @@ struct CloudStartSession { let token = try await asks.ask(tab: tab.id, text: text, asked: asked, services: services) asks.show(tab: tab.id, token: token, .working, CloudStartSessionStrings.starting(agent: request.agent, machine: named), services: services) do { - let fields = try await start.run(request, asPerson, context, askedBy: caller, revealing: false) + // The account the agent asked in: a switch while the card was open refuses. + let fields = try await start.run(request, asPerson, context, account: key.account, askedBy: caller, revealing: false) asks.finish(tab: tab.id, token: token, failure: nil, services: services) return fields } catch { @@ -147,11 +149,18 @@ struct CloudStartSession { } /// Takes or creates the machine, connects, opens the chat's tab there and starts the session. - func run(_ request: Request, _ invocation: ActionInvocation, _ context: AppActionContext, askedBy: String? = nil, + /// `account`: the user and team signed in when it was asked. It is checked before each step + /// that changes anything and after each wait, so a team switch or sign-out mid-run starts + /// nothing in the account signed in now. + func run(_ request: Request, _ invocation: ActionInvocation, _ context: AppActionContext, account: [String?], askedBy: String? = nil, revealing: Bool = true) async throws -> [String: ActionResultValue] { - let session = try await machine(request, invocation, askedBy: askedBy) + let still = { [services] (machine: String) throws in try Self.checked(account, now: services.cloud.account, request.asked(machine: machine)) } + try still(request.name ?? request.machineID ?? "") + let session = try await machine(request, invocation, account: account, askedBy: askedBy) let name = session.machine.title, id = session.machineID, agent = request.agent + try still(name) try await connected(session, invocation, request) + try still(name) let daemon = session.daemon guard let host = AgentTabStore.registryHost(of: daemon) else { throw ActionFailure(message: CloudStartSessionStrings.notConnected(name, id: id, agent: agent)) @@ -162,6 +171,7 @@ struct CloudStartSession { } catch { throw ActionFailure(message: CloudStartSessionStrings.notConnected(name, id: id, agent: agent)) } + try still(name) let started: AgentTabRemoteStart.Started let tabKey: String do { @@ -170,11 +180,16 @@ struct CloudStartSession { tabKey = try await pending.value().key // The start reads the tab as the machine's tree lists it. _ = await CloudMove.holds(within: .seconds(10)) { [services] in services.locateTab(tabKey) != nil } + try Self.check(account, now: services.cloud.account, request.asked(machine: name)) switch tabs.remoteHost(tabKey) { case let remote as AgentTabRemoteHost: started = try await remote.startNow(cwd: request.cwd, harness: agent) case let refused as AgentTabRefusedHost: throw refused.refusal default: throw AgentRemoteStartRefusal.startUnsupported(machine: name) } + } catch let changed as AccountChanged { + throw changed.failure + } catch is CancellationError { + throw CancellationError() } catch { throw ActionFailure(message: Self.startFailure(error, name: name, id: id, agent: agent)) } @@ -188,6 +203,20 @@ struct CloudStartSession { ] } + /// The account changed (or the run was cancelled) since `asked` was asked: stop before the next step. + struct AccountChanged: Error { var failure: ActionFailure } + + /// Inside a step whose other errors are mapped (``AccountChanged`` passes through the mapping). + static func check(_ account: [String?], now: [String?], _ asked: CloudStartSessionStrings.Asked) throws { + try Task.checkCancellation() + guard account == now else { throw AccountChanged(failure: ActionFailure(message: CloudStartSessionStrings.accountChanged(asked))) } + } + + /// Between steps: the refusal itself. + static func checked(_ account: [String?], now: [String?], _ asked: CloudStartSessionStrings.Asked) throws { + do { try check(account, now: now, asked) } catch let changed as AccountChanged { throw changed.failure } + } + /// The start's refusal in the run's words: the machine is up, its agent is not. static func startFailure(_ error: any Error, name: String, id: String, agent: String) -> String { if let refusal = error as? AgentRemoteStartRefusal { @@ -220,7 +249,7 @@ struct CloudStartSession { static let listFresh: Duration = .seconds(15) /// The machine named by id, else by name, else a new one with that name. - private func machine(_ request: Request, _ invocation: ActionInvocation, askedBy: String?) async throws -> CloudMachineSession { + private func machine(_ request: Request, _ invocation: ActionInvocation, account: [String?], askedBy: String?) async throws -> CloudMachineSession { let machines = services.machines if let id = request.machineID { guard let session = machines.session(id) else { throw ActionFailure(message: CloudStartSessionStrings.noSuchMachine(id: id, agent: request.agent)) } @@ -230,17 +259,22 @@ struct CloudStartSession { let cloud = services.cloud // A list the server answered just now for this account: a machine by this name made // elsewhere is taken, never made again. A failed or superseded read refuses. + let still = { try Self.check(account, now: cloud.account, request.asked(machine: name)) } if !cloud.hasCurrentList(within: Self.listFresh) { await cloud.refresh() } + try Self.checked(account, now: cloud.account, request.asked(machine: name)) guard cloud.hasCurrentList(within: Self.listFresh) else { throw ActionFailure(message: CloudStartSessionStrings.listUnread(request.asked(machine: name))) } if let named = machines.cloud.first(where: { $0.machine.title == name }) { return named } do { - return try await cloud.startSessionRuns.creating(name, account: cloud.account) { [machines] in + return try await cloud.startSessionRuns.creating(name, account: account) { [machines] in // Another ask's creation may have ended first. if let named = machines.cloud.first(where: { $0.machine.title == name }) { return named } + try still() return try await cloud.createMachine(name: name, startedByPerson: invocation.origin == .user, askedBy: askedBy ?? invocation.callerAgentSession) } } catch is CancellationError { throw CancellationError() + } catch let changed as AccountChanged { + throw changed.failure } catch { let detail = (error as? ActionFailure)?.message ?? (error as? any LocalizedError)?.errorDescription ?? String(describing: error) throw ActionFailure(message: CloudStartSessionStrings.createFailed(name, quoted: Self.shellQuoted(name), agent: request.agent, reason: Self.reason(detail))) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift index 71709fd85315..9bdc6cd4b95d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift @@ -91,6 +91,10 @@ enum CloudStartSessionStrings { } /// The machine list could not be read, so a machine by that name may exist. + static func accountChanged(_ asked: Asked) -> String { + String(format: String(localized: "cloud.startSession.accountChanged", defaultValue: "Your Cloud account or team changed after this was asked, so %2$@ didn't start on %1$@. Run `%3$@` again in the account you want it in.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) + } + static func listUnread(_ asked: Asked) -> String { String(format: String(localized: "cloud.startSession.listUnread", defaultValue: "Couldn't read your Cloud machines, so %2$@ didn't start: a machine named %1$@ may already exist. Check `cmux cloud list-machines`, then run `%3$@` again.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings index 0bfa76388c97..568897bc8378 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings @@ -513,6 +513,33 @@ "uk": {"stringUnit": {"state": "needs_review", "value": "Не вдалося прочитати ваші машини Cloud, тому %2$@ не запущено: машина з назвою %1$@ може вже існувати. Перевірте `cmux cloud list-machines`, потім знову виконайте `%3$@`."}}, "vi": {"stringUnit": {"state": "needs_review", "value": "Không thể đọc danh sách máy Cloud của bạn nên %2$@ chưa khởi động: có thể đã có một máy tên %1$@. Kiểm tra `cmux cloud list-machines`, rồi chạy lại `%3$@`."}} } + }, + "cloud.startSession.accountChanged": { + "comment": "The signed-in Cloud account or team changed while the run waited. %1$@ machine, %2$@ agent, %3$@ the command (keep it in backticks).", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Your Cloud account or team changed after this was asked, so %2$@ didn't start on %1$@. Run `%3$@` again in the account you want it in."}}, + "ja": {"stringUnit": {"state": "translated", "value": "確認の後に Cloud のアカウントまたはチームが変わったため、%1$@ で %2$@ は開始されませんでした。使いたいアカウントで `%3$@` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Dein Cloud-Konto oder Team hat sich nach der Anfrage geändert, daher wurde %2$@ auf %1$@ nicht gestartet. Führe `%3$@` im gewünschten Konto erneut aus."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Votre compte ou équipe Cloud a changé après la demande : %2$@ n'a pas démarré sur %1$@. Relancez `%3$@` dans le compte voulu."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Tu cuenta o equipo de Cloud cambió después de la solicitud, así que %2$@ no se inició en %1$@. Vuelve a ejecutar `%3$@` en la cuenta que quieras."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "تغيّر حساب Cloud أو الفريق بعد الطلب، لذا لم يبدأ %2$@ على %1$@. شغّل `%3$@` مرة أخرى في الحساب الذي تريده."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "요청 후 Cloud 계정 또는 팀이 바뀌어 %1$@에서 %2$@이(가) 시작되지 않았습니다. 원하는 계정에서 `%3$@`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "提出请求后你的 Cloud 帐户或团队已更改,因此 %2$@ 没有在 %1$@ 上启动。请在你想要的帐户中再次运行 `%3$@`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "提出請求後你的 Cloud 帳號或團隊已變更,因此 %2$@ 沒有在 %1$@ 上啟動。請在你想要的帳號中再次執行 `%3$@`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Vaš Cloud račun ili tim se promijenio nakon zahtjeva, pa %2$@ nije pokrenut na %1$@. Ponovo pokrenite `%3$@` u računu u kojem ga želite."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Din Cloud-konto eller dit team ændrede sig efter anmodningen, så %2$@ startede ikke på %1$@. Kør `%3$@` igen i den konto, du vil bruge."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Il tuo account o team Cloud è cambiato dopo la richiesta, quindi %2$@ non è stato avviato su %1$@. Esegui di nuovo `%3$@` nell'account che vuoi usare."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "គណនី ឬក្រុម Cloud របស់អ្នកបានប្តូរបន្ទាប់ពីសំណើនេះ ដូច្នេះ %2$@ មិនបានចាប់ផ្តើមលើ %1$@ ទេ។ ដំណើរការ `%3$@` ម្តងទៀតក្នុងគណនីដែលអ្នកចង់ប្រើ។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Cloud-kontoen eller teamet ditt endret seg etter forespørselen, så %2$@ startet ikke på %1$@. Kjør `%3$@` igjen i kontoen du vil bruke."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Twoje konto lub zespół Cloud zmieniły się po prośbie, więc %2$@ nie uruchomiono na %1$@. Uruchom `%3$@` ponownie na koncie, którego chcesz użyć."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Sua conta ou equipe do Cloud mudou depois do pedido, então %2$@ não foi iniciado em %1$@. Execute `%3$@` de novo na conta que você quer usar."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Аккаунт или команда Cloud изменились после запроса, поэтому %2$@ не запущен на %1$@. Снова выполните `%3$@` в нужном аккаунте."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "บัญชีหรือทีม Cloud ของคุณเปลี่ยนไปหลังจากคำขอนี้ %2$@ จึงไม่ได้เริ่มบน %1$@ เรียกใช้ `%3$@` อีกครั้งในบัญชีที่คุณต้องการ"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "İstekten sonra Cloud hesabınız veya ekibiniz değişti, bu yüzden %2$@ %1$@ üzerinde başlamadı. `%3$@` komutunu istediğiniz hesapta yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Обліковий запис або команда Cloud змінилися після запиту, тому %2$@ не запущено на %1$@. Знову виконайте `%3$@` у потрібному обліковому записі."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Tài khoản hoặc nhóm Cloud của bạn đã thay đổi sau yêu cầu này, nên %2$@ chưa khởi động trên %1$@. Hãy chạy lại `%3$@` trong tài khoản bạn muốn dùng."}} + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index 79b46d4cfc86..d4b9f6f461f7 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -201,43 +201,77 @@ struct CloudStartSessionTests { #expect(!services.machines.cloud.contains { $0.machine.title == name }) } - /// Two asks for one new name share one creation; another account's ask makes its own. + enum CreationEvent: Equatable { case joined(String), entered(Int) } + + /// Two asks for one new name share one creation; another team's ask for it, meanwhile, makes its own. @Test func twoRunsForOneNewNameCreateOnce() async throws { let f = Self.fixture() let runs = CloudStartSessionRuns() - let (joins, join) = AsyncStream.makeStream() - runs.joinedCreation = { join.yield($0) } + let (events, event) = AsyncStream.makeStream() + runs.joinedCreation = { event.yield(.joined($0)) } // One gate per creation, each finished at the end: a second creation (the bug) never shares one. - var gates: [AsyncStream.Continuation] = [] - defer { for gate in gates { gate.finish() }; join.finish() } - let (entered, enter) = AsyncStream.makeStream() - defer { enter.finish() } + let gates = Box<[AsyncStream.Continuation]>([]) + defer { for gate in gates.value { gate.finish() }; event.finish() } let creates = Box(0) func create() -> @MainActor () async throws -> CloudMachineSession { let (gate, open) = AsyncStream.makeStream() - gates.append(open) + gates.value.append(open) return { creates.value += 1 - enter.yield(creates.value) + event.yield(.entered(creates.value)) for await _ in gate { break } return f.session } } + // The next join or creation: a second creation (the bug) ends the wait as surely as the join. + func next() async -> CreationEvent? { + for await value in events { return value } + return nil + } let first = Task { @MainActor in try await runs.creating("dev", account: Self.account, create()) } - for await _ in entered { break } + #expect(await next() == .entered(1)) let second = Task { @MainActor in try await runs.creating("dev", account: Self.account, create()) } - for await name in joins { #expect(name == "dev"); break } - gates[0].yield() + #expect(await next() == .joined("dev")) + let other = Task { @MainActor in try await runs.creating("dev", account: ["user-1", "team-2"], create()) } + #expect(await next() == .entered(2), "another team's ask made its own machine while the first was being made") + for gate in gates.value { gate.yield() } let one = try await first.value let two = try await second.value - #expect(creates.value == 1) - #expect(one === f.session && two === f.session) - // Another team's ask for the same name is its own creation. - let other = Task { @MainActor in try await runs.creating("dev", account: ["user-1", "team-2"], create()) } - for await count in entered { #expect(count == 2); break } - gates[2].yield() _ = try await other.value #expect(creates.value == 2) + #expect(one === f.session && two === f.session) + } + + /// A run asked in one account stops before its next step once another is signed in: the + /// refusal says so and gives the command, and nothing is created. + @Test func aRunAskedInAnotherAccountStartsNothing() async throws { + let services = Self.fixture().services + let name = "team-\(UUID().uuidString.prefix(6).lowercased())" + let request = CloudStartSession.Request(machineID: nil, name: name, agent: "claude", cwd: nil) + let asked = ["user-1", "team-A"] as [String?] + #expect(asked != services.cloud.account) + do { + _ = try await CloudStartSession(services: services).run(request, Self.invocation(["agent": .string("claude"), "name": .string(name)]), + AppActionContext(services: services), account: asked) + Issue.record("a run asked in another account went on") + } catch let failure as ActionFailure { + #expect(failure.message == CloudStartSessionStrings.accountChanged(request.asked(machine: name))) + #expect(failure.message.contains("`cmux cloud start-session --name \(name) --agent claude`")) + } + #expect(!services.machines.cloud.contains { $0.machine.title == name }) + } + + @Test func theAccountCheckRefusesAChangeAndStopsACancelledRun() async throws { + let asked = Self.asked + #expect(throws: Never.self) { try CloudStartSession.checked(Self.account, now: Self.account, asked) } + #expect(throws: CloudStartSession.AccountChanged.self) { try CloudStartSession.check(Self.account, now: ["user-1", "team-2"], asked) } + #expect(throws: ActionFailure.self) { try CloudStartSession.checked(Self.account, now: [nil, nil], asked) } + let cancelled = Task { @MainActor in + withUnsafeCurrentTask { $0?.cancel() } + return Result { try CloudStartSession.checked(Self.account, now: Self.account, asked) } + } + guard case .failure(let error) = await cancelled.value else { Issue.record("a cancelled run went on"); return } + #expect(error is CancellationError) } /// Another account's same ask never takes a kept run's end, and a sign-out forgets them all. @@ -389,6 +423,8 @@ struct CloudStartSessionTests { let started = Box(0) let work: @MainActor () async throws -> CloudStartSessionRuns.Fields = { started.value += 1 + // A second start (the bug) answers at once: it never reads the first start's gate. + guard started.value == 1 else { return ["session_id": .string("s-\(started.value)")] } for await _ in gate { break } return ["session_id": .string("s-1")] } From ce84d45ccec4e1d08de2faec10cf4c4997abd78e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 00:54:31 -0700 Subject: [PATCH 14/33] cmux next: regenerate action exports with cloud start-session on current base Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- plans/cmux-next/action-surfaces.json | 45 ++++++++++++++++++- plans/cmux-next/actions.md | 8 ++-- .../next-docs/cli-reference.generated.json | 10 ++++- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/plans/cmux-next/action-surfaces.json b/plans/cmux-next/action-surfaces.json index 8aa258907697..035e61f3fe6e 100644 --- a/plans/cmux-next/action-surfaces.json +++ b/plans/cmux-next/action-surfaces.json @@ -26296,6 +26296,34 @@ "title_key" : "action.remote.openLocalBrowserTab", "title_table" : "RemoteActions" }, + { + "cli" : "offered", + "cli_name" : "cloud start-session", + "context_menu" : "offered", + "context_menus" : [ + "cloudMachine" + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "cloudStartSession", + "main_menu" : null, + "mcp" : "offered", + "palette" : "offered", + "palette_section" : { + "id" : "category.cloud", + "order" : 110, + "title" : "Cloud and Account", + "title_key" : "category.cloud", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Start Agent Session on Cloud Machine…", + "title_key" : "action.cloudStartSession", + "title_table" : "CloudSessionActions" + }, { "cli" : "offered", "cli_name" : "machine set-icon", @@ -38180,15 +38208,28 @@ ] } }, + { + "enabled_when" : "can_perform", + "id" : "cloudStartSession", + "kind" : "action", + "order" : 15, + "visible_when" : { + "debug_only" : false, + "feature" : "cloud", + "requires" : [ + + ] + } + }, { "kind" : "separator", - "order" : 15 + "order" : 16 }, { "enabled_when" : "can_perform", "id" : "palette.cloud.deleteSnapshot", "kind" : "action", - "order" : 16, + "order" : 17, "visible_when" : { "debug_only" : false, "feature" : "cloud", diff --git a/plans/cmux-next/actions.md b/plans/cmux-next/actions.md index 0858ab67e5cd..98ab1e1c6b86 100644 --- a/plans/cmux-next/actions.md +++ b/plans/cmux-next/actions.md @@ -45,13 +45,13 @@ Declare its placements (menu, group, rank) and whether the CLI offers it, or nam - `sidebar`: 87 - `notifications`: 21 - `agents`: 93 -- `cloud`: 60 +- `cloud`: 61 - `remote`: 18 - `settings`: 123 -## Counts (1213 actions) +## Counts (1214 actions) -Palette 1163, CLI verbs 697, right-click 581, MCP tools 615. +Palette 1164, CLI verbs 698, right-click 582, MCP tools 616. ## Menus @@ -68,7 +68,7 @@ Palette 1163, CLI verbs 697, right-click 581, MCP tools 615. - **browserLink**: browser.link.openInNewTab browser.link.openInNewWindow browser.link.openInNewSpace browser.link.openInNewWorkspace browser.link.openInSplit openLinkInDefaultBrowser | browser.link.openInIncognitoWindow | browser.link.saveAs browser.link.copy browser.link.copyText - **browserImage**: browser.image.openInNewTab | browser.image.saveAs browser.image.copy browser.image.copyAddress - **browserSelection**: browser.selection.copy home.sendSelectionToChief home.sendSelectionTo | browser.selection.search browser.selection.lookUp -- **cloudMachine**: cloudNewTerminal cloudOpenMachine cloudSSH cloudSetupGitHub | cloudRenameMachine | [connection] > (cloudResizeMachine palette.cloud.status palette.cloud.snapshot palette.cloud.fork palette.cloud.handoff palette.cloud.ports palette.cloud.promoteTemplate palette.cloud.restore cloudPauseMachine cloudResumeMachine cloudSetIdlePause cloudOpenBrowser cloudPortList cloudPortForward cloudPortClose | palette.cloud.deleteSnapshot) | [copy] > (cloudCopyMachineID cloudCopyLink cloudCopyPort) [tools] > (cloudFilesList cloudFileRead cloudFileWrite cloudFileMkdir cloudFileRemove cloudFileStat cloudPrepareSCP cloudTunnelAttach cloudTunnelDetach cloudTunnelRotateKey cloudNetworkList cloudFirewallList cloudFirewallGet cloudFirewallCreate cloudFirewallDelete | palette.cloud.tools) | cloudKillMachine +- **cloudMachine**: cloudNewTerminal cloudOpenMachine cloudSSH cloudSetupGitHub | cloudRenameMachine | [connection] > (cloudResizeMachine palette.cloud.status palette.cloud.snapshot palette.cloud.fork palette.cloud.handoff palette.cloud.ports palette.cloud.promoteTemplate palette.cloud.restore cloudPauseMachine cloudResumeMachine cloudSetIdlePause cloudOpenBrowser cloudPortList cloudPortForward cloudPortClose cloudStartSession | palette.cloud.deleteSnapshot) | [copy] > (cloudCopyMachineID cloudCopyLink cloudCopyPort) [tools] > (cloudFilesList cloudFileRead cloudFileWrite cloudFileMkdir cloudFileRemove cloudFileStat cloudPrepareSCP cloudTunnelAttach cloudTunnelDetach cloudTunnelRotateKey cloudNetworkList cloudFirewallList cloudFirewallGet cloudFirewallCreate cloudFirewallDelete | palette.cloud.tools) | cloudKillMachine - **sshMachine**: remote.newWorkspace | remote.reconnect remote.disconnect remote.install machine.doctor | remote.copyError | remote.forget - **machineSection**: [appearance] > (machine.setIcon machine.clearIcon) | machine.reconnect | remote.removeGroup - **newTab**: newSurface openBrowser.chromium palette.newAgentChat macScreen.open | browserProfile.newTab newTab.page | agentPane.togglePointerInspector diff --git a/web/data/next-docs/cli-reference.generated.json b/web/data/next-docs/cli-reference.generated.json index c3bf1aeac110..3932ba05d97c 100644 --- a/web/data/next-docs/cli-reference.generated.json +++ b/web/data/next-docs/cli-reference.generated.json @@ -1,8 +1,8 @@ { "version": 1, "counts": { - "verbs": 697, - "mcp": 615 + "verbs": 698, + "mcp": 616 }, "nouns": [ { @@ -1150,6 +1150,12 @@ "id": "cloudSSH", "mcp": true }, + { + "command": "cmux cloud start-session", + "title": "Start Agent Session on Cloud Machine…", + "id": "cloudStartSession", + "mcp": true + }, { "command": "cmux cloud tunnel-attach", "title": "Attach Cloud Tunnel Network", From 1f2c5bb4191a1bf653ae2e64259ceda72e62cb41 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 01:07:05 -0700 Subject: [PATCH 15/33] cloud start-session: a --target must be in the list of the account signed in now; the connect and the tab check the account; a tab that never lists refuses Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextApp/Cloud/CloudStartSession.swift | 24 ++++++++++++----- .../Cloud/CloudStartSessionStrings.swift | 4 +++ .../Resources/CloudSessions.xcstrings | 27 +++++++++++++++++++ .../CloudStartSessionTests.swift | 18 +++++++++++++ 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift index 15c039e78f97..4b45f5fd38e5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift @@ -159,7 +159,7 @@ struct CloudStartSession { let session = try await machine(request, invocation, account: account, askedBy: askedBy) let name = session.machine.title, id = session.machineID, agent = request.agent try still(name) - try await connected(session, invocation, request) + try await connected(session, invocation, request, account: account) try still(name) let daemon = session.daemon guard let host = AgentTabStore.registryHost(of: daemon) else { @@ -178,9 +178,10 @@ struct CloudStartSession { let tabs = services.agentTabs let pending = try tabs.open(in: anchor.pane.handle, of: daemon, seed: request.cwd.map { AgentPaneSeedSource(AgentPaneSeed(cwd: $0)) }) tabKey = try await pending.value().key - // The start reads the tab as the machine's tree lists it. - _ = await CloudMove.holds(within: .seconds(10)) { [services] in services.locateTab(tabKey) != nil } + // The start reads the tab as the machine's tree lists it (`tabKey` is the store's id). + let listed = await CloudMove.holds(within: .seconds(10)) { [services] in services.locateTab(tabKey) != nil } try Self.check(account, now: services.cloud.account, request.asked(machine: name)) + guard listed else { throw AccountChanged(failure: ActionFailure(message: CloudStartSessionStrings.notConnected(name, id: id, agent: agent))) } switch tabs.remoteHost(tabKey) { case let remote as AgentTabRemoteHost: started = try await remote.startNow(cwd: request.cwd, harness: agent) case let refused as AgentTabRefusedHost: throw refused.refusal @@ -195,7 +196,8 @@ struct CloudStartSession { } // Where a hook that saves the session's trajectory off the machine (hq#4381) would go, once // acpmux has one: the session started, so its id is known here. None exists on base yet. - if revealing, invocation.allowsViewChange { CloudHandlers.show(anchor.id, context) } + // The session runs now; the result reports it. A switch during the start shows nothing. + if revealing, invocation.allowsViewChange, account == services.cloud.account { CloudHandlers.show(anchor.id, context) } return [ "machine_id": .string(id), "machine_name": .string(name), "target": .string("machine:\(id)"), "session_id": .string(started.session), "host": .string(host), "agent": .string(agent), @@ -203,7 +205,8 @@ struct CloudStartSession { ] } - /// The account changed (or the run was cancelled) since `asked` was asked: stop before the next step. + /// A refusal that passes through a step's error mapping as it is: the account changed since + /// the run was asked, or a step's own precondition failed. struct AccountChanged: Error { var failure: ActionFailure } /// Inside a step whose other errors are mapped (``AccountChanged`` passes through the mapping). @@ -251,12 +254,17 @@ struct CloudStartSession { /// The machine named by id, else by name, else a new one with that name. private func machine(_ request: Request, _ invocation: ActionInvocation, account: [String?], askedBy: String?) async throws -> CloudMachineSession { let machines = services.machines + let cloud = services.cloud if let id = request.machineID { + // Only a machine of the list read for the account signed in now: after a team switch, + // the previous team's machines stay in the store until that list lands. + if !cloud.hasLoadedCurrentMachines { await cloud.refresh() } + try Self.checked(account, now: cloud.account, request.asked(machine: id)) + guard cloud.hasLoadedCurrentMachines else { throw ActionFailure(message: CloudStartSessionStrings.targetUnread(request.asked(machine: id))) } guard let session = machines.session(id) else { throw ActionFailure(message: CloudStartSessionStrings.noSuchMachine(id: id, agent: request.agent)) } return session } let name = request.name ?? "" - let cloud = services.cloud // A list the server answered just now for this account: a machine by this name made // elsewhere is taken, never made again. A failed or superseded read refuses. let still = { try Self.check(account, now: cloud.account, request.asked(machine: name)) } @@ -283,10 +291,12 @@ struct CloudStartSession { /// The machine connected with its store's id: a paused one resumes, a new one comes up. Nothing /// here waits on a client snapshot (hq#4381): the machine's own acpmux keeps its sessions. - private func connected(_ session: CloudMachineSession, _ invocation: ActionInvocation, _ request: Request) async throws { + private func connected(_ session: CloudMachineSession, _ invocation: ActionInvocation, _ request: Request, account: [String?]) async throws { let failure = { ActionFailure(message: CloudStartSessionStrings.notConnected(session.machine.title, id: session.machineID, agent: request.agent)) } if session.daemon.connection == nil { await session.refreshIfMaybePaused() + // The resume or connect below changes the machine: only for the account that asked. + try Self.checked(account, now: services.cloud.account, request.asked(machine: session.machine.title)) let origin = CloudHandlers.connectOrigin(for: invocation) if session.machine.status == .paused { session.attach(origin: origin) } else { session.connect(origin: origin) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift index 9bdc6cd4b95d..32240123a48d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift @@ -95,6 +95,10 @@ enum CloudStartSessionStrings { String(format: String(localized: "cloud.startSession.accountChanged", defaultValue: "Your Cloud account or team changed after this was asked, so %2$@ didn't start on %1$@. Run `%3$@` again in the account you want it in.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) } + static func targetUnread(_ asked: Asked) -> String { + String(format: String(localized: "cloud.startSession.targetUnread", defaultValue: "Couldn't read the Cloud machines of the account signed in now, so %2$@ didn't start on %1$@. Check `cmux cloud list-machines`, then run `%3$@` again.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) + } + static func listUnread(_ asked: Asked) -> String { String(format: String(localized: "cloud.startSession.listUnread", defaultValue: "Couldn't read your Cloud machines, so %2$@ didn't start: a machine named %1$@ may already exist. Check `cmux cloud list-machines`, then run `%3$@` again.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings index 568897bc8378..b68d739911d9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings @@ -540,6 +540,33 @@ "uk": {"stringUnit": {"state": "needs_review", "value": "Обліковий запис або команда Cloud змінилися після запиту, тому %2$@ не запущено на %1$@. Знову виконайте `%3$@` у потрібному обліковому записі."}}, "vi": {"stringUnit": {"state": "needs_review", "value": "Tài khoản hoặc nhóm Cloud của bạn đã thay đổi sau yêu cầu này, nên %2$@ chưa khởi động trên %1$@. Hãy chạy lại `%3$@` trong tài khoản bạn muốn dùng."}} } + }, + "cloud.startSession.targetUnread": { + "comment": "The machine list of the account signed in now could not be read, so the target is not known to be theirs. %1$@ machine id, %2$@ agent, %3$@ the command (keep it in backticks).", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Couldn't read the Cloud machines of the account signed in now, so %2$@ didn't start on %1$@. Check `cmux cloud list-machines`, then run `%3$@` again."}}, + "ja": {"stringUnit": {"state": "translated", "value": "現在サインインしているアカウントの Cloud マシンを読み込めなかったため、%1$@ で %2$@ は開始されませんでした。`cmux cloud list-machines` で確認してから、`%3$@` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Die Cloud-Maschinen des jetzt angemeldeten Kontos konnten nicht gelesen werden, daher wurde %2$@ auf %1$@ nicht gestartet. Prüfe `cmux cloud list-machines` und führe dann `%3$@` erneut aus."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Impossible de lire les machines Cloud du compte connecté : %2$@ n'a pas démarré sur %1$@. Vérifiez `cmux cloud list-machines`, puis relancez `%3$@`."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "No se pudieron leer las máquinas de Cloud de la cuenta con la sesión iniciada, así que %2$@ no se inició en %1$@. Revisa `cmux cloud list-machines` y vuelve a ejecutar `%3$@`."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "تعذّرت قراءة أجهزة Cloud للحساب المسجّل الآن، لذا لم يبدأ %2$@ على %1$@. افحص `cmux cloud list-machines`، ثم شغّل `%3$@` مرة أخرى."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "지금 로그인한 계정의 Cloud 머신을 읽지 못해 %1$@에서 %2$@이(가) 시작되지 않았습니다. `cmux cloud list-machines`로 확인한 다음 `%3$@`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "无法读取当前登录帐户的 Cloud 机器,因此 %2$@ 没有在 %1$@ 上启动。请检查 `cmux cloud list-machines`,然后再次运行 `%3$@`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "無法讀取目前登入帳號的 Cloud 機器,因此 %2$@ 沒有在 %1$@ 上啟動。請檢查 `cmux cloud list-machines`,然後再次執行 `%3$@`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Nije moguće pročitati Cloud mašine trenutno prijavljenog računa, pa %2$@ nije pokrenut na %1$@. Provjerite `cmux cloud list-machines`, pa ponovo pokrenite `%3$@`."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Kunne ikke læse Cloud-maskinerne for den konto, der er logget ind nu, så %2$@ startede ikke på %1$@. Tjek `cmux cloud list-machines`, og kør derefter `%3$@` igen."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Impossibile leggere le macchine Cloud dell'account con cui hai effettuato l'accesso, quindi %2$@ non è stato avviato su %1$@. Controlla `cmux cloud list-machines`, poi esegui di nuovo `%3$@`."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "មិនអាចអានម៉ាស៊ីន Cloud របស់គណនីដែលបានចូលឥឡូវនេះបានទេ ដូច្នេះ %2$@ មិនបានចាប់ផ្តើមលើ %1$@ ទេ។ ពិនិត្យ `cmux cloud list-machines` រួចដំណើរការ `%3$@` ម្តងទៀត។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Kunne ikke lese Cloud-maskinene til kontoen som er logget inn nå, så %2$@ startet ikke på %1$@. Sjekk `cmux cloud list-machines`, og kjør deretter `%3$@` igjen."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Nie udało się odczytać maszyn Cloud konta, na które jesteś teraz zalogowany, więc %2$@ nie uruchomiono na %1$@. Sprawdź `cmux cloud list-machines`, a potem uruchom `%3$@` ponownie."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Não foi possível ler as máquinas do Cloud da conta conectada agora, então %2$@ não foi iniciado em %1$@. Confira `cmux cloud list-machines` e execute `%3$@` de novo."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Не удалось прочитать машины Cloud аккаунта, в который выполнен вход, поэтому %2$@ не запущен на %1$@. Проверьте `cmux cloud list-machines`, затем снова выполните `%3$@`."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "อ่านเครื่อง Cloud ของบัญชีที่ลงชื่อเข้าใช้อยู่ไม่ได้ %2$@ จึงไม่ได้เริ่มบน %1$@ ตรวจสอบ `cmux cloud list-machines` แล้วเรียกใช้ `%3$@` อีกครั้ง"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Şu anda oturum açık olan hesabın Cloud makineleri okunamadı, bu yüzden %2$@ %1$@ üzerinde başlamadı. `cmux cloud list-machines` ile kontrol edin, ardından `%3$@` komutunu yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Не вдалося прочитати машини Cloud облікового запису, у який виконано вхід, тому %2$@ не запущено на %1$@. Перевірте `cmux cloud list-machines`, потім знову виконайте `%3$@`."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Không đọc được các máy Cloud của tài khoản đang đăng nhập, nên %2$@ chưa khởi động trên %1$@. Hãy kiểm tra `cmux cloud list-machines`, rồi chạy lại `%3$@`."}} + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index d4b9f6f461f7..6aca3345e599 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -261,6 +261,24 @@ struct CloudStartSessionTests { #expect(!services.machines.cloud.contains { $0.machine.title == name }) } + /// A machine in the store whose account's list isn't the one read now (after a team switch, + /// the previous team's machines stay until that list lands) is never a target. + @Test func aTargetOutsideTheListOfTheAccountSignedInNowStartsNothing() async throws { + let f = Self.fixture() + #expect(!f.services.cloud.hasLoadedCurrentMachines) + let invoke = try #require(f.services.registry.action(for: CloudStartSession.action)?.invoke) + let run = f.services.registry.capturingRun { invoke(Self.invocation(target: f.session.machineID, ["agent": .string("claude")])) } + let task = try #require(run.results.first, "start-session tracked no result") + guard case .failure(let failure) = await task.value else { + Issue.record("a target outside this account's list went on") + return + } + let request = CloudStartSession.Request(machineID: f.session.machineID, name: nil, agent: "claude", cwd: nil) + #expect(failure.message == CloudStartSessionStrings.targetUnread(request.asked(machine: f.session.machineID))) + #expect(failure.message.contains("`cmux cloud start-session --target machine:\(f.session.machineID) --agent claude`")) + #expect(f.session.daemon.connection == nil, "nothing connected") + } + @Test func theAccountCheckRefusesAChangeAndStopsACancelledRun() async throws { let asked = Self.asked #expect(throws: Never.self) { try CloudStartSession.checked(Self.account, now: Self.account, asked) } From a97ce385d83c9777f575dbf9c9735872274e62c6 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 01:37:39 -0700 Subject: [PATCH 16/33] cloud start-session: a run's end resumes its callers (no waiter task); a sign-out or team switch ends an open card Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextApp/Cloud/CloudStartSession.swift | 3 +- .../Cloud/CloudStartSessionAsks.swift | 62 +++++++++++++------ .../CloudStartSessionTests.swift | 44 +++++++++++-- 3 files changed, 85 insertions(+), 24 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift index 4b45f5fd38e5..6c4e4b89a805 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift @@ -91,7 +91,8 @@ struct CloudStartSession { let key = CloudStartSessionRuns.Key(request, caller: caller, account: services.cloud.account) CloudHandlers.result("start cloud session", context) { try await runs.answer(key, within: Self.answerWithin, pending: Self.pending(request, services)) { - let token = try await asks.ask(tab: tab.id, text: text, asked: asked, services: services) + let cloud = services.cloud + let token = try await asks.ask(tab: tab.id, text: text, asked: asked, account: key.account, now: { cloud.account }, services: services) asks.show(tab: tab.id, token: token, .working, CloudStartSessionStrings.starting(agent: request.agent, machine: named), services: services) do { // The account the agent asked in: a switch while the card was open refuses. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift index 15cbb106e452..d774676f3ca4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift @@ -3,7 +3,8 @@ import CmuxNextAgentPane import Foundation /// An agent's ask to start a Cloud session, as a card in its own chat (``AgentToolCards``): the -/// agent's run waits for the person's Start or Not now, for at most ``wait``. The ask owns its +/// agent's run waits for the person's Start or Not now, for at most ``wait``, and while the +/// account it was asked in stays signed in (a sign-out or team switch ends the card). The ask owns its /// chat's card from the ask until its run ends (``finish(tab:token:failure:services:)``): another /// ask in that chat meanwhile is refused, and never replaces it. @MainActor @@ -17,13 +18,17 @@ final class CloudStartSessionAsks { /// when the chat has no page. The app's tool cards by default; a test's own. var present: (@MainActor (_ cards: [AgentPaneToolCard], _ tab: String, _ services: AppServices, _ answer: @escaping AgentToolCards.Answer) -> Bool)? + enum Answer: Sendable { case start, notNow, expired, accountChanged } + /// Shows the card in chat tab `tab` and returns its token once the person clicks Start. Throws - /// when they choose Not now, when nobody answers within ``wait``, or when the chat has no page. - func ask(tab: String, text: String, asked: CloudStartSessionStrings.Asked, services: AppServices) async throws -> UUID { + /// when they choose Not now, when nobody answers within ``wait``, when the signed-in account + /// stops being `account` (`now`, read on every change), or when the chat has no page. + func ask(tab: String, text: String, asked: CloudStartSessionStrings.Asked, account: [String?], + now: @escaping @MainActor @Sendable () -> [String?], services: AppServices) async throws -> UUID { guard open[tab] == nil else { throw ActionFailure(message: CloudStartSessionStrings.askOpen(asked)) } let token = UUID() - let (answers, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingOldest(1)) - guard set([Self.askCard(tab: tab, text: text)], tab: tab, services: services, answer: { _, action, _ in continuation.yield(action == "start") }) else { + let (answers, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingOldest(1)) + guard set([Self.askCard(tab: tab, text: text)], tab: tab, services: services, answer: { _, action, _ in continuation.yield(action == "start" ? .start : .notNow) }) else { continuation.finish() _ = set([], tab: tab, services: services) { _, _, _ in } throw ActionFailure(message: CloudStartSessionStrings.noPage(asked)) @@ -33,22 +38,37 @@ final class CloudStartSessionAsks { let deadline = Task { // wakeup-allow: one-shot deadline for the person's answer on the card try? await Task.sleep(for: Self.wait) - continuation.yield(nil) + continuation.yield(.expired) + } + // task-owner: the card's watch on the signed-in account; cancelled below with the card. + let watch = Task { @MainActor in + for await signedIn in ObservationStream(now) where signedIn != account { + continuation.yield(.accountChanged) + return + } } defer { deadline.cancel() + watch.cancel() continuation.finish() } - var answer: Bool? + var answer = Answer.expired for await value in answers { answer = value break } - guard answer == true else { + switch answer { + case .start: + return token + case .notNow, .expired, .accountChanged: finish(tab: tab, token: token, failure: nil, services: services) - throw ActionFailure(message: answer == false ? CloudStartSessionStrings.declined(asked) : CloudStartSessionStrings.expired(asked)) + let message = switch answer { + case .notNow: CloudStartSessionStrings.declined(asked) + case .accountChanged: CloudStartSessionStrings.accountChanged(asked) + default: CloudStartSessionStrings.expired(asked) + } + throw ActionFailure(message: message) } - return token } /// The card while its run goes on; only the ask that owns it changes it. @@ -125,8 +145,9 @@ final class CloudStartSessionRuns { private final class Run { var task: Task? - /// Callers waiting for its end now. - var waiting = 0 + /// Callers waiting for its end now, each resumed when it ends, or removed at its own deadline. + var waiters: [UUID: AsyncStream.Continuation] = [:] + var waiting: Int { waiters.count } var outcome: Outcome? /// When it ended with nobody waiting (kept for the next same ask). var kept: ContinuousClock.Instant? @@ -174,11 +195,11 @@ final class CloudStartSessionRuns { /// run's end (handed out once), the one going on, else `work`, started now. func answer(_ key: Key, within: Duration, pending: Fields, _ work: @escaping @MainActor () async throws -> Fields) async throws -> Fields { let run = take(key, work) - if let outcome = run.outcome, run.kept != nil { return try outcome.get() } - run.waiting += 1 + if let outcome = run.outcome { return try outcome.get() } + let caller = UUID() let (ends, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingOldest(1)) - // task-owner: this caller's wait for the run; ends with the run, which has its own deadlines. - let waiter = Task { @MainActor in continuation.yield(await run.task?.value) } + // The run's end resumes it (``take``); nothing else waits on the run for this caller. + run.waiters[caller] = continuation // task-owner: this caller's answer deadline; cancelled below once the run ends first. let deadline = Task { @MainActor [sleep] in await sleep(within) @@ -186,7 +207,7 @@ final class CloudStartSessionRuns { } defer { deadline.cancel() - waiter.cancel() + run.waiters[caller] = nil continuation.finish() } var ended: Outcome? @@ -194,7 +215,6 @@ final class CloudStartSessionRuns { ended = value break } - run.waiting -= 1 // An end that came as the deadline did still reaches this caller. if let outcome = ended ?? run.outcome { return try outcome.get() } return pending @@ -219,8 +239,10 @@ final class CloudStartSessionRuns { let outcome: Outcome do { outcome = .success(try await work()) } catch { outcome = .failure(error) } run.outcome = outcome + let reached = !run.waiters.isEmpty + for waiter in run.waiters.values { waiter.yield(outcome) } if let self, runs[key] === run { - if run.waiting > 0 { + if reached { runs[key] = nil } else { run.kept = now() @@ -249,4 +271,6 @@ final class CloudStartSessionRuns { private func prune() { trim(keeping: Self.limit - 1) } var keptCount: Int { runs.values.filter { $0.kept != nil }.count } + /// Callers waiting on any run now (tests: a caller that answered pending holds nothing). + var waitingCount: Int { runs.values.reduce(0) { $0 + $1.waiting } } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index 6aca3345e599..d873842a0a9a 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -5,6 +5,7 @@ import CmuxNextAgentPane import CmuxNextCloud import CmuxNextDaemon import Foundation +import Observation import Testing /// `cmux cloud start-session` (Lawrence: "start new VMs from acp chat"): its arguments are checked @@ -140,7 +141,7 @@ struct CloudStartSessionTests { let asks = CloudStartSessionAsks() for _ in 0..<2 { await #expect(throws: ActionFailure(message: CloudStartSessionStrings.noPage(Self.asked))) { - try await asks.ask(tab: "tab-without-page", text: "ask", asked: Self.asked, services: services) + try await asks.ask(tab: "tab-without-page", text: "ask", asked: Self.asked, account: Self.account, now: { Self.account }, services: services) } } } @@ -375,7 +376,7 @@ struct CloudStartSessionTests { let services = Self.fixture().services let asks = CloudStartSessionAsks(), cards = Cards() cards.install(asks) - let first = Task { try await asks.ask(tab: "chat-1", text: "first", asked: Self.asked, services: services) } + let first = Task { try await asks.ask(tab: "chat-1", text: "first", asked: Self.asked, account: Self.account, now: { Self.account }, services: services) } await cards.shownIn("chat-1") #expect(cards.shown["chat-1"]?.first?.state == .ask) cards.click("chat-1", "start") @@ -383,7 +384,7 @@ struct CloudStartSessionTests { // Started: the run goes on, and the chat's card is still the first ask's. asks.show(tab: "chat-1", token: token, .working, "starting", services: services) await #expect(throws: ActionFailure(message: CloudStartSessionStrings.askOpen(Self.asked))) { - _ = try await asks.ask(tab: "chat-1", text: "second", asked: Self.asked, services: services) + _ = try await asks.ask(tab: "chat-1", text: "second", asked: Self.asked, account: Self.account, now: { Self.account }, services: services) } #expect(cards.shown["chat-1"]?.first?.text == "starting") // Another ask's token changes nothing. @@ -400,7 +401,7 @@ struct CloudStartSessionTests { let services = Self.fixture().services let asks = CloudStartSessionAsks(), cards = Cards() cards.install(asks) - let ask = Task { try await asks.ask(tab: "chat-2", text: "ask", asked: Self.asked, services: services) } + let ask = Task { try await asks.ask(tab: "chat-2", text: "ask", asked: Self.asked, account: Self.account, now: { Self.account }, services: services) } await cards.shownIn("chat-2") cards.click("chat-2", "notNow") await #expect(throws: ActionFailure(message: CloudStartSessionStrings.declined(Self.asked))) { _ = try await ask.value } @@ -408,6 +409,25 @@ struct CloudStartSessionTests { #expect(asks.open["chat-2"] == nil) } + @MainActor @Observable final class SignedIn { + var account: [String?] + init(_ account: [String?]) { self.account = account } + } + + /// A sign-out or team switch while the card is open ends it: the run says so, and the chat may ask again. + @Test func anAccountChangeEndsAnOpenCard() async throws { + let services = Self.fixture().services + let asks = CloudStartSessionAsks(), cards = Cards() + cards.install(asks) + let signedIn = SignedIn(Self.account) + let ask = Task { try await asks.ask(tab: "chat-3", text: "ask", asked: Self.asked, account: Self.account, now: { signedIn.account }, services: services) } + await cards.shownIn("chat-3") + signedIn.account = ["user-1", "team-2"] + await #expect(throws: ActionFailure(message: CloudStartSessionStrings.accountChanged(Self.asked))) { _ = try await ask.value } + #expect(cards.shown["chat-3"] == nil) + #expect(asks.open["chat-3"] == nil) + } + // MARK: A run that outlasts its caller's answer /// What the runs' work counts and the clock they read, shared with their closures. @@ -421,6 +441,22 @@ struct CloudStartSessionTests { /// A caller's wait that never ends by itself (the run's end cancels it). static let waits: @MainActor (Duration) async -> Void = { _ in try? await Task.sleep(for: .seconds(3600)) } + /// A caller that answered pending leaves nothing waiting on a run that never ends. + @Test func aCallerThatAnsweredPendingHoldsNothingOnTheRun() async throws { + let runs = CloudStartSessionRuns() + runs.sleep = { _ in } + let (gate, open) = AsyncStream.makeStream() + defer { open.finish() } + let answered = try await runs.answer(Self.key, within: .seconds(1), pending: Self.pending) { + for await _ in gate { break } + return ["session_id": .string("never")] + } + #expect(answered == Self.pending) + #expect(runs.waitingCount == 0) + #expect(runs.count == 1, "the run goes on") + runs.forgetAll() + } + @Test func aRunItsCallerSawEndIsNotKept() async throws { let runs = CloudStartSessionRuns() runs.sleep = Self.waits From 3f7ed6a1019715c0029b21b62dc72e9280b2a792 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 01:42:40 -0700 Subject: [PATCH 17/33] cloud start-session: import CmuxNextCompat for the card's account watch Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift index d774676f3ca4..6bffd16da5ee 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift @@ -1,5 +1,6 @@ import CmuxNextActions import CmuxNextAgentPane +import CmuxNextCompat import Foundation /// An agent's ask to start a Cloud session, as a card in its own chat (``AgentToolCards``): the From 40a06f7c5c05e8b6e5c5df972c6ee8d522ff38c6 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 02:15:34 -0700 Subject: [PATCH 18/33] cmux next: the agent ask and runs registry behind cloud start-session An agent's ask to start a Cloud session shows a card in its own chat (Start, Not now) that only the person's Start lets through; it ends at 2 minutes, on Not now, or when the signed-in account or team changes. CloudStartSessionRuns lets the same command again join a run that outlasted its caller's answer, or take its end once; runs and machine creations are per account, and a sign-out or team switch ends them. The start-session verb that uses them is the next PR. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextApp/Cloud/CloudService.swift | 20 + .../Cloud/CloudStartSessionAsks.swift | 310 ++++++++++++++ .../Cloud/CloudStartSessionStrings.swift | 36 ++ .../Resources/CloudSessions.xcstrings | 168 ++++++++ .../CloudStartSessionAsksTests.swift | 400 ++++++++++++++++++ 5 files changed, 934 insertions(+) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift index e3ac47344a6a..e17f087a229e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift @@ -76,6 +76,10 @@ final class CloudService { /// Agents' asks to move their chats, and the cards they raise in chats. @ObservationIgnored let moveAsks = CloudMoveAsks() @ObservationIgnored let toolCards = AgentToolCards() + /// Agents' asks to start a session on a Cloud machine (``CloudStartSessionAsks``). + @ObservationIgnored let startSessionAsks = CloudStartSessionAsks() + /// `cmux cloud start-session` runs, joined by a repeat of the same ask (``CloudStartSessionRuns``). + @ObservationIgnored let startSessionRuns = CloudStartSessionRuns() /// Moved chats' file requests for this Mac, asked in their chats. @ObservationIgnored let accessAsks = CloudAccessAsks() /// Files the person drops or pastes into a moved chat, sent to its machine (``CloudUploads``). @@ -148,6 +152,7 @@ final class CloudService { CloudMovedChats.app = movedChats link.isAvailable = { [weak self] in self.map { $0.mayCallCloud && !$0.policyDisabled && $0.auth.isSignedIn } ?? false } link.isSignedIn = { [weak self] in self?.auth.isSignedIn ?? false } + startSessionAsks.toolCards = toolCards } static var deviceName: String { MacName().linkDeviceName() } @@ -240,6 +245,17 @@ final class CloudService { await self?.refresh(ifOlderThan: .seconds(30)) } }) + // A sign-out or a team switch (the user signed in may stay) ends start-session asks and + // runs: none goes on in the account signed in after. + observers.append(Task { [weak self] in + guard let self else { return } + var last = account + for await account in ObservationStream({ self.account }) where account != last { + last = account + startSessionRuns.forgetAll() + startSessionAsks.endAll() + } + }) } /// Set while an administrator turned Cloud off (`DisabledFeatures`). @@ -364,6 +380,10 @@ final class CloudService { /// client sends (``CloudAuth/teamID``). private var listAccount: [String?] { [auth.user?.id, auth.teamID] } + /// The user and team signed in now (observable: ``CloudAuth``). Start-session asks and runs + /// belong to the one they were asked in. + var account: [String?] { listAccount } + private func reconcile(_ list: [CloudMachine]) { // A list that arrives after Cloud was turned off connects nothing. guard !policyDisabled else { return } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift new file mode 100644 index 000000000000..31c9cbc58f6c --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift @@ -0,0 +1,310 @@ +import CmuxNextActions +import CmuxNextAgentPane +import CmuxNextCompat +import Foundation + +/// An agent's ask to start a Cloud session, as a card in its own chat (``AgentToolCards``): the +/// agent's run waits for the person's Start or Not now, for at most ``wait``, and while the +/// account it was asked in stays signed in. The ask owns its chat's card from the ask until its +/// run ends (``finish(tab:token:failure:)``): another ask in that chat meanwhile is refused, and +/// never replaces it. A sign-out or team switch ends every card (``endAll()``). +@MainActor +final class CloudStartSessionAsks { + static let owner = "cloudStartSession" + /// How long a card waits for the person; the run says so after. + static let wait: Duration = .seconds(120) + + enum Answer: Sendable { case start, notNow, expired, accountChanged } + + private struct Open { + var token: UUID + var asked: CloudStartSessionStrings.Asked + /// The ask's wait for the person's answer; nil once they clicked Start. + var answer: AsyncStream.Continuation? + } + + /// The ask that owns each chat's card, by tab. + private var open: [String: Open] = [:] + /// Where cards show: the app's tool cards (set by ``CloudService``), or a test's ``present``. + weak var toolCards: AgentToolCards? + /// Shows `cards` in chat tab `tab` (none removes them), each click going to the answer; false + /// when the chat has no page. Set by tests in place of the app's tool cards. + var present: (@MainActor (_ cards: [AgentPaneToolCard], _ tab: String, _ answer: @escaping AgentToolCards.Answer) -> Bool)? + + /// The ask that owns chat tab `tab`'s card, if any. + func owner(of tab: String) -> UUID? { open[tab]?.token } + + /// Shows the card in chat tab `tab` and returns its token once the person clicks Start. Throws + /// when they choose Not now, when nobody answers within ``wait``, when the signed-in account + /// stops being `account` (`now`, read on every change), or when the chat has no page; throws + /// `CancellationError` when the run is cancelled. Every way out but Start removes the card. + func ask(tab: String, text: String, asked: CloudStartSessionStrings.Asked, account: [String?], + now: @escaping @MainActor @Sendable () -> [String?]) async throws -> UUID { + guard open[tab] == nil else { throw ActionFailure(message: CloudStartSessionStrings.askOpen(asked)) } + let token = UUID() + let (answers, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingOldest(1)) + guard set([Self.askCard(tab: tab, text: text)], tab: tab, answer: { _, action, _ in continuation.yield(action == "start" ? .start : .notNow) }) else { + continuation.finish() + _ = set([], tab: tab) { _, _, _ in } + throw ActionFailure(message: CloudStartSessionStrings.noPage(asked)) + } + open[tab] = Open(token: token, asked: asked, answer: continuation) + // task-owner: the card's one-shot deadline; cancelled below once the person answers. + let deadline = Task { + // wakeup-allow: one-shot deadline for the person's answer on the card + try? await Task.sleep(for: Self.wait) + continuation.yield(.expired) + } + // task-owner: the card's watch on the signed-in account; cancelled below with the card. + let watch = Task { @MainActor in + for await signedIn in ObservationStream(now) where signedIn != account { + continuation.yield(.accountChanged) + return + } + } + defer { + deadline.cancel() + watch.cancel() + continuation.finish() + if open[tab]?.token == token { open[tab]?.answer = nil } + } + var answer: Answer? + for await value in answers { + answer = value + break + } + guard let answer else { + // The run was cancelled (its account ended, ``CloudStartSessionRuns/forgetAll()``). + finish(tab: tab, token: token, failure: nil) + throw CancellationError() + } + switch answer { + case .start: + return token + case .notNow, .expired, .accountChanged: + finish(tab: tab, token: token, failure: nil) + let message = switch answer { + case .notNow: CloudStartSessionStrings.declined(asked) + case .accountChanged: CloudStartSessionStrings.accountChanged(asked) + default: CloudStartSessionStrings.expired(asked) + } + throw ActionFailure(message: message) + } + } + + /// The card while its run goes on; only the ask that owns it changes it. + func show(tab: String, token: UUID, _ state: AgentPaneToolCard.State, _ text: String) { + guard open[tab]?.token == token else { return } + _ = set([card(tab, state, text, [])], tab: tab) { _, _, _ in } + } + + /// The run ended: the card goes, or says why it stopped with Dismiss; the chat may ask again. + func finish(tab: String, token: UUID, failure: String?) { + guard open[tab]?.token == token else { return } + open[tab] = nil + guard let failure else { return _ = set([], tab: tab) { _, _, _ in } } + let dismiss = AgentPaneToolCard.Action(id: "dismiss", title: CloudMoveAskStrings.dismiss) + _ = set([card(tab, .failed, failure, [dismiss])], tab: tab) { [weak self] _, _, _ in + // A later ask in this chat owns the card from then on; its Dismiss is its own. + guard let self, open[tab] == nil else { return } + _ = set([], tab: tab) { _, _, _ in } + } + } + + /// The account the asks were made in ended: a card still asking ends its ask (which removes + /// it), and one whose run goes on says the run stopped. Called by ``CloudService`` on a + /// sign-out or team switch, beside ``CloudStartSessionRuns/forgetAll()``. + func endAll() { + for (tab, ask) in open { + if let answer = ask.answer { + answer.yield(.accountChanged) + } else { + finish(tab: tab, token: ask.token, failure: CloudStartSessionStrings.accountChanged(ask.asked)) + } + } + } + + /// The card that asks: the agent's ask in `text`, with Start and Not now. + static func askCard(tab: String, text: String) -> AgentPaneToolCard { + let actions = [AgentPaneToolCard.Action(id: "start", title: CloudStartSessionStrings.start), + AgentPaneToolCard.Action(id: "notNow", title: CloudMoveAskStrings.notNow)] + return AgentPaneToolCard(id: "cloud-start-session-" + tab, state: .ask, text: text, actions: actions) + } + + private func card(_ tab: String, _ state: AgentPaneToolCard.State, _ text: String, _ actions: [AgentPaneToolCard.Action]) -> AgentPaneToolCard { + AgentPaneToolCard(id: "cloud-start-session-" + tab, state: state, text: text, actions: actions) + } + + private func set(_ cards: [AgentPaneToolCard], tab: String, answer: @escaping AgentToolCards.Answer) -> Bool { + if let present { return present(cards, tab, answer) } + return toolCards?.set(cards, owner: Self.owner, tab: tab, answer: answer) ?? false + } +} + +/// `cmux cloud start-session` runs by what they ask for. Each caller waits for its run at most +/// its answer time, then answers `pending`, and the run goes on. The same ask again while a run +/// goes on waits for that run. A run that ends with a caller still waiting has reached it and is +/// dropped; one that ends after every caller answered `pending` is kept for the next same ask, +/// which gets its end once. A kept run lasts at most ``kept`` (checked on every ask) and at most +/// ``limit`` are kept, trimmed when a run starts and when one is kept; no timer. Runs and +/// creations are per account (user and team): another account's ask never joins or takes them, +/// and a sign-out or team switch ends them (``forgetAll()``). +@MainActor +final class CloudStartSessionRuns { + struct Key: Hashable { + /// `id:` or `name:`. + var machine: String + var agent: String + var cwd: String? + /// The agent chat that asked; nil for a person's or a script's run. + var caller: String? + /// The user and team signed in when it was asked (``CloudService/account``). + var account: [String?] + } + + private struct Creation: Hashable { + var name: String + var account: [String?] + } + + typealias Fields = [String: ActionResultValue] + typealias Outcome = Result + + private final class Run { + var task: Task? + /// Callers waiting for its end now: each is resumed when it ends, with the failure it + /// gets if the run is forgotten first, or removes itself at its own deadline. + var waiters: [UUID: (end: AsyncStream.Continuation, forgotten: any Error)] = [:] + var outcome: Outcome? + /// When it ended with nobody waiting (kept for the next same ask). + var kept: ContinuousClock.Instant? + } + + static let kept: Duration = .seconds(600) + static let limit = 32 + private var runs: [Key: Run] = [:] + /// Creations of a machine by name and account, each with its own token: two asks for one new + /// name create one machine. + private var creations: [Creation: (token: UUID, task: Task)] = [:] + /// Called when an ask joins a creation already going on (tests wait on it). + var joinedCreation: @MainActor (String) -> Void = { _ in } + var now: @MainActor () -> ContinuousClock.Instant = { ContinuousClock.now } + /// The caller's wait: a one-shot sleep, cancelled when the run ends first. + var sleep: @MainActor (Duration) async -> Void = { duration in + // wakeup-allow: one-shot answer deadline of one caller; cancelled when its run ends first + try? await Task.sleep(for: duration) + } + + /// The machine `create` makes for `name` in `account`, or the one a creation already going on + /// for both makes. + func creating(_ name: String, account: [String?], _ create: @escaping @MainActor () async throws -> CloudMachineSession) async throws -> CloudMachineSession { + let key = Creation(name: name, account: account) + if let running = creations[key] { + joinedCreation(name) + return try await running.task.value + } + let token = UUID() + // task-owner: one creation per name and account; removed when it ends or by forgetAll(). + let task = Task { @MainActor in try await create() } + creations[key] = (token, task) + defer { if creations[key]?.token == token { creations[key] = nil } } + return try await task.value + } + + /// The end of `key`'s run, or `pending` once `within` has passed (the run goes on): a kept + /// run's end (handed out once), the one going on, else `work`, started now. If the runs are + /// forgotten while this caller waits (``forgetAll()``), it gets `forgotten`. + func answer(_ key: Key, within: Duration, pending: Fields, forgotten: any Error, + _ work: @escaping @MainActor () async throws -> Fields) async throws -> Fields { + let run = take(key, work) + if let outcome = run.outcome { return try outcome.get() } + let caller = UUID() + let (ends, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingOldest(1)) + // The run's end resumes it (``take``), or ``forgetAll()``; nothing else waits on the run. + run.waiters[caller] = (continuation, forgotten) + // task-owner: this caller's answer deadline; cancelled below once the run ends first. + let deadline = Task { @MainActor [sleep] in + await sleep(within) + continuation.yield(nil) + } + defer { + deadline.cancel() + run.waiters[caller] = nil + continuation.finish() + } + var ended: Outcome? + for await value in ends { + ended = value + break + } + // An end that came as the deadline did still reaches this caller. + if let outcome = ended ?? run.outcome { return try outcome.get() } + return pending + } + + /// The account the runs were asked in ended: each waiting caller gets its `forgotten` + /// failure now, every run and creation is cancelled, and none is kept or joined after. + func forgetAll() { + for run in runs.values { + for waiter in run.waiters.values { waiter.end.yield(.failure(waiter.forgotten)) } + run.waiters = [:] + run.task?.cancel() + } + for creation in creations.values { creation.task.cancel() } + runs = [:] + creations = [:] + } + + /// The run of `key` to answer: a kept one (taken out, its end handed out once), the one going + /// on, else a new one. + private func take(_ key: Key, _ work: @escaping @MainActor () async throws -> Fields) -> Run { + if let run = runs[key] { + if let kept = run.kept { + runs[key] = nil + if now() - kept <= Self.kept { return run } + } else { + return run + } + } + prune() + let run = Run() + runs[key] = run + // task-owner: the start itself; it runs to its end even after its callers answered pending. + run.task = Task { @MainActor [weak self] in + let outcome: Outcome + do { outcome = .success(try await work()) } catch { outcome = .failure(error) } + run.outcome = outcome + let reached = !run.waiters.isEmpty + for waiter in run.waiters.values { waiter.end.yield(outcome) } + if let self, runs[key] === run { + if reached { + runs[key] = nil + } else { + run.kept = now() + trim(keeping: Self.limit) + } + } + return outcome + } + return run + } + + /// Waits for `key`'s run to end (tests). + func settled(_ key: Key) async { _ = await runs[key]?.task?.value } + + var count: Int { runs.count } + var keptCount: Int { runs.values.filter { $0.kept != nil }.count } + /// Callers waiting on any run now (tests: a caller that answered pending holds nothing). + var waitingCount: Int { runs.values.reduce(0) { $0 + $1.waiters.count } } + + /// Drops expired kept runs, then the oldest kept ones beyond `keeping`. + private func trim(keeping: Int) { + let cutoff = now() - Self.kept + runs = runs.filter { $0.value.kept.map { $0 > cutoff } ?? true } + let kept = runs.compactMap { key, run in run.kept.map { (key, $0) } }.sorted { $0.1 < $1.1 } + for (key, _) in kept.prefix(max(0, kept.count - keeping)) { runs[key] = nil } + } + + /// Before a new run: room for it among the kept ones. + private func prune() { trim(keeping: Self.limit - 1) } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift new file mode 100644 index 000000000000..f86ca486936c --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift @@ -0,0 +1,36 @@ +import Foundation + +/// What `cmux cloud start-session` says (``CloudStartSessionAsks``): an agent's ask in its chat, +/// and why it stopped. Each refusal names the machine and the agent and gives the command to run +/// again. Keys live in Resources/CloudSessions.xcstrings with every app locale. +enum CloudStartSessionStrings { + /// What a refusal of an agent's ask names: the machine, the agent and the command to run again. + struct Asked: Equatable { + var machine: String + var agent: String + var command: String + } + + static var start: String { String(localized: "cloud.startSession.start", defaultValue: "Start", table: "CloudSessions", bundle: .module) } + + static func noPage(_ asked: Asked) -> String { + String(format: String(localized: "cloud.startSession.noPage", defaultValue: "Couldn't show the question in the chat, so %2$@ didn't start on %1$@. Run `%3$@` again once the chat is open.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) + } + + static func askOpen(_ asked: Asked) -> String { + String(format: String(localized: "cloud.startSession.askOpen", defaultValue: "This chat already asks the person to start a Cloud session. Wait for their answer, then run `%3$@` again if %2$@ still isn't running on %1$@.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) + } + + static func declined(_ asked: Asked) -> String { + String(format: String(localized: "cloud.startSession.declined", defaultValue: "The person chose Not now, so %2$@ didn't start on %1$@. Ask them in the chat before you run `%3$@` again.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) + } + + static func expired(_ asked: Asked) -> String { + String(format: String(localized: "cloud.startSession.expired", defaultValue: "Nobody answered the question in the chat within 2 minutes, so %2$@ didn't start on %1$@. Run `%3$@` again when the person is there.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) + } + + /// The signed-in account or team changed while the ask or its run waited. + static func accountChanged(_ asked: Asked) -> String { + String(format: String(localized: "cloud.startSession.accountChanged", defaultValue: "Your Cloud account or team changed after this was asked, so %2$@ didn't start on %1$@. Run `%3$@` again in the account you want it in.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings new file mode 100644 index 000000000000..8b538d50e32b --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings @@ -0,0 +1,168 @@ +{ + "sourceLanguage": "en", + "strings": { + "cloud.startSession.start": { + "comment": "Button on the card: start the agent session.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Start"}}, + "ja": {"stringUnit": {"state": "translated", "value": "開始"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Starten"}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Démarrer"}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Iniciar"}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "بدء"}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "시작"}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "启动"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "啟動"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Pokreni"}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Start"}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Avvia"}}, + "km": {"stringUnit": {"state": "needs_review", "value": "ចាប់ផ្ដើម"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Start"}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Uruchom"}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Iniciar"}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Запустить"}}, + "th": {"stringUnit": {"state": "needs_review", "value": "เริ่ม"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Başlat"}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Запустити"}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Bắt đầu"}} + } + }, + "cloud.startSession.noPage": { + "comment": "The card couldn't show in the chat. %1$@ machine, %2$@ agent, %3$@ the command (keep it in backticks).", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Couldn't show the question in the chat, so %2$@ didn't start on %1$@. Run `%3$@` again once the chat is open."}}, + "ja": {"stringUnit": {"state": "translated", "value": "チャットに確認を表示できなかったため、%1$@ で %2$@ は開始されませんでした。チャットを開いてから `%3$@` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Die Frage konnte im Chat nicht angezeigt werden, daher wurde %2$@ auf %1$@ nicht gestartet. Führe `%3$@` erneut aus, sobald der Chat geöffnet ist."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Impossible d'afficher la question dans la discussion : %2$@ n'a pas démarré sur %1$@. Relancez `%3$@` une fois la discussion ouverte."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "No se pudo mostrar la pregunta en el chat, así que %2$@ no se inició en %1$@. Vuelve a ejecutar `%3$@` cuando el chat esté abierto."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "تعذّر عرض السؤال في الدردشة، لذا لم يبدأ %2$@ على %1$@. شغّل `%3$@` مرة أخرى بعد فتح الدردشة."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "채팅에 질문을 표시하지 못해 %1$@에서 %2$@이(가) 시작되지 않았습니다. 채팅이 열리면 `%3$@`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "无法在聊天中显示问题,因此 %2$@ 没有在 %1$@ 上启动。聊天打开后请再次运行 `%3$@`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "無法在聊天中顯示問題,因此 %2$@ 沒有在 %1$@ 上啟動。聊天開啟後請再次執行 `%3$@`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Nije moguće prikazati pitanje u razgovoru, pa se %2$@ nije pokrenuo na %1$@. Ponovo pokrenite `%3$@` kada razgovor bude otvoren."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Kunne ikke vise spørgsmålet i chatten, så %2$@ startede ikke på %1$@. Kør `%3$@` igen, når chatten er åben."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Impossibile mostrare la domanda nella chat, quindi %2$@ non è stato avviato su %1$@. Esegui di nuovo `%3$@` quando la chat è aperta."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "មិនអាចបង្ហាញសំណួរក្នុងការជជែកបានទេ ដូច្នេះ %2$@ មិនបានចាប់ផ្ដើមនៅលើ %1$@ ទេ។ ដំណើរការ `%3$@` ម្ដងទៀតនៅពេលការជជែកបើក។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Kunne ikke vise spørsmålet i chatten, så %2$@ startet ikke på %1$@. Kjør `%3$@` på nytt når chatten er åpen."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Nie można pokazać pytania w czacie, więc %2$@ nie uruchomił się na %1$@. Uruchom ponownie `%3$@`, gdy czat będzie otwarty."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Não foi possível mostrar a pergunta no chat, então %2$@ não iniciou em %1$@. Execute `%3$@` novamente quando o chat estiver aberto."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Не удалось показать вопрос в чате, поэтому %2$@ не запущен на %1$@. Снова выполните `%3$@`, когда чат будет открыт."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "แสดงคำถามในแชทไม่ได้ %2$@ จึงไม่ได้เริ่มบน %1$@ เรียกใช้ `%3$@` อีกครั้งเมื่อแชทเปิดอยู่"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Soru sohbette gösterilemedi, bu yüzden %2$@ %1$@ üzerinde başlamadı. Sohbet açıkken `%3$@` komutunu yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Не вдалося показати запитання в чаті, тому %2$@ не запущено на %1$@. Знову виконайте `%3$@`, коли чат буде відкрито."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Không thể hiển thị câu hỏi trong cuộc trò chuyện nên %2$@ chưa khởi động trên %1$@. Chạy lại `%3$@` khi cuộc trò chuyện đang mở."}} + } + }, + "cloud.startSession.askOpen": { + "comment": "A second ask while the chat's card is still open. %1$@ machine, %2$@ agent, %3$@ the command (keep it in backticks).", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "This chat already asks the person to start a Cloud session. Wait for their answer, then run `%3$@` again if %2$@ still isn't running on %1$@."}}, + "ja": {"stringUnit": {"state": "translated", "value": "このチャットではすでに Cloud セッションの開始を確認しています。回答を待ち、%1$@ で %2$@ がまだ動いていなければ `%3$@` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Dieser Chat fragt die Person schon, ob eine Cloud-Sitzung starten soll. Warte auf ihre Antwort und führe `%3$@` erneut aus, falls %2$@ auf %1$@ dann noch nicht läuft."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Cette discussion demande déjà à la personne de démarrer une session Cloud. Attendez sa réponse, puis relancez `%3$@` si %2$@ ne tourne toujours pas sur %1$@."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Este chat ya pide a la persona iniciar una sesión de Cloud. Espera su respuesta y vuelve a ejecutar `%3$@` si %2$@ sigue sin ejecutarse en %1$@."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "تطلب هذه الدردشة بالفعل من الشخص بدء جلسة Cloud. انتظر إجابته، ثم شغّل `%3$@` مرة أخرى إذا كان %2$@ لا يزال لا يعمل على %1$@."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "이 채팅은 이미 사용자에게 Cloud 세션 시작을 묻고 있습니다. 답을 기다린 뒤 %1$@에서 %2$@이(가) 아직 실행 중이 아니면 `%3$@`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "此聊天已在询问对方是否启动 Cloud 会话。请等待回答;如果 %2$@ 仍未在 %1$@ 上运行,再次运行 `%3$@`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "此聊天已在詢問對方是否啟動 Cloud 工作階段。請等待回答;如果 %2$@ 仍未在 %1$@ 上執行,再次執行 `%3$@`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Ovaj razgovor već traži od osobe da pokrene Cloud sesiju. Sačekajte odgovor, a zatim ponovo pokrenite `%3$@` ako %2$@ još ne radi na %1$@."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Denne chat spørger allerede personen om at starte en Cloud-session. Vent på svaret, og kør derefter `%3$@` igen, hvis %2$@ stadig ikke kører på %1$@."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Questa chat chiede già alla persona di avviare una sessione Cloud. Attendi la risposta, poi esegui di nuovo `%3$@` se %2$@ non è ancora in esecuzione su %1$@."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "ការជជែកនេះកំពុងសុំឱ្យមនុស្សនោះចាប់ផ្ដើមវគ្គ Cloud រួចហើយ។ រង់ចាំចម្លើយរបស់គេ រួចដំណើរការ `%3$@` ម្ដងទៀត ប្រសិនបើ %2$@ នៅតែមិនដំណើរការនៅលើ %1$@។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Denne chatten spør allerede personen om å starte en Cloud-økt. Vent på svaret, og kjør deretter `%3$@` på nytt hvis %2$@ fortsatt ikke kjører på %1$@."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Ten czat już prosi użytkownika o uruchomienie sesji Cloud. Poczekaj na odpowiedź, a następnie uruchom ponownie `%3$@`, jeśli %2$@ nadal nie działa na %1$@."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Este chat já está pedindo à pessoa para iniciar uma sessão do Cloud. Aguarde a resposta e execute `%3$@` novamente se %2$@ ainda não estiver rodando em %1$@."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Этот чат уже просит пользователя запустить сеанс Cloud. Дождитесь ответа, затем снова выполните `%3$@`, если %2$@ всё ещё не работает на %1$@."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "แชทนี้กำลังขอให้ผู้ใช้เริ่มเซสชัน Cloud อยู่แล้ว รอคำตอบ แล้วเรียกใช้ `%3$@` อีกครั้งหาก %2$@ ยังไม่ทำงานบน %1$@"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Bu sohbet kişiden zaten bir Cloud oturumu başlatmasını istiyor. Yanıtını bekleyin, %2$@ hâlâ %1$@ üzerinde çalışmıyorsa `%3$@` komutunu yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Цей чат уже просить користувача запустити сеанс Cloud. Дочекайтеся відповіді, потім знову виконайте `%3$@`, якщо %2$@ досі не працює на %1$@."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Cuộc trò chuyện này đã đang hỏi người dùng để bắt đầu một phiên Cloud. Hãy chờ câu trả lời, rồi chạy lại `%3$@` nếu %2$@ vẫn chưa chạy trên %1$@."}} + } + }, + "cloud.startSession.declined": { + "comment": "The person chose Not now on the card. %1$@ machine, %2$@ agent, %3$@ the command (keep it in backticks).", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "The person chose Not now, so %2$@ didn't start on %1$@. Ask them in the chat before you run `%3$@` again."}}, + "ja": {"stringUnit": {"state": "translated", "value": "「後で」が選ばれたため、%1$@ で %2$@ は開始されませんでした。`%3$@` をもう一度実行する前に、チャットで確認してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Die Person hat „Nicht jetzt“ gewählt, daher wurde %2$@ auf %1$@ nicht gestartet. Frag sie im Chat, bevor du `%3$@` erneut ausführst."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "La personne a choisi Pas maintenant : %2$@ n'a pas démarré sur %1$@. Demandez-lui dans la discussion avant de relancer `%3$@`."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "La persona eligió Ahora no, así que %2$@ no se inició en %1$@. Pregúntale en el chat antes de volver a ejecutar `%3$@`."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "اختار الشخص «ليس الآن»، لذا لم يبدأ %2$@ على %1$@. اسأله في الدردشة قبل تشغيل `%3$@` مرة أخرى."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "사용자가 나중에를 선택해 %1$@에서 %2$@이(가) 시작되지 않았습니다. `%3$@`을(를) 다시 실행하기 전에 채팅에서 물어보세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "对方选择了“以后再说”,因此 %2$@ 没有在 %1$@ 上启动。再次运行 `%3$@` 前请先在聊天中询问。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "對方選擇了「以後再說」,因此 %2$@ 沒有在 %1$@ 上啟動。再次執行 `%3$@` 前請先在聊天中詢問。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Osoba je odabrala „Ne sada“, pa se %2$@ nije pokrenuo na %1$@. Pitajte je u razgovoru prije nego što ponovo pokrenete `%3$@`."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Personen valgte »Ikke nu«, så %2$@ startede ikke på %1$@. Spørg vedkommende i chatten, før du kører `%3$@` igen."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "La persona ha scelto «Non ora», quindi %2$@ non è stato avviato su %1$@. Chiedile nella chat prima di eseguire di nuovo `%3$@`."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "មនុស្សនោះបានជ្រើស «មិនមែនឥឡូវនេះ» ដូច្នេះ %2$@ មិនបានចាប់ផ្ដើមនៅលើ %1$@ ទេ។ សួរគេក្នុងការជជែក មុនពេលអ្នកដំណើរការ `%3$@` ម្ដងទៀត។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Personen valgte «Ikke nå», så %2$@ startet ikke på %1$@. Spør vedkommende i chatten før du kjører `%3$@` på nytt."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Użytkownik wybrał „Nie teraz”, więc %2$@ nie uruchomił się na %1$@. Zapytaj go w czacie, zanim ponownie uruchomisz `%3$@`."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "A pessoa escolheu “Agora não”, então %2$@ não iniciou em %1$@. Pergunte a ela no chat antes de executar `%3$@` novamente."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Пользователь выбрал «Не сейчас», поэтому %2$@ не запущен на %1$@. Спросите его в чате, прежде чем снова выполнять `%3$@`."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "ผู้ใช้เลือก “ไม่ใช่ตอนนี้” %2$@ จึงไม่ได้เริ่มบน %1$@ ถามผู้ใช้ในแชทก่อนเรียกใช้ `%3$@` อีกครั้ง"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Kişi “Şimdi değil” seçeneğini seçti, bu yüzden %2$@ %1$@ üzerinde başlamadı. `%3$@` komutunu yeniden çalıştırmadan önce sohbette ona sorun."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Користувач вибрав «Не зараз», тому %2$@ не запущено на %1$@. Запитайте його в чаті, перш ніж знову виконувати `%3$@`."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Người dùng đã chọn “Để sau” nên %2$@ chưa khởi động trên %1$@. Hãy hỏi họ trong cuộc trò chuyện trước khi chạy lại `%3$@`."}} + } + }, + "cloud.startSession.expired": { + "comment": "Nobody answered the card in time. %1$@ machine, %2$@ agent, %3$@ the command (keep it in backticks).", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Nobody answered the question in the chat within 2 minutes, so %2$@ didn't start on %1$@. Run `%3$@` again when the person is there."}}, + "ja": {"stringUnit": {"state": "translated", "value": "2 分以内にチャットの確認に回答がなかったため、%1$@ で %2$@ は開始されませんでした。本人がいるときに `%3$@` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Niemand hat die Frage im Chat innerhalb von 2 Minuten beantwortet, daher wurde %2$@ auf %1$@ nicht gestartet. Führe `%3$@` erneut aus, wenn die Person da ist."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Personne n'a répondu à la question dans la discussion en 2 minutes : %2$@ n'a pas démarré sur %1$@. Relancez `%3$@` quand la personne est là."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Nadie respondió a la pregunta del chat en 2 minutos, así que %2$@ no se inició en %1$@. Vuelve a ejecutar `%3$@` cuando la persona esté."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "لم يُجب أحد عن السؤال في الدردشة خلال دقيقتين، لذا لم يبدأ %2$@ على %1$@. شغّل `%3$@` مرة أخرى عندما يكون الشخص موجودًا."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "2분 안에 채팅의 질문에 답한 사람이 없어 %1$@에서 %2$@이(가) 시작되지 않았습니다. 사용자가 있을 때 `%3$@`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "2 分钟内没有人回答聊天中的问题,因此 %2$@ 没有在 %1$@ 上启动。对方在时请再次运行 `%3$@`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "2 分鐘內沒有人回答聊天中的問題,因此 %2$@ 沒有在 %1$@ 上啟動。對方在時請再次執行 `%3$@`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Niko nije odgovorio na pitanje u razgovoru u roku od 2 minute, pa se %2$@ nije pokrenuo na %1$@. Ponovo pokrenite `%3$@` kada osoba bude tu."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Ingen svarede på spørgsmålet i chatten inden for 2 minutter, så %2$@ startede ikke på %1$@. Kør `%3$@` igen, når personen er der."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Nessuno ha risposto alla domanda nella chat entro 2 minuti, quindi %2$@ non è stato avviato su %1$@. Esegui di nuovo `%3$@` quando la persona è presente."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "គ្មាននរណាឆ្លើយសំណួរក្នុងការជជែកក្នុងរយៈពេល 2 នាទីទេ ដូច្នេះ %2$@ មិនបានចាប់ផ្ដើមនៅលើ %1$@ ទេ។ ដំណើរការ `%3$@` ម្ដងទៀតនៅពេលមនុស្សនោះនៅទីនោះ។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Ingen svarte på spørsmålet i chatten innen 2 minutter, så %2$@ startet ikke på %1$@. Kjør `%3$@` på nytt når personen er til stede."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Nikt nie odpowiedział na pytanie w czacie w ciągu 2 minut, więc %2$@ nie uruchomił się na %1$@. Uruchom ponownie `%3$@`, gdy użytkownik będzie dostępny."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Ninguém respondeu à pergunta no chat em 2 minutos, então %2$@ não iniciou em %1$@. Execute `%3$@` novamente quando a pessoa estiver presente."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Никто не ответил на вопрос в чате за 2 минуты, поэтому %2$@ не запущен на %1$@. Снова выполните `%3$@`, когда пользователь будет на месте."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "ไม่มีใครตอบคำถามในแชทภายใน 2 นาที %2$@ จึงไม่ได้เริ่มบน %1$@ เรียกใช้ `%3$@` อีกครั้งเมื่อผู้ใช้อยู่"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "Sohbetteki soruyu 2 dakika içinde kimse yanıtlamadı, bu yüzden %2$@ %1$@ üzerinde başlamadı. Kişi oradayken `%3$@` komutunu yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Ніхто не відповів на запитання в чаті протягом 2 хвилин, тому %2$@ не запущено на %1$@. Знову виконайте `%3$@`, коли користувач буде на місці."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Không ai trả lời câu hỏi trong cuộc trò chuyện trong vòng 2 phút nên %2$@ chưa khởi động trên %1$@. Chạy lại `%3$@` khi người dùng có mặt."}} + } + }, + "cloud.startSession.accountChanged": { + "comment": "The signed-in Cloud account or team changed while the run waited. %1$@ machine, %2$@ agent, %3$@ the command (keep it in backticks).", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Your Cloud account or team changed after this was asked, so %2$@ didn't start on %1$@. Run `%3$@` again in the account you want it in."}}, + "ja": {"stringUnit": {"state": "translated", "value": "確認の後に Cloud のアカウントまたはチームが変わったため、%1$@ で %2$@ は開始されませんでした。使いたいアカウントで `%3$@` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Dein Cloud-Konto oder Team hat sich nach der Anfrage geändert, daher wurde %2$@ auf %1$@ nicht gestartet. Führe `%3$@` im gewünschten Konto erneut aus."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Votre compte ou équipe Cloud a changé après la demande : %2$@ n'a pas démarré sur %1$@. Relancez `%3$@` dans le compte voulu."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Tu cuenta o equipo de Cloud cambió después de la solicitud, así que %2$@ no se inició en %1$@. Vuelve a ejecutar `%3$@` en la cuenta que quieras."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "تغيّر حساب Cloud أو الفريق بعد الطلب، لذا لم يبدأ %2$@ على %1$@. شغّل `%3$@` مرة أخرى في الحساب الذي تريده."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "요청 후 Cloud 계정 또는 팀이 바뀌어 %1$@에서 %2$@이(가) 시작되지 않았습니다. 원하는 계정에서 `%3$@`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "提出请求后你的 Cloud 帐户或团队已更改,因此 %2$@ 没有在 %1$@ 上启动。请在你想要的帐户中再次运行 `%3$@`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "提出請求後你的 Cloud 帳號或團隊已變更,因此 %2$@ 沒有在 %1$@ 上啟動。請在你想要的帳號中再次執行 `%3$@`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Vaš Cloud račun ili tim se promijenio nakon zahtjeva, pa %2$@ nije pokrenut na %1$@. Ponovo pokrenite `%3$@` u računu u kojem ga želite."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Din Cloud-konto eller dit team ændrede sig efter anmodningen, så %2$@ startede ikke på %1$@. Kør `%3$@` igen i den konto, du vil bruge."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Il tuo account o team Cloud è cambiato dopo la richiesta, quindi %2$@ non è stato avviato su %1$@. Esegui di nuovo `%3$@` nell'account che vuoi usare."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "គណនី ឬក្រុម Cloud របស់អ្នកបានប្តូរបន្ទាប់ពីសំណើនេះ ដូច្នេះ %2$@ មិនបានចាប់ផ្តើមលើ %1$@ ទេ។ ដំណើរការ `%3$@` ម្តងទៀតក្នុងគណនីដែលអ្នកចង់ប្រើ។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Cloud-kontoen eller teamet ditt endret seg etter forespørselen, så %2$@ startet ikke på %1$@. Kjør `%3$@` igjen i kontoen du vil bruke."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Twoje konto lub zespół Cloud zmieniły się po prośbie, więc %2$@ nie uruchomiono na %1$@. Uruchom `%3$@` ponownie na koncie, którego chcesz użyć."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Sua conta ou equipe do Cloud mudou depois do pedido, então %2$@ não foi iniciado em %1$@. Execute `%3$@` de novo na conta que você quer usar."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Аккаунт или команда Cloud изменились после запроса, поэтому %2$@ не запущен на %1$@. Снова выполните `%3$@` в нужном аккаунте."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "บัญชีหรือทีม Cloud ของคุณเปลี่ยนไปหลังจากคำขอนี้ %2$@ จึงไม่ได้เริ่มบน %1$@ เรียกใช้ `%3$@` อีกครั้งในบัญชีที่คุณต้องการ"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "İstekten sonra Cloud hesabınız veya ekibiniz değişti, bu yüzden %2$@ %1$@ üzerinde başlamadı. `%3$@` komutunu istediğiniz hesapta yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Обліковий запис або команда Cloud змінилися після запиту, тому %2$@ не запущено на %1$@. Знову виконайте `%3$@` у потрібному обліковому записі."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Tài khoản hoặc nhóm Cloud của bạn đã thay đổi sau yêu cầu này, nên %2$@ chưa khởi động trên %1$@. Hãy chạy lại `%3$@` trong tài khoản bạn muốn dùng."}} + } + } + }, + "version": "1.0" +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift new file mode 100644 index 000000000000..4aa06de1be21 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift @@ -0,0 +1,400 @@ +import AppKit +import CmuxNextActions +import CmuxNextAgentPane +@testable import CmuxNextApp +import Foundation +import Observation +import Testing + +/// The agent ask behind `cmux cloud start-session` (Lawrence: "start new VMs from acp chat"): a +/// card in the agent's own chat that only the person's Start lets through, and the runs registry +/// that lets the same command again pick up a run that outlasted its caller's answer. +@MainActor @Suite(.serialized) +struct CloudStartSessionAsksTests { + static let account: [String?] = ["user-1", "team-1"] + static let asked = CloudStartSessionStrings.Asked(machine: "dev", agent: "claude", command: "cmux cloud start-session --target machine:vm-x --agent claude") + static let forgotten = ActionFailure(message: CloudStartSessionStrings.accountChanged(Self.asked)) + + // MARK: The card belongs to its ask until the run ends + + /// Cards shown in place of a chat page, the clicks a test sends them, and a signal per card shown. + final class Cards { + var shown: [String: [AgentPaneToolCard]] = [:] + var answers: [String: AgentToolCards.Answer] = [:] + let (events, event) = AsyncStream.makeStream() + + func install(_ asks: CloudStartSessionAsks) { + asks.present = { [unowned self] cards, tab, answer in + shown[tab] = cards.isEmpty ? nil : cards + answers[tab] = answer + if !cards.isEmpty { event.yield(tab) } + return true + } + } + + /// Waits until a card shows in `tab` (the ask reached its card). + func shownIn(_ tab: String) async { + for await shownTab in events where shownTab == tab { return } + } + + func click(_ tab: String, _ action: String) { answers[tab]?("cloud-start-session-" + tab, action, nil) } + } + + static func ask(_ asks: CloudStartSessionAsks, _ tab: String, now: @escaping @MainActor @Sendable () -> [String?] = { Self.account }) -> Task { + Task { try await asks.ask(tab: tab, text: "ask", asked: Self.asked, account: Self.account, now: now) } + } + + @Test func aCardThatCannotShowStartsNothingAndLeavesNoAskOpen() async { + let asks = CloudStartSessionAsks() + for _ in 0..<2 { + await #expect(throws: ActionFailure(message: CloudStartSessionStrings.noPage(Self.asked))) { + try await asks.ask(tab: "tab-without-page", text: "ask", asked: Self.asked, account: Self.account, now: { Self.account }) + } + } + #expect(asks.owner(of: "tab-without-page") == nil) + } + + @Test func theCardAsksWithStartAndNotNow() { + let card = CloudStartSessionAsks.askCard(tab: "chat", text: "The agent asks to start a claude session on Cloud machine dev.") + #expect(card.state == .ask) + #expect(card.actions.map(\.id) == ["start", "notNow"]) + #expect(card.actions.map(\.title) == [CloudStartSessionStrings.start, CloudMoveAskStrings.notNow]) + } + + @Test func aSecondAskWhileTheFirstRunsNeverTakesItsCard() async throws { + let asks = CloudStartSessionAsks(), cards = Cards() + cards.install(asks) + let first = Self.ask(asks, "chat-1") + await cards.shownIn("chat-1") + #expect(cards.shown["chat-1"]?.first?.state == .ask) + cards.click("chat-1", "start") + let token = try await first.value + // Started: the run goes on, and the chat's card is still the first ask's. + asks.show(tab: "chat-1", token: token, .working, "starting") + await #expect(throws: ActionFailure(message: CloudStartSessionStrings.askOpen(Self.asked))) { + _ = try await asks.ask(tab: "chat-1", text: "second", asked: Self.asked, account: Self.account, now: { Self.account }) + } + #expect(cards.shown["chat-1"]?.first?.text == "starting") + // Another ask's token changes nothing. + asks.finish(tab: "chat-1", token: UUID(), failure: "not yours") + #expect(cards.shown["chat-1"]?.first?.text == "starting") + asks.finish(tab: "chat-1", token: token, failure: "Machine `dev` started but its agent didn't (x).") + #expect(cards.shown["chat-1"]?.first?.state == .failed) + #expect(asks.owner(of: "chat-1") == nil, "the chat may ask again") + cards.click("chat-1", "dismiss") + #expect(cards.shown["chat-1"] == nil) + } + + @Test func notNowStartsNothingAndFreesTheChat() async throws { + let asks = CloudStartSessionAsks(), cards = Cards() + cards.install(asks) + let ask = Self.ask(asks, "chat-2") + await cards.shownIn("chat-2") + cards.click("chat-2", "notNow") + await #expect(throws: ActionFailure(message: CloudStartSessionStrings.declined(Self.asked))) { _ = try await ask.value } + #expect(cards.shown["chat-2"] == nil) + #expect(asks.owner(of: "chat-2") == nil) + } + + @MainActor @Observable final class SignedIn { + var account: [String?] + init(_ account: [String?]) { self.account = account } + } + + /// A sign-out or team switch while the card is open ends it: the run says so, and the chat may ask again. + @Test func anAccountChangeEndsAnOpenCard() async throws { + let asks = CloudStartSessionAsks(), cards = Cards() + cards.install(asks) + let signedIn = SignedIn(Self.account) + let ask = Self.ask(asks, "chat-3", now: { signedIn.account }) + await cards.shownIn("chat-3") + signedIn.account = ["user-1", "team-2"] + await #expect(throws: ActionFailure(message: CloudStartSessionStrings.accountChanged(Self.asked))) { _ = try await ask.value } + #expect(cards.shown["chat-3"] == nil) + #expect(asks.owner(of: "chat-3") == nil) + } + + /// The run of a cancelled ask (its account ended) leaves no card and no ask behind. + @Test func aCancelledAskRemovesItsCard() async throws { + let asks = CloudStartSessionAsks(), cards = Cards() + cards.install(asks) + let ask = Self.ask(asks, "chat-4") + await cards.shownIn("chat-4") + ask.cancel() + await #expect(throws: CancellationError.self) { _ = try await ask.value } + #expect(cards.shown["chat-4"] == nil) + #expect(asks.owner(of: "chat-4") == nil) + } + + /// ``CloudStartSessionAsks/endAll()`` (a sign-out or team switch): a card still asking ends its + /// ask, and one whose run goes on says the run stopped. + @Test func endAllEndsAnAskingCardAndAStartedOne() async throws { + let asks = CloudStartSessionAsks(), cards = Cards() + cards.install(asks) + let started = Self.ask(asks, "chat-5") + await cards.shownIn("chat-5") + cards.click("chat-5", "start") + let token = try await started.value + asks.show(tab: "chat-5", token: token, .working, "starting") + let asking = Self.ask(asks, "chat-6") + await cards.shownIn("chat-6") + asks.endAll() + await #expect(throws: ActionFailure(message: CloudStartSessionStrings.accountChanged(Self.asked))) { _ = try await asking.value } + #expect(cards.shown["chat-6"] == nil) + #expect(cards.shown["chat-5"]?.first?.state == .failed) + #expect(cards.shown["chat-5"]?.first?.text == CloudStartSessionStrings.accountChanged(Self.asked)) + #expect(asks.owner(of: "chat-5") == nil && asks.owner(of: "chat-6") == nil) + } + + // MARK: A run that outlasts its caller's answer + + /// What the runs' work counts and the clock they read, shared with their closures. + @MainActor final class Box { + var value: T + init(_ value: T) { self.value = value } + } + + static let key = CloudStartSessionRuns.Key(machine: "name:dev", agent: "codex", cwd: nil, caller: "chat-9", account: Self.account) + static let pending: CloudStartSessionRuns.Fields = ["pending": .bool(true)] + /// A caller's wait that never ends by itself (the run's end cancels it). + static let waits: @MainActor (Duration) async -> Void = { _ in try? await Task.sleep(for: .seconds(3600)) } + + static func answer(_ runs: CloudStartSessionRuns, _ key: CloudStartSessionRuns.Key = Self.key, within: Duration = .seconds(190), + _ work: @escaping @MainActor () async throws -> CloudStartSessionRuns.Fields) async throws -> CloudStartSessionRuns.Fields { + try await runs.answer(key, within: within, pending: Self.pending, forgotten: Self.forgotten, work) + } + + /// A caller that answered pending leaves nothing waiting on a run that never ends. + @Test func aCallerThatAnsweredPendingHoldsNothingOnTheRun() async throws { + let runs = CloudStartSessionRuns() + runs.sleep = { _ in } + let (gate, open) = AsyncStream.makeStream() + defer { open.finish() } + let answered = try await Self.answer(runs, within: .seconds(1)) { + for await _ in gate { break } + return ["session_id": .string("never")] + } + #expect(answered == Self.pending) + #expect(runs.waitingCount == 0) + #expect(runs.count == 1, "the run goes on") + runs.forgetAll() + } + + /// A sign-out or team switch answers a caller still waiting at once, and cancels its run. + @Test func forgetAllAnswersAWaitingCallerAtOnce() async throws { + let runs = CloudStartSessionRuns() + let (waiting, wait) = AsyncStream.makeStream() + defer { wait.finish() } + runs.sleep = { _ in + wait.yield() + try? await Task.sleep(for: .seconds(3600)) + } + // Never ends by itself: only the run's cancellation ends it. + let (never, keep) = AsyncStream.makeStream() + defer { keep.finish() } + let caller = Task { @MainActor in + try await Self.answer(runs) { + for await _ in never { break } + return ["session_id": .string("late")] + } + } + for await _ in waiting { break } + runs.forgetAll() + await #expect(throws: Self.forgotten) { _ = try await caller.value } + #expect(runs.count == 0) + // The same ask after starts afresh. + runs.sleep = Self.waits + #expect(try await Self.answer(runs) { ["session_id": .string("new")] }["session_id"] == .string("new")) + } + + @Test func aRunItsCallerSawEndIsNotKept() async throws { + let runs = CloudStartSessionRuns() + runs.sleep = Self.waits + let started = Box(0) + let work: @MainActor () async throws -> CloudStartSessionRuns.Fields = { + started.value += 1 + return ["session_id": .string("s-\(started.value)")] + } + #expect(try await Self.answer(runs, work)["session_id"] == .string("s-1")) + #expect(runs.count == 0) + #expect(try await Self.answer(runs, work)["session_id"] == .string("s-2"), "a new session, not the last one") + } + + @Test func theSameAskWhileARunGoesOnWaitsForItAndTakesItsEnd() async throws { + let runs = CloudStartSessionRuns() + let (gate, open) = AsyncStream.makeStream() + defer { open.finish() } + let started = Box(0) + let work: @MainActor () async throws -> CloudStartSessionRuns.Fields = { + started.value += 1 + // A second start (the bug) answers at once: it never reads the first start's gate. + guard started.value == 1 else { return ["session_id": .string("s-\(started.value)")] } + for await _ in gate { break } + return ["session_id": .string("s-1")] + } + // The first caller's wait ends at once: it answers pending, and the run goes on. + runs.sleep = { _ in } + #expect(try await Self.answer(runs, work) == Self.pending) + // The second caller is waiting when its own wait begins: that lets the run end. + runs.sleep = { _ in + open.yield(()) + try? await Task.sleep(for: .seconds(3600)) + } + #expect(try await Self.answer(runs, work)["session_id"] == .string("s-1")) + #expect(started.value == 1) + #expect(runs.count == 0, "it reached a caller, so it is not kept") + } + + @Test func aRunEveryCallerLeftIsHandedToTheNextSameAskOnce() async throws { + let runs = CloudStartSessionRuns() + let (gate, open) = AsyncStream.makeStream() + defer { open.finish() } + let started = Box(0) + let work: @MainActor () async throws -> CloudStartSessionRuns.Fields = { + started.value += 1 + guard started.value == 1 else { return ["session_id": .string("s-\(started.value)")] } + for await _ in gate { break } + return ["session_id": .string("s-1")] + } + runs.sleep = { _ in } + #expect(try await Self.answer(runs, work) == Self.pending) + open.yield(()) + await runs.settled(Self.key) + #expect(runs.count == 1, "nobody saw it end") + runs.sleep = Self.waits + #expect(try await Self.answer(runs, work)["session_id"] == .string("s-1")) + #expect(started.value == 1) + #expect(runs.count == 0, "handed out once") + } + + @Test func aKeptRunExpiresForTheSameAsk() async throws { + let runs = CloudStartSessionRuns() + let clock = Box(ContinuousClock.now) + runs.now = { clock.value } + let (gate, open) = AsyncStream.makeStream() + defer { open.finish() } + runs.sleep = { _ in } + _ = try await Self.answer(runs) { + for await _ in gate { break } + return ["session_id": .string("old")] + } + open.yield(()) + await runs.settled(Self.key) + clock.value += CloudStartSessionRuns.kept + .seconds(1) + runs.sleep = Self.waits + let fresh = try await Self.answer(runs) { ["session_id": .string("new")] } + #expect(fresh["session_id"] == .string("new")) + #expect(runs.count == 0) + } + + @Test func aFailedRunEveryCallerLeftIsHandedOutOnceToo() async throws { + let runs = CloudStartSessionRuns() + let (gate, open) = AsyncStream.makeStream() + defer { open.finish() } + let failure = ActionFailure(message: "Machine `dev` started but its agent didn't (x).") + runs.sleep = { _ in } + _ = try await Self.answer(runs) { + for await _ in gate { break } + throw failure + } + open.yield(()) + await runs.settled(Self.key) + runs.sleep = Self.waits + await #expect(throws: failure) { _ = try await Self.answer(runs) { ["session_id": .string("not reached")] } } + #expect(runs.count == 0) + } + + @Test func runsThatEndTogetherKeepAtMostTheLimit() async throws { + let runs = CloudStartSessionRuns() + runs.sleep = { _ in } + let total = CloudStartSessionRuns.limit + 8 + let keys = (0...makeStream() } + defer { for gate in gates { gate.continuation.finish() } } + for (key, gate) in zip(keys, gates) { + _ = try await Self.answer(runs, key) { + for await _ in gate.stream { break } + return ["session_id": .string("s")] + } + } + // Every caller answered pending; now every run ends. + for gate in gates { gate.continuation.yield(()) } + for key in keys { await runs.settled(key) } + #expect(runs.keptCount <= CloudStartSessionRuns.limit) + } + + /// Another account's same ask never takes a kept run's end. + @Test func runsArePerAccount() async throws { + let runs = CloudStartSessionRuns() + let mine = Self.key + var theirs = Self.key + theirs.account = ["user-2", nil] + let (gate, open) = AsyncStream.makeStream() + defer { open.finish() } + runs.sleep = { _ in } + // Mine answers pending, then ends with nobody waiting: kept for my next ask. + _ = try await Self.answer(runs, mine) { + for await _ in gate { break } + return ["session_id": .string("mine")] + } + open.yield() + await runs.settled(mine) + #expect(runs.keptCount == 1) + runs.sleep = Self.waits + let started = Box(0) + let fields = try await Self.answer(runs, theirs) { + started.value += 1 + return ["session_id": .string("theirs")] + } + #expect(fields["session_id"] == .string("theirs")) + #expect(started.value == 1, "another account's ask ran its own start") + #expect(runs.keptCount == 1, "mine is still kept for me") + runs.forgetAll() + #expect(runs.count == 0) + } + + // MARK: One creation per new name + + enum CreationEvent: Equatable { case joined(String), entered(Int) } + + /// Two asks for one new name share one creation; another team's ask for it, meanwhile, makes its own. + @Test func twoRunsForOneNewNameCreateOnce() async throws { + let services = MachineIconTests.services() + let session = MachineIconTests.cloudMachine(services, id: "vm-create-\(UUID().uuidString.prefix(8))") + let runs = CloudStartSessionRuns() + let (events, event) = AsyncStream.makeStream() + runs.joinedCreation = { event.yield(.joined($0)) } + // One gate per creation, each finished at the end: a second creation (the bug) never shares one. + let gates = Box<[AsyncStream.Continuation]>([]) + defer { for gate in gates.value { gate.finish() }; event.finish() } + let creates = Box(0) + func create() -> @MainActor () async throws -> CloudMachineSession { + let (gate, open) = AsyncStream.makeStream() + gates.value.append(open) + return { + creates.value += 1 + event.yield(.entered(creates.value)) + for await _ in gate { break } + return session + } + } + // The next join or creation: a second creation (the bug) ends the wait as surely as the join. + func next() async -> CreationEvent? { + for await value in events { return value } + return nil + } + let first = Task { @MainActor in try await runs.creating("dev", account: Self.account, create()) } + #expect(await next() == .entered(1)) + let second = Task { @MainActor in try await runs.creating("dev", account: Self.account, create()) } + #expect(await next() == .joined("dev")) + let other = Task { @MainActor in try await runs.creating("dev", account: ["user-1", "team-2"], create()) } + #expect(await next() == .entered(2), "another team's ask made its own machine while the first was being made") + for gate in gates.value { gate.yield() } + let one = try await first.value + let two = try await second.value + _ = try await other.value + #expect(creates.value == 2) + #expect(one === session && two === session) + } +} From 8f91ab57113f7b8ae07487da7a580bb4ed306f15 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 02:19:20 -0700 Subject: [PATCH 19/33] cloud start-session asks: label the test helper's key Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextAppTests/CloudStartSessionAsksTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift index 4aa06de1be21..d6449740237e 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift @@ -159,7 +159,7 @@ struct CloudStartSessionAsksTests { /// A caller's wait that never ends by itself (the run's end cancels it). static let waits: @MainActor (Duration) async -> Void = { _ in try? await Task.sleep(for: .seconds(3600)) } - static func answer(_ runs: CloudStartSessionRuns, _ key: CloudStartSessionRuns.Key = Self.key, within: Duration = .seconds(190), + static func answer(_ runs: CloudStartSessionRuns, key: CloudStartSessionRuns.Key = Self.key, within: Duration = .seconds(190), _ work: @escaping @MainActor () async throws -> CloudStartSessionRuns.Fields) async throws -> CloudStartSessionRuns.Fields { try await runs.answer(key, within: within, pending: Self.pending, forgotten: Self.forgotten, work) } @@ -313,7 +313,7 @@ struct CloudStartSessionAsksTests { let gates = keys.map { _ in AsyncStream.makeStream() } defer { for gate in gates { gate.continuation.finish() } } for (key, gate) in zip(keys, gates) { - _ = try await Self.answer(runs, key) { + _ = try await Self.answer(runs, key: key) { for await _ in gate.stream { break } return ["session_id": .string("s")] } @@ -334,7 +334,7 @@ struct CloudStartSessionAsksTests { defer { open.finish() } runs.sleep = { _ in } // Mine answers pending, then ends with nobody waiting: kept for my next ask. - _ = try await Self.answer(runs, mine) { + _ = try await Self.answer(runs, key: mine) { for await _ in gate { break } return ["session_id": .string("mine")] } @@ -343,7 +343,7 @@ struct CloudStartSessionAsksTests { #expect(runs.keptCount == 1) runs.sleep = Self.waits let started = Box(0) - let fields = try await Self.answer(runs, theirs) { + let fields = try await Self.answer(runs, key: theirs) { started.value += 1 return ["session_id": .string("theirs")] } From d7a94b702ea853ecd2a80cc17d2560913c562e5f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 02:35:11 -0700 Subject: [PATCH 20/33] cloud start-session asks: endAll walks a snapshot; test an end at the deadline; finish the test card stream Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../Cloud/CloudStartSessionAsks.swift | 7 +++++-- .../CloudStartSessionAsksTests.swift | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift index 31c9cbc58f6c..9683c8647bdb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift @@ -115,7 +115,8 @@ final class CloudStartSessionAsks { /// it), and one whose run goes on says the run stopped. Called by ``CloudService`` on a /// sign-out or team switch, beside ``CloudStartSessionRuns/forgetAll()``. func endAll() { - for (tab, ask) in open { + // A snapshot: ``finish`` removes entries as it goes. + for (tab, ask) in Array(open) { if let answer = ask.answer { answer.yield(.accountChanged) } else { @@ -237,7 +238,9 @@ final class CloudStartSessionRuns { ended = value break } - // An end that came as the deadline did still reaches this caller. + // An end that came as the deadline did (before this caller resumed) still reaches it: the + // caller gets the end instead of pending, and the run counts as seen (``take`` drops it, + // since this caller was still registered when it ended). if let outcome = ended ?? run.outcome { return try outcome.get() } return pending } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift index d6449740237e..0635f8be52f8 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift @@ -23,6 +23,8 @@ struct CloudStartSessionAsksTests { var answers: [String: AgentToolCards.Answer] = [:] let (events, event) = AsyncStream.makeStream() + deinit { event.finish() } + func install(_ asks: CloudStartSessionAsks) { asks.present = { [unowned self] cards, tab, answer in shown[tab] = cards.isEmpty ? nil : cards @@ -207,6 +209,22 @@ struct CloudStartSessionAsksTests { #expect(try await Self.answer(runs) { ["session_id": .string("new")] }["session_id"] == .string("new")) } + /// An end that lands as the caller's deadline fires reaches that caller (not pending), and the + /// run is not kept: the caller saw it. + @Test func anEndAtTheDeadlineReachesTheCaller() async throws { + let runs = CloudStartSessionRuns() + let (gate, open) = AsyncStream.makeStream() + defer { open.finish() } + // The deadline fires and, before the caller resumes, the run ends. + runs.sleep = { _ in open.yield() } + let fields = try await Self.answer(runs) { + for await _ in gate { break } + return ["session_id": .string("s")] + } + #expect(fields["session_id"] == .string("s")) + #expect(runs.count == 0, "the caller saw it end, so it is not kept") + } + @Test func aRunItsCallerSawEndIsNotKept() async throws { let runs = CloudStartSessionRuns() runs.sleep = Self.waits From 2c104746aa38aa6a5480fb04e10573f5c2db4ffb Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 02:35:35 -0700 Subject: [PATCH 21/33] cloud start-session asks: the deadline test waits for the run's end Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextAppTests/CloudStartSessionAsksTests.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift index 0635f8be52f8..0bbfc3e0ce4a 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift @@ -214,11 +214,16 @@ struct CloudStartSessionAsksTests { @Test func anEndAtTheDeadlineReachesTheCaller() async throws { let runs = CloudStartSessionRuns() let (gate, open) = AsyncStream.makeStream() - defer { open.finish() } - // The deadline fires and, before the caller resumes, the run ends. - runs.sleep = { _ in open.yield() } + let (ending, ended) = AsyncStream.makeStream() + defer { open.finish(); ended.finish() } + // The deadline lets the run end, and fires once it has. + runs.sleep = { _ in + open.yield() + for await _ in ending { break } + } let fields = try await Self.answer(runs) { for await _ in gate { break } + ended.yield() return ["session_id": .string("s")] } #expect(fields["session_id"] == .string("s")) From 31c8dd936046e220e79b553706d1b37d7d5bec3f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 02:48:11 -0700 Subject: [PATCH 22/33] cloud start-session: check the account before each change on the machine A machine created for the account that asked joins this Mac's list only while that account is signed in. The keyed start checks the account before each restart and each start try, and the first-workspace fallback checks it right before creating. Closing the new chat's tab before its session started says so (a new string in every locale), not that the account changed. The card test's stream waits end after 10 seconds. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextApp/AgentTabRemoteHost.swift | 19 +++- .../CmuxNextApp/Cloud/CloudService.swift | 6 +- .../CmuxNextApp/Cloud/CloudStartSession.swift | 30 +++++- .../Cloud/CloudStartSessionStrings.swift | 5 + .../CmuxNextApp/Handlers/CloudHandlers.swift | 6 +- .../Resources/CloudSessions.xcstrings | 27 +++++ .../CloudStartSessionTests.swift | 101 +++++++++++++++++- 7 files changed, 179 insertions(+), 15 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabRemoteHost.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabRemoteHost.swift index 5eef793b223d..4309bfe95610 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabRemoteHost.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabRemoteHost.swift @@ -88,9 +88,11 @@ nonisolated struct AgentTabRemoteHost: AgentPaneHostProviding { /// Starts the new chat now, as the page's first send would (`cmux cloud start-session`): the /// same keyed start, in `cwd` (nil: the tab's seed folder, then its workspace's) with `harness`. - func startNow(cwd: String?, harness: String) async throws -> AgentTabRemoteStart.Started { + /// `proceed` runs before each step that changes that machine (a keyed restart, each start + /// try): its throw stops the start there, and what it threw is what this throws. + func startNow(cwd: String?, harness: String, proceed: @escaping @Sendable () async throws -> Void = {}) async throws -> AgentTabRemoteStart.Started { guard let start else { throw AgentRemoteStartRefusal.startUnsupported(machine: machine) } - return try await Self.keyedStart(cwd, harness, approvals: approvals, servesKeyedReason: servesKeyedReason, start: start) + return try await Self.keyedStart(cwd, harness, approvals: approvals, servesKeyedReason: servesKeyedReason, proceed: proceed, start: start) } /// The chat's Allow needs this app's key on that machine's agent daemon (cx-oa6ah): an idle @@ -100,11 +102,17 @@ nonisolated struct AgentTabRemoteHost: AgentPaneHostProviding { /// restart; after the restart (or without one) the chat starts as it is. private static func keyedStart( _ cwd: String?, _ harness: String?, approvals: AgentPaneRemoteApprovals?, servesKeyedReason: Bool, - start: @escaping @Sendable (String?, String?, AgentSessionAttachClient.PersonKey?) async throws -> AgentTabRemoteStart.Started + proceed: @escaping @Sendable () async throws -> Void = {}, + start unchecked: @escaping @Sendable (String?, String?, AgentSessionAttachClient.PersonKey?) async throws -> AgentTabRemoteStart.Started ) async throws -> AgentTabRemoteStart.Started { + let start = { @Sendable (cwd: String?, harness: String?, key: AgentSessionAttachClient.PersonKey?) in + try await proceed() + return try await unchecked(cwd, harness, key) + } guard let approvals, let prepare = approvals.prepare else { return try await start(cwd, harness, nil) } guard servesKeyedReason else { // An older cmux-tui: the app's own status check (remove with ``AgentPaneRemoteApprovals/Preparation/checkStatus``). + try await proceed() _ = await prepare(.checkStatus) return try await start(cwd, harness, nil) } @@ -116,7 +124,10 @@ nonisolated struct AgentTabRemoteHost: AgentPaneHostProviding { // (nil: a restart runs or failed; prepare joins it or tries again) let current = AcpmuxRemotePersonKeys.shared.instance(machine: approvals.machineID) let rebound = current != nil && current != key.instance - if refusal.keyedRestartSafe == true, !rebound { _ = await prepare(.daemonSaidSafe) } + if refusal.keyedRestartSafe == true, !rebound { + try await proceed() + _ = await prepare(.daemonSaidSafe) + } return try await start(cwd, harness, nil) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift index a7694279144d..4b5741bc5f43 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift @@ -467,7 +467,10 @@ final class CloudService { /// `creation` moves to its `creating` stage when the request is sent. /// `startedByPerson`: the person's own gesture in this app; any other /// create goes out at once on a paid plan (a Free or full plan gets the upgrade card; the opt-in `cloud.askBeforeAgentCreate` asks first) (``CloudMachineCreateFlow``); `askedBy` names the agent chat that asked. - func createMachine(name: String?, creation: CloudMachineCreation? = nil, startedByPerson: Bool, askedBy: String? = nil) async throws -> CloudMachineSession { + /// `proceed` runs once the server answered, before the new machine joins this Mac's list or + /// connects: its throw (the account that asked is no longer signed in) leaves it out. + func createMachine(name: String?, creation: CloudMachineCreation? = nil, startedByPerson: Bool, askedBy: String? = nil, + proceed: () throws -> Void = {}) async throws -> CloudMachineSession { if let reason = unavailableReason { throw ActionFailure(message: reason) } guard auth.isSignedIn else { throw ActionFailure(message: CloudStrings.signInFirst) } creating += 1 @@ -477,6 +480,7 @@ final class CloudService { ? try await appServerCreate(name: name, startedByPerson: startedByPerson, askedBy: askedBy, onSent: onSent) : try await CloudMachineCreateFlow.legacy(name: name, startedByPerson: startedByPerson, askedBy: askedBy, askFirst: UserDefaults.standard.bool(forKey: "cloud.askBeforeAgentCreate"), gate: { [weak self] in self?.createGate ?? .allowed }, confirm: { [weak self] in await self?.confirmCreate?($0) ?? false }, create: { [api] in try await api.createMachine(displayName: name, onSent: onSent) }) logger.info("created machine \(machine.id, privacy: .public)") + try proceed() if let existing = machines.session(machine.id) { return existing } guard let session = addSession(machine) else { throw ActionFailure(message: CloudStrings.noClient) } // A list in flight was asked before this machine existed. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift index bcaadd61594d..4c2059932437 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift @@ -178,7 +178,13 @@ struct CloudStartSession { } let anchor: (id: String, key: WorkspaceKey, pane: PaneModel) do { - anchor = try await CloudHandlers.terminalAnchor(on: session, context) + anchor = try await CloudHandlers.terminalAnchor(on: session, context) { [services] in + try Self.check(account, now: services.cloud.account, request.asked(machine: name)) + } + } catch let changed as AccountChanged { + throw changed.failure + } catch is CancellationError { + throw CancellationError() } catch { throw ActionFailure(message: CloudStartSessionStrings.notConnected(name, id: id, agent: agent)) } @@ -188,13 +194,18 @@ struct CloudStartSession { do { let tabs = services.agentTabs let pending = try tabs.open(in: anchor.pane.handle, of: daemon, seed: request.cwd.map { AgentPaneSeedSource(AgentPaneSeed(cwd: $0)) }) - tabKey = try await pending.value().key + tabKey = try await Self.created(pending, name: name, id: id, agent: agent).key // The start reads the tab as the machine's tree lists it (`tabKey` is the store's id). let listed = await CloudMove.holds(within: .seconds(10)) { [services] in services.locateTab(tabKey) != nil } try Self.check(account, now: services.cloud.account, request.asked(machine: name)) guard listed else { throw AccountChanged(failure: ActionFailure(message: CloudStartSessionStrings.notConnected(name, id: id, agent: agent))) } switch tabs.remoteHost(tabKey) { - case let remote as AgentTabRemoteHost: started = try await remote.startNow(cwd: request.cwd, harness: agent) + case let remote as AgentTabRemoteHost: + // Each restart and start try is for the account that asked, checked right before it. + let command = request.command + started = try await remote.startNow(cwd: request.cwd, harness: agent) { @MainActor [services] in + try Self.check(account, now: services.cloud.account, CloudStartSessionStrings.Asked(machine: name, agent: agent, command: command)) + } case let refused as AgentTabRefusedHost: throw refused.refusal default: throw AgentRemoteStartRefusal.startUnsupported(machine: name) } @@ -216,6 +227,16 @@ struct CloudStartSession { ] } + /// The store's tab of the chat this run opened. The person closing it before the store + /// answered is no account change: the refusal says the tab closed and gives the command. + static func created(_ pending: AgentTabPending, name: String, id: String, agent: String) async throws -> AgentTabCreated { + do { + return try await pending.value() + } catch is CancellationError where !Task.isCancelled { + throw AccountChanged(failure: ActionFailure(message: CloudStartSessionStrings.tabClosed(name, id: id, agent: agent))) + } + } + /// A refusal that passes through a step's error mapping as it is: the account changed since /// the run was asked, or a step's own precondition failed. struct AccountChanged: Error { var failure: ActionFailure } @@ -288,7 +309,8 @@ struct CloudStartSession { // Another ask's creation may have ended first. if let named = machines.cloud.first(where: { $0.machine.title == name }) { return named } try still() - return try await cloud.createMachine(name: name, startedByPerson: invocation.origin == .user, askedBy: askedBy ?? invocation.callerAgentSession) + // The answer of a create asked in another account never joins this one's list. + return try await cloud.createMachine(name: name, startedByPerson: invocation.origin == .user, askedBy: askedBy ?? invocation.callerAgentSession, proceed: still) } } catch is CancellationError { throw CancellationError() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift index 85626e481cb5..4ae7b395cb18 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift @@ -63,6 +63,11 @@ enum CloudStartSessionStrings { String(format: String(localized: "cloud.startSession.notConnected", defaultValue: "Machine `%1$@` didn't connect, so no agent started there. Check it with `cmux cloud machine-status --target machine:%2$@`, then run `cmux cloud start-session --target machine:%2$@ --agent %3$@` again.", table: "CloudSessions", bundle: .module), name, id, agent) } + /// The person closed the new chat's tab before its session started. + static func tabClosed(_ name: String, id: String, agent: String) -> String { + String(format: String(localized: "cloud.startSession.tabClosed", defaultValue: "The new chat's tab on machine `%1$@` was closed before its agent started, so %3$@ didn't start there. Run `cmux cloud start-session --target machine:%2$@ --agent %3$@` again.", table: "CloudSessions", bundle: .module), name, id, agent) + } + /// The machine is up and the start was refused; `reason` is the refusal in plain words. static func agentFailed(_ name: String, id: String, agent: String, reason: String) -> String { String(format: String(localized: "cloud.startSession.agentFailed", defaultValue: "Machine `%1$@` started but its agent didn't (%4$@). Run `cmux cloud start-session --target machine:%2$@ --agent %3$@` again.", table: "CloudSessions", bundle: .module), name, id, agent, reason) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift index ab94966b7a0c..b2e280ab9fc3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift @@ -163,8 +163,11 @@ enum CloudHandlers { /// The existing workspace and pane where a Cloud terminal should land. /// Keep the durable workspace key on the spawn request so terminals stay /// attached to the machine after daemon handle ids are recycled. - @MainActor static func terminalAnchor(on session: CloudMachineSession, _ context: AppActionContext) async throws -> (id: String, key: WorkspaceKey, pane: PaneModel) { + /// `proceed` runs right before each workspace this creates: its throw creates nothing. + @MainActor static func terminalAnchor(on session: CloudMachineSession, _ context: AppActionContext, + proceed: () throws -> Void = {}) async throws -> (id: String, key: WorkspaceKey, pane: PaneModel) { let hadWorkspace = !session.daemon.store.workspaces.isEmpty + if !hadWorkspace { try proceed() } let id = try await firstWorkspace(on: session, context) if let anchor = anchor(id: id, on: session) { return anchor } if let anchor = await mirroredAnchor(id: id, on: session) { return anchor } @@ -172,6 +175,7 @@ enum CloudHandlers { // store was empty. Do not issue a second create if that mirror is // unexpectedly late. guard hadWorkspace else { throw ActionFailure(message: CloudStrings.notConnected) } + try proceed() guard let created = await context.services.windows.createWorkspace(on: session.daemon) else { throw ActionFailure(message: CloudStrings.notConnected) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings index b1921fd2f927..2db52719c234 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings @@ -325,6 +325,33 @@ "vi": {"stringUnit": {"state": "needs_review", "value": "Máy `%1$@` không kết nối được nên không có tác tử nào được khởi động trên đó. Kiểm tra máy bằng `cmux cloud machine-status --target machine:%2$@`, rồi chạy lại `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}} } }, + "cloud.startSession.tabClosed": { + "comment": "The person closed the new chat's tab before its agent started. %1$@ machine name, %2$@ machine id, %3$@ agent.", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "The new chat's tab on machine `%1$@` was closed before its agent started, so %3$@ didn't start there. Run `cmux cloud start-session --target machine:%2$@ --agent %3$@` again."}}, + "ja": {"stringUnit": {"state": "translated", "value": "マシン `%1$@` の新しいチャットのタブがエージェントの開始前に閉じられたため、%3$@ はそこで開始されませんでした。`cmux cloud start-session --target machine:%2$@ --agent %3$@` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Der Tab des neuen Chats auf Maschine `%1$@` wurde geschlossen, bevor sein Agent startete, daher wurde %3$@ dort nicht gestartet. Führe `cmux cloud start-session --target machine:%2$@ --agent %3$@` erneut aus."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "L'onglet de la nouvelle discussion sur la machine `%1$@` a été fermé avant le démarrage de son agent : %3$@ n'y a pas démarré. Relancez `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "La pestaña del nuevo chat en la máquina `%1$@` se cerró antes de que se iniciara su agente, así que %3$@ no se inició allí. Vuelve a ejecutar `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "أُغلق تبويب الدردشة الجديدة على الجهاز `%1$@` قبل أن يبدأ وكيلها، لذا لم يبدأ %3$@ هناك. شغّل `cmux cloud start-session --target machine:%2$@ --agent %3$@` مرة أخرى."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "머신 `%1$@`의 새 채팅 탭이 에이전트가 시작되기 전에 닫혀 %3$@이(가) 시작되지 않았습니다. `cmux cloud start-session --target machine:%2$@ --agent %3$@`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "机器 `%1$@` 上新聊天的标签页在其代理启动前被关闭,因此 %3$@ 没有在那里启动。请再次运行 `cmux cloud start-session --target machine:%2$@ --agent %3$@`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "機器 `%1$@` 上新聊天的分頁在其代理啟動前被關閉,因此 %3$@ 沒有在那裡啟動。請再次執行 `cmux cloud start-session --target machine:%2$@ --agent %3$@`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Kartica novog chata na mašini `%1$@` zatvorena je prije nego što se njen agent pokrenuo, pa %3$@ tamo nije pokrenut. Ponovo pokrenite `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Fanen med den nye chat på maskinen `%1$@` blev lukket, før dens agent startede, så %3$@ startede ikke der. Kør `cmux cloud start-session --target machine:%2$@ --agent %3$@` igen."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "La scheda della nuova chat sulla macchina `%1$@` è stata chiusa prima che il suo agente si avviasse, quindi %3$@ non è stato avviato lì. Esegui di nuovo `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "ផ្ទាំងជជែកថ្មីនៅលើម៉ាស៊ីន `%1$@` ត្រូវបានបិទមុនពេលភ្នាក់ងាររបស់វាចាប់ផ្ដើម ដូច្នេះ %3$@ មិនបានចាប់ផ្ដើមនៅទីនោះទេ។ ដំណើរការ `cmux cloud start-session --target machine:%2$@ --agent %3$@` ម្ដងទៀត។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Fanen for den nye chatten på maskinen `%1$@` ble lukket før agenten startet, så %3$@ startet ikke der. Kjør `cmux cloud start-session --target machine:%2$@ --agent %3$@` på nytt."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Karta nowego czatu na maszynie `%1$@` została zamknięta, zanim uruchomił się jej agent, więc %3$@ nie został tam uruchomiony. Uruchom ponownie `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "A aba do novo chat na máquina `%1$@` foi fechada antes de o agente iniciar, então %3$@ não foi iniciado lá. Execute `cmux cloud start-session --target machine:%2$@ --agent %3$@` novamente."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Вкладку нового чата на машине `%1$@` закрыли до запуска её агента, поэтому %3$@ там не запущен. Снова выполните `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "แท็บแชตใหม่บนเครื่อง `%1$@` ถูกปิดก่อนที่เอเจนต์จะเริ่มทำงาน %3$@ จึงไม่ได้เริ่มบนเครื่องนั้น เรียกใช้ `cmux cloud start-session --target machine:%2$@ --agent %3$@` อีกครั้ง"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "`%1$@` makinesindeki yeni sohbetin sekmesi ajanı başlamadan kapatıldı, bu yüzden %3$@ orada başlatılmadı. `cmux cloud start-session --target machine:%2$@ --agent %3$@` komutunu yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Вкладку нового чату на машині `%1$@` закрили до запуску її агента, тому %3$@ там не запущено. Знову виконайте `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Thẻ của cuộc trò chuyện mới trên máy `%1$@` đã bị đóng trước khi tác tử khởi động, nên %3$@ chưa khởi động trên đó. Chạy lại `cmux cloud start-session --target machine:%2$@ --agent %3$@`."}} + } + }, "cloud.startSession.agentFailed": { "comment": "The machine is up but the agent session didn't start. %1$@ name, %2$@ id, %3$@ agent, %4$@ the reason in plain words.", "extractionState": "manual", diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index f2a2000d43b4..48874ddc014a 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -3,6 +3,7 @@ import CmuxNextActions import CmuxNextAgentPane @testable import CmuxNextApp import CmuxNextCloud +import CmuxNextCompat import CmuxNextDaemon import Foundation import Testing @@ -135,10 +136,8 @@ struct CloudStartSessionTests { invoke(Self.invocation(target: f.session.machineID, ["agent": .string("claude")], origin: .mcp, agent: "asking-session")) } let task = try #require(run.results.first, "start-session tracked no result") - for await cards in shown where !cards.isEmpty { - #expect(cards.first?.state == .ask) - break - } + let asking = try #require(await Self.cards(shown) { !$0.isEmpty }, "the agent's card never showed") + #expect(asking.first?.state == .ask) // What the account observer does on a sign-out or team switch. f.services.cloud.startSessionRuns.forgetAll() f.services.cloud.startSessionAsks.endAll() @@ -148,10 +147,102 @@ struct CloudStartSessionTests { } let request = CloudStartSession.Request(machineID: f.session.machineID, name: nil, agent: "claude", cwd: nil) #expect(failure.message == CloudStartSessionStrings.accountChanged(request.asked(machine: f.session.machine.title))) - for await cards in shown where cards.isEmpty { break } + #expect(await Self.cards(shown) { $0.isEmpty } != nil, "the agent's card stayed") #expect(f.session.daemon.connection == nil, "nothing connected") } + /// The first cards `shown` yields that `match` takes, or nil once the stream ends or 10 seconds + /// pass: a run that fails before its card shows, or never clears it, fails instead of hanging. + static func cards(_ shown: AsyncStream<[AgentPaneToolCard]>, _ match: @escaping @Sendable ([AgentPaneToolCard]) -> Bool) async -> [AgentPaneToolCard]? { + // concurrency-allow: the test's ten-second bound on a card the run shows or clears + await withTaskGroup(of: [AgentPaneToolCard]?.self) { group in + group.addTask { + for await cards in shown where match(cards) { return cards } + return nil + } + group.addTask { + // wakeup-allow: one-shot ten-second test deadline + try? await Task.sleep(for: .seconds(10)) + return nil + } + defer { group.cancelAll() } + return await group.next() ?? nil + } + } + + /// The person closing the new chat's tab before the store answered is not an account change: + /// the refusal says the tab closed and gives the command. A run that was cancelled stays so. + @Test func closingTheNewTabAfterStartSaysSoAndGivesTheCommand() async throws { + let closed = AgentTabPending(key: "provisional", surface: SurfaceID(rawValue: 9)) + // What the store's creation throws once the person closed the provisional tab. + closed.result = Task { throw CancellationError() } + do { + _ = try await CloudStartSession.created(closed, name: "devbox", id: "vm-devbox", agent: "codex") + Issue.record("a closed tab started") + } catch let refusal as CloudStartSession.AccountChanged { + #expect(refusal.failure.message == CloudStartSessionStrings.tabClosed("devbox", id: "vm-devbox", agent: "codex")) + #expect(refusal.failure.message.contains("`cmux cloud start-session --target machine:vm-devbox --agent codex`")) + #expect(refusal.failure.message != CloudStartSessionStrings.accountChanged(CloudStartSession.Request(machineID: "vm-devbox", name: nil, agent: "codex", cwd: nil).asked(machine: "devbox"))) + } + let cancelled = Task { @MainActor in + withUnsafeCurrentTask { $0?.cancel() } + return await Result { try await CloudStartSession.created(closed, name: "devbox", id: "vm-devbox", agent: "codex") } + } + guard case .failure(let error) = await cancelled.value else { Issue.record("a cancelled run went on"); return } + #expect(error is CancellationError) + } + + /// What a keyed start did on the machine, in order. + final class StartSteps: Sendable { + let steps = Mutex([String]()) + func note(_ step: String) { steps.withLock { $0.append(step) } } + var all: [String] { steps.withLock { $0 } } + } + + /// A new chat's host on a machine whose first keyed try is refused as safe to restart. + static func keyedHost(_ steps: StartSteps, servesKeyedReason: Bool) -> AgentTabRemoteHost { + let approvals = AgentPaneRemoteApprovals(machineID: "vm-keyed-\(UUID().uuidString.prefix(8))", label: "devbox", restart: { nil }, prepare: { _ in + steps.note("prepare") + return true + }) + var host = AgentTabRemoteHost(surface: 1, session: nil, machine: "devbox", endpoint: { throw CancellationError() }, + servesKeyedReason: servesKeyedReason, approvals: approvals) + host.start = { _, _, key in + steps.note(key == nil ? "start" : "start keyed") + if key != nil { throw AgentRemoteStartRefusal(code: AgentSessionAttachError.needsKeyedStart, machine: "devbox", keyedRestartSafe: true) } + return AgentTabRemoteStart.Started(surface: 1, session: "s-1") + } + return host + } + + /// A sign-out or team switch during a keyed start stops it before the next restart or start + /// try on that machine; a start that goes through is reported as it is. + @Test(arguments: [false, true]) + func anAccountChangeStopsAKeyedStartBeforeItsNextStep(servesKeyedReason: Bool) async throws { + let asked = Self.asked + // Signed out before anything ran: nothing runs on the machine. + let before = StartSteps() + await #expect(throws: CloudStartSession.AccountChanged.self) { + try await Self.keyedHost(before, servesKeyedReason: servesKeyedReason).startNow(cwd: nil, harness: "claude") { @MainActor in + try CloudStartSession.check(Self.account, now: [nil, nil], asked) + } + } + #expect(before.all.isEmpty, "\(before.all)") + // Signed out once the first step ran: the next restart or start never runs. + let after = StartSteps() + await #expect(throws: CancellationError.self) { + try await Self.keyedHost(after, servesKeyedReason: servesKeyedReason).startNow(cwd: nil, harness: "claude") { + if !after.all.isEmpty { throw CancellationError() } + } + } + #expect(after.all == [servesKeyedReason ? "start keyed" : "prepare"], "\(after.all)") + // The same account throughout: the restart and the start run, and the start is the answer. + let through = StartSteps() + let started = try await Self.keyedHost(through, servesKeyedReason: servesKeyedReason).startNow(cwd: nil, harness: "claude") {} + #expect(started == AgentTabRemoteStart.Started(surface: 1, session: "s-1")) + #expect(through.all == (servesKeyedReason ? ["start keyed", "prepare", "start"] : ["prepare", "start"]), "\(through.all)") + } + @Test func aRunStillStartingAnswersPendingWithWhatToRunNext() async throws { let request = CloudStartSession.Request(machineID: "vm-a", name: nil, agent: "claude", cwd: nil) let pending = CloudStartSession.pending(request, Self.fixture().services) From bb220d108f0d8a767d709666b9672711b4e8cb23 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 03:00:36 -0700 Subject: [PATCH 23/33] cloud start-session asks: an ended account wins over a buffered answer A Start clicked just before the account ended, with the ask not yet resumed, starts nothing. A forget that lands after a caller's deadline fired still refuses that caller instead of answering pending. A card whose run was going on says the start stopped (a new string in every locale), not that the agent didn't start. Sign-out ends asks and runs first, before the link and install cleanup. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextApp/Cloud/CloudService.swift | 3 ++ .../Cloud/CloudStartSessionAsks.swift | 25 ++++++++++--- .../Cloud/CloudStartSessionStrings.swift | 5 +++ .../Resources/CloudSessions.xcstrings | 27 ++++++++++++++ .../CloudStartSessionAsksTests.swift | 37 ++++++++++++++++++- 5 files changed, 90 insertions(+), 7 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift index e17f087a229e..181df14cf2c1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift @@ -508,6 +508,9 @@ final class CloudService { var willSignOut: [@MainActor () -> Void] = [] func signOut() async { + // Start-session asks and runs end now, not after the link and install cleanup below. + startSessionRuns.forgetAll() + startSessionAsks.endAll() for hook in willSignOut { hook() } AgentPaneTailCache.shared.clear() // the panes' chat tails: memory only, forgotten whole // No ensure starts from here until the session's final endLink (the diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift index 9683c8647bdb..aade7db200a0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift @@ -21,6 +21,8 @@ final class CloudStartSessionAsks { var asked: CloudStartSessionStrings.Asked /// The ask's wait for the person's answer; nil once they clicked Start. var answer: AsyncStream.Continuation? + /// Its account ended (``endAll()``) while it asked: a Start already sent is void. + var ended = false } /// The ask that owns each chat's card, by tab. @@ -73,11 +75,14 @@ final class CloudStartSessionAsks { answer = value break } - guard let answer else { + guard var answer else { // The run was cancelled (its account ended, ``CloudStartSessionRuns/forgetAll()``). finish(tab: tab, token: token, failure: nil) throw CancellationError() } + // A Start that came before the account ended, with this ask not yet resumed: the stream + // keeps the first answer, so the end is read from the ask and the account itself. + if case .start = answer, open[tab]?.token != token || open[tab]?.ended == true || now() != account { answer = .accountChanged } switch answer { case .start: return token @@ -118,9 +123,10 @@ final class CloudStartSessionAsks { // A snapshot: ``finish`` removes entries as it goes. for (tab, ask) in Array(open) { if let answer = ask.answer { + open[tab]?.ended = true answer.yield(.accountChanged) } else { - finish(tab: tab, token: ask.token, failure: CloudStartSessionStrings.accountChanged(ask.asked)) + finish(tab: tab, token: ask.token, failure: CloudStartSessionStrings.accountChangedWhileStarting(ask.asked)) } } } @@ -177,6 +183,9 @@ final class CloudStartSessionRuns { /// gets if the run is forgotten first, or removes itself at its own deadline. var waiters: [UUID: (end: AsyncStream.Continuation, forgotten: any Error)] = [:] var outcome: Outcome? + /// Forgotten (``forgetAll()``): a caller whose deadline fired first gets its `forgotten` + /// failure all the same, never `pending`. + var forgotten = false /// When it ended with nobody waiting (kept for the next same ask). var kept: ContinuousClock.Instant? } @@ -238,10 +247,13 @@ final class CloudStartSessionRuns { ended = value break } - // An end that came as the deadline did (before this caller resumed) still reaches it: the - // caller gets the end instead of pending, and the run counts as seen (``take`` drops it, - // since this caller was still registered when it ended). - if let outcome = ended ?? run.outcome { return try outcome.get() } + if let ended { return try ended.get() } + // The stream keeps the first value: a forget or an end that came after the deadline did, + // before this caller resumed, is read from the run. The forget wins (the run was cancelled); + // an end reaches the caller instead of pending, and the run counts as seen (``take`` drops + // it, since this caller was still registered when it ended). + if run.forgotten { throw forgotten } + if let outcome = run.outcome { return try outcome.get() } return pending } @@ -249,6 +261,7 @@ final class CloudStartSessionRuns { /// failure now, every run and creation is cancelled, and none is kept or joined after. func forgetAll() { for run in runs.values { + run.forgotten = true for waiter in run.waiters.values { waiter.end.yield(.failure(waiter.forgotten)) } run.waiters = [:] run.task?.cancel() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift index f86ca486936c..ef7f2dc56679 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift @@ -33,4 +33,9 @@ enum CloudStartSessionStrings { static func accountChanged(_ asked: Asked) -> String { String(format: String(localized: "cloud.startSession.accountChanged", defaultValue: "Your Cloud account or team changed after this was asked, so %2$@ didn't start on %1$@. Run `%3$@` again in the account you want it in.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) } + + /// The signed-in account or team changed after Start, while the run went on: it stopped. + static func accountChangedWhileStarting(_ asked: Asked) -> String { + String(format: String(localized: "cloud.startSession.accountChangedWhileStarting", defaultValue: "Your Cloud account or team changed while %2$@ was starting on %1$@, so the start stopped. Run `%3$@` again in the account you want it in.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) + } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings index 8b538d50e32b..b0b91dc677da 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings @@ -162,6 +162,33 @@ "uk": {"stringUnit": {"state": "needs_review", "value": "Обліковий запис або команда Cloud змінилися після запиту, тому %2$@ не запущено на %1$@. Знову виконайте `%3$@` у потрібному обліковому записі."}}, "vi": {"stringUnit": {"state": "needs_review", "value": "Tài khoản hoặc nhóm Cloud của bạn đã thay đổi sau yêu cầu này, nên %2$@ chưa khởi động trên %1$@. Hãy chạy lại `%3$@` trong tài khoản bạn muốn dùng."}} } + }, + "cloud.startSession.accountChangedWhileStarting": { + "comment": "The signed-in Cloud account or team changed after the person clicked Start, while the run went on, so it stopped. %1$@ machine, %2$@ agent, %3$@ the command (keep it in backticks).", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Your Cloud account or team changed while %2$@ was starting on %1$@, so the start stopped. Run `%3$@` again in the account you want it in."}}, + "ja": {"stringUnit": {"state": "translated", "value": "%1$@ で %2$@ を開始している間に Cloud のアカウントまたはチームが変わったため、開始は中止されました。使いたいアカウントで `%3$@` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Dein Cloud-Konto oder Team hat sich geändert, während %2$@ auf %1$@ startete, daher wurde der Start abgebrochen. Führe `%3$@` im gewünschten Konto erneut aus."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Votre compte ou équipe Cloud a changé pendant le démarrage de %2$@ sur %1$@ : le démarrage s'est arrêté. Relancez `%3$@` dans le compte voulu."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Tu cuenta o equipo de Cloud cambió mientras %2$@ se iniciaba en %1$@, así que el inicio se detuvo. Vuelve a ejecutar `%3$@` en la cuenta que quieras."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "تغيّر حساب Cloud أو الفريق أثناء بدء %2$@ على %1$@، لذا توقّف البدء. شغّل `%3$@` مرة أخرى في الحساب الذي تريده."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "%1$@에서 %2$@을(를) 시작하는 동안 Cloud 계정 또는 팀이 바뀌어 시작이 중단되었습니다. 원하는 계정에서 `%3$@`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "在 %1$@ 上启动 %2$@ 期间你的 Cloud 帐户或团队已更改,因此启动已停止。请在你想要的帐户中再次运行 `%3$@`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "在 %1$@ 上啟動 %2$@ 期間你的 Cloud 帳號或團隊已變更,因此啟動已停止。請在你想要的帳號中再次執行 `%3$@`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Vaš Cloud račun ili tim promijenio se dok se %2$@ pokretao na %1$@, pa je pokretanje zaustavljeno. Ponovo pokrenite `%3$@` u računu u kojem ga želite."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Din Cloud-konto eller dit team ændrede sig, mens %2$@ startede på %1$@, så starten stoppede. Kør `%3$@` igen i den konto, du vil bruge."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Il tuo account o team Cloud è cambiato mentre %2$@ si avviava su %1$@, quindi l'avvio si è fermato. Esegui di nuovo `%3$@` nell'account che vuoi."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "គណនី ឬក្រុម Cloud របស់អ្នកបានផ្លាស់ប្ដូរ ខណៈពេល %2$@ កំពុងចាប់ផ្ដើមនៅលើ %1$@ ដូច្នេះការចាប់ផ្ដើមបានឈប់។ ដំណើរការ `%3$@` ម្ដងទៀតក្នុងគណនីដែលអ្នកចង់បាន។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Cloud-kontoen eller teamet ditt endret seg mens %2$@ startet på %1$@, så oppstarten stoppet. Kjør `%3$@` på nytt i kontoen du vil bruke."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Twoje konto lub zespół Cloud zmieniły się podczas uruchamiania %2$@ na %1$@, więc uruchamianie zostało zatrzymane. Uruchom ponownie `%3$@` na wybranym koncie."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Sua conta ou equipe do Cloud mudou enquanto %2$@ iniciava em %1$@, então a inicialização parou. Execute `%3$@` novamente na conta que você quer."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Ваш аккаунт или команда Cloud изменились, пока %2$@ запускался на %1$@, поэтому запуск остановлен. Снова выполните `%3$@` в нужном аккаунте."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "บัญชีหรือทีม Cloud ของคุณเปลี่ยนไประหว่างที่ %2$@ กำลังเริ่มบน %1$@ การเริ่มจึงหยุดลง เรียกใช้ `%3$@` อีกครั้งในบัญชีที่คุณต้องการ"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "%2$@ %1$@ üzerinde başlatılırken Cloud hesabınız veya ekibiniz değişti, bu yüzden başlatma durduruldu. `%3$@` komutunu istediğiniz hesapta yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Ваш обліковий запис або команда Cloud змінилися, поки %2$@ запускався на %1$@, тому запуск зупинено. Знову виконайте `%3$@` у потрібному обліковому записі."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Tài khoản hoặc nhóm Cloud của bạn đã thay đổi trong khi %2$@ đang khởi động trên %1$@, nên việc khởi động đã dừng. Chạy lại `%3$@` trong tài khoản bạn muốn."}} + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift index 0bbfc3e0ce4a..1d391b613af7 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift @@ -144,10 +144,25 @@ struct CloudStartSessionAsksTests { await #expect(throws: ActionFailure(message: CloudStartSessionStrings.accountChanged(Self.asked))) { _ = try await asking.value } #expect(cards.shown["chat-6"] == nil) #expect(cards.shown["chat-5"]?.first?.state == .failed) - #expect(cards.shown["chat-5"]?.first?.text == CloudStartSessionStrings.accountChanged(Self.asked)) + #expect(cards.shown["chat-5"]?.first?.text == CloudStartSessionStrings.accountChangedWhileStarting(Self.asked)) #expect(asks.owner(of: "chat-5") == nil && asks.owner(of: "chat-6") == nil) } + /// A Start clicked just before the account ended, with the ask not yet resumed, starts + /// nothing: the ask refuses as an account change and the card goes. + @Test func aStartJustBeforeTheAccountEndedIsVoid() async throws { + let asks = CloudStartSessionAsks(), cards = Cards() + cards.install(asks) + let ask = Self.ask(asks, "chat-7") + await cards.shownIn("chat-7") + // Both in one main-actor turn: the Start is the stream's first answer. + cards.click("chat-7", "start") + asks.endAll() + await #expect(throws: ActionFailure(message: CloudStartSessionStrings.accountChanged(Self.asked))) { _ = try await ask.value } + #expect(cards.shown["chat-7"] == nil) + #expect(asks.owner(of: "chat-7") == nil) + } + // MARK: A run that outlasts its caller's answer /// What the runs' work counts and the clock they read, shared with their closures. @@ -209,6 +224,26 @@ struct CloudStartSessionAsksTests { #expect(try await Self.answer(runs) { ["session_id": .string("new")] }["session_id"] == .string("new")) } + /// A forget that lands after the caller's deadline fired, before the caller resumed, still + /// refuses that caller: never `pending` for a run its account ended. + @Test func aForgetAfterTheDeadlineStillRefusesTheCaller() async throws { + let runs = CloudStartSessionRuns() + // The forget is queued on the main actor before the deadline's own answer. + runs.sleep = { [runs] _ in + // task-owner: one-shot test step, ends at once + Task { @MainActor in runs.forgetAll() } + } + let (never, keep) = AsyncStream.makeStream() + defer { keep.finish() } + await #expect(throws: Self.forgotten) { + _ = try await Self.answer(runs, within: .seconds(1)) { + for await _ in never { break } + return ["session_id": .string("late")] + } + } + #expect(runs.count == 0) + } + /// An end that lands as the caller's deadline fires reaches that caller (not pending), and the /// run is not kept: the caller saw it. @Test func anEndAtTheDeadlineReachesTheCaller() async throws { From 9c809cd68896a9dc64c11ec6ecbd9e531b74b2bb Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 03:00:51 -0700 Subject: [PATCH 24/33] cloud start-session: fix the new tests' isolation; a run stopped after Start says so Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextApp/Cloud/CloudStartSession.swift | 2 +- .../Cloud/CloudStartSessionStrings.swift | 5 ++++ .../Resources/CloudSessions.xcstrings | 27 +++++++++++++++++++ .../CloudStartSessionTests.swift | 15 +++++++---- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift index 4c2059932437..6fc0e67b3690 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift @@ -101,7 +101,7 @@ struct CloudStartSession { return fields } catch is CancellationError { // Only a sign-out or team switch cancels a run (``CloudStartSessionRuns/forgetAll()``). - asks.finish(tab: tab.id, token: token, failure: CloudStartSessionStrings.accountChanged(asked)) + asks.finish(tab: tab.id, token: token, failure: CloudStartSessionStrings.accountChangedWhileStarting(asked)) throw CancellationError() } catch { asks.finish(tab: tab.id, token: token, failure: (error as? ActionFailure)?.message ?? Self.startFailure(error, name: named, id: request.machineID ?? named, agent: request.agent)) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift index 4ae7b395cb18..9e946a447534 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionStrings.swift @@ -34,6 +34,11 @@ enum CloudStartSessionStrings { String(format: String(localized: "cloud.startSession.accountChanged", defaultValue: "Your Cloud account or team changed after this was asked, so %2$@ didn't start on %1$@. Run `%3$@` again in the account you want it in.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) } + /// The signed-in account or team changed after Start, while the run went on: it stopped. + static func accountChangedWhileStarting(_ asked: Asked) -> String { + String(format: String(localized: "cloud.startSession.accountChangedWhileStarting", defaultValue: "Your Cloud account or team changed while %2$@ was starting on %1$@, so the start stopped. Run `%3$@` again in the account you want it in.", table: "CloudSessions", bundle: .module), asked.machine, asked.agent, asked.command) + } + // MARK: The start (``CloudStartSession``) /// A non-person run that names no machine. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings index 2db52719c234..3d5667c6e978 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings @@ -163,6 +163,33 @@ "vi": {"stringUnit": {"state": "needs_review", "value": "Tài khoản hoặc nhóm Cloud của bạn đã thay đổi sau yêu cầu này, nên %2$@ chưa khởi động trên %1$@. Hãy chạy lại `%3$@` trong tài khoản bạn muốn dùng."}} } }, + "cloud.startSession.accountChangedWhileStarting": { + "comment": "The signed-in Cloud account or team changed after the person clicked Start, while the run went on, so it stopped. %1$@ machine, %2$@ agent, %3$@ the command (keep it in backticks).", + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Your Cloud account or team changed while %2$@ was starting on %1$@, so the start stopped. Run `%3$@` again in the account you want it in."}}, + "ja": {"stringUnit": {"state": "translated", "value": "%1$@ で %2$@ を開始している間に Cloud のアカウントまたはチームが変わったため、開始は中止されました。使いたいアカウントで `%3$@` をもう一度実行してください。"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "Dein Cloud-Konto oder Team hat sich geändert, während %2$@ auf %1$@ startete, daher wurde der Start abgebrochen. Führe `%3$@` im gewünschten Konto erneut aus."}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Votre compte ou équipe Cloud a changé pendant le démarrage de %2$@ sur %1$@ : le démarrage s'est arrêté. Relancez `%3$@` dans le compte voulu."}}, + "es": {"stringUnit": {"state": "needs_review", "value": "Tu cuenta o equipo de Cloud cambió mientras %2$@ se iniciaba en %1$@, así que el inicio se detuvo. Vuelve a ejecutar `%3$@` en la cuenta que quieras."}}, + "ar": {"stringUnit": {"state": "needs_review", "value": "تغيّر حساب Cloud أو الفريق أثناء بدء %2$@ على %1$@، لذا توقّف البدء. شغّل `%3$@` مرة أخرى في الحساب الذي تريده."}}, + "ko": {"stringUnit": {"state": "needs_review", "value": "%1$@에서 %2$@을(를) 시작하는 동안 Cloud 계정 또는 팀이 바뀌어 시작이 중단되었습니다. 원하는 계정에서 `%3$@`을(를) 다시 실행하세요."}}, + "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "在 %1$@ 上启动 %2$@ 期间你的 Cloud 帐户或团队已更改,因此启动已停止。请在你想要的帐户中再次运行 `%3$@`。"}}, + "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "在 %1$@ 上啟動 %2$@ 期間你的 Cloud 帳號或團隊已變更,因此啟動已停止。請在你想要的帳號中再次執行 `%3$@`。"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "Vaš Cloud račun ili tim promijenio se dok se %2$@ pokretao na %1$@, pa je pokretanje zaustavljeno. Ponovo pokrenite `%3$@` u računu u kojem ga želite."}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Din Cloud-konto eller dit team ændrede sig, mens %2$@ startede på %1$@, så starten stoppede. Kør `%3$@` igen i den konto, du vil bruge."}}, + "it": {"stringUnit": {"state": "needs_review", "value": "Il tuo account o team Cloud è cambiato mentre %2$@ si avviava su %1$@, quindi l'avvio si è fermato. Esegui di nuovo `%3$@` nell'account che vuoi."}}, + "km": {"stringUnit": {"state": "needs_review", "value": "គណនី ឬក្រុម Cloud របស់អ្នកបានផ្លាស់ប្ដូរ ខណៈពេល %2$@ កំពុងចាប់ផ្ដើមនៅលើ %1$@ ដូច្នេះការចាប់ផ្ដើមបានឈប់។ ដំណើរការ `%3$@` ម្ដងទៀតក្នុងគណនីដែលអ្នកចង់បាន។"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Cloud-kontoen eller teamet ditt endret seg mens %2$@ startet på %1$@, så oppstarten stoppet. Kjør `%3$@` på nytt i kontoen du vil bruke."}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Twoje konto lub zespół Cloud zmieniły się podczas uruchamiania %2$@ na %1$@, więc uruchamianie zostało zatrzymane. Uruchom ponownie `%3$@` na wybranym koncie."}}, + "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Sua conta ou equipe do Cloud mudou enquanto %2$@ iniciava em %1$@, então a inicialização parou. Execute `%3$@` novamente na conta que você quer."}}, + "ru": {"stringUnit": {"state": "needs_review", "value": "Ваш аккаунт или команда Cloud изменились, пока %2$@ запускался на %1$@, поэтому запуск остановлен. Снова выполните `%3$@` в нужном аккаунте."}}, + "th": {"stringUnit": {"state": "needs_review", "value": "บัญชีหรือทีม Cloud ของคุณเปลี่ยนไประหว่างที่ %2$@ กำลังเริ่มบน %1$@ การเริ่มจึงหยุดลง เรียกใช้ `%3$@` อีกครั้งในบัญชีที่คุณต้องการ"}}, + "tr": {"stringUnit": {"state": "needs_review", "value": "%2$@ %1$@ üzerinde başlatılırken Cloud hesabınız veya ekibiniz değişti, bu yüzden başlatma durduruldu. `%3$@` komutunu istediğiniz hesapta yeniden çalıştırın."}}, + "uk": {"stringUnit": {"state": "needs_review", "value": "Ваш обліковий запис або команда Cloud змінилися, поки %2$@ запускався на %1$@, тому запуск зупинено. Знову виконайте `%3$@` у потрібному обліковому записі."}}, + "vi": {"stringUnit": {"state": "needs_review", "value": "Tài khoản hoặc nhóm Cloud của bạn đã thay đổi trong khi %2$@ đang khởi động trên %1$@, nên việc khởi động đã dừng. Chạy lại `%3$@` trong tài khoản bạn muốn."}} + } + }, "cloud.startSession.needsMachine": { "comment": "cmux cloud start-session run by a script or agent without a machine. %1$@ is the agent (claude). Keep the commands as written.", "extractionState": "manual", diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index 48874ddc014a..99635b0d26ce 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -184,16 +184,21 @@ struct CloudStartSessionTests { #expect(refusal.failure.message.contains("`cmux cloud start-session --target machine:vm-devbox --agent codex`")) #expect(refusal.failure.message != CloudStartSessionStrings.accountChanged(CloudStartSession.Request(machineID: "vm-devbox", name: nil, agent: "codex", cwd: nil).asked(machine: "devbox"))) } - let cancelled = Task { @MainActor in + let cancelled = Task { @MainActor () -> (any Error)? in withUnsafeCurrentTask { $0?.cancel() } - return await Result { try await CloudStartSession.created(closed, name: "devbox", id: "vm-devbox", agent: "codex") } + do { + _ = try await CloudStartSession.created(closed, name: "devbox", id: "vm-devbox", agent: "codex") + return nil + } catch { + return error + } } - guard case .failure(let error) = await cancelled.value else { Issue.record("a cancelled run went on"); return } - #expect(error is CancellationError) + let error = await cancelled.value + #expect(error is CancellationError, "a cancelled run's error: \(String(describing: error))") } /// What a keyed start did on the machine, in order. - final class StartSteps: Sendable { + nonisolated final class StartSteps: Sendable { let steps = Mutex([String]()) func note(_ step: String) { steps.withLock { $0.append(step) } } var all: [String] { steps.withLock { $0 } } From 4e0becc4cb34f9fadae0e749b9fe745671373eeb Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 03:38:08 -0700 Subject: [PATCH 25/33] cloud start-session asks: a creation that ends after forgetAll hands out nothing Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../Cloud/CloudStartSessionAsks.swift | 15 +++++++-- .../CloudStartSessionAsksTests.swift | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift index aade7db200a0..4a7a192b9009 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift @@ -196,6 +196,8 @@ final class CloudStartSessionRuns { /// Creations of a machine by name and account, each with its own token: two asks for one new /// name create one machine. private var creations: [Creation: (token: UUID, task: Task)] = [:] + /// Counts ``forgetAll()`` calls: a creation that ends after one is never handed out. + private var forgets = 0 /// Called when an ask joins a creation already going on (tests wait on it). var joinedCreation: @MainActor (String) -> Void = { _ in } var now: @MainActor () -> ContinuousClock.Instant = { ContinuousClock.now } @@ -206,19 +208,25 @@ final class CloudStartSessionRuns { } /// The machine `create` makes for `name` in `account`, or the one a creation already going on - /// for both makes. + /// for both makes. A creation the runs were forgotten during (``forgetAll()``) throws + /// `CancellationError` even if it finished: cancellation is cooperative. func creating(_ name: String, account: [String?], _ create: @escaping @MainActor () async throws -> CloudMachineSession) async throws -> CloudMachineSession { let key = Creation(name: name, account: account) + let asked = forgets + let unforgotten = { [self] (machine: CloudMachineSession) throws -> CloudMachineSession in + guard forgets == asked else { throw CancellationError() } + return machine + } if let running = creations[key] { joinedCreation(name) - return try await running.task.value + return try unforgotten(await running.task.value) } let token = UUID() // task-owner: one creation per name and account; removed when it ends or by forgetAll(). let task = Task { @MainActor in try await create() } creations[key] = (token, task) defer { if creations[key]?.token == token { creations[key] = nil } } - return try await task.value + return try unforgotten(await task.value) } /// The end of `key`'s run, or `pending` once `within` has passed (the run goes on): a kept @@ -260,6 +268,7 @@ final class CloudStartSessionRuns { /// The account the runs were asked in ended: each waiting caller gets its `forgotten` /// failure now, every run and creation is cancelled, and none is kept or joined after. func forgetAll() { + forgets += 1 for run in runs.values { run.forgotten = true for waiter in run.waiters.values { waiter.end.yield(.failure(waiter.forgotten)) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift index 1d391b613af7..1bc8f06894a5 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionAsksTests.swift @@ -455,4 +455,36 @@ struct CloudStartSessionAsksTests { #expect(creates.value == 2) #expect(one === session && two === session) } + + /// A creation that ignores cancellation and finishes after a sign-out or team switch hands its + /// machine to nobody: its asker and anyone who joined get `CancellationError`. + @Test func aCreationThatEndsAfterForgetAllHandsOutNothing() async throws { + let services = MachineIconTests.services() + let session = MachineIconTests.cloudMachine(services, id: "vm-forgot-\(UUID().uuidString.prefix(8))") + let runs = CloudStartSessionRuns() + let (events, event) = AsyncStream.makeStream() + runs.joinedCreation = { event.yield(.joined($0)) } + defer { event.finish() } + // Resumed only by the test, never by cancellation. + let resume = Box?>(nil) + let create: @MainActor () async throws -> CloudMachineSession = { + await withCheckedContinuation { continuation in + resume.value = continuation + event.yield(.entered(1)) + } + return session + } + func next() async -> CreationEvent? { + for await value in events { return value } + return nil + } + let first = Task { @MainActor in try await runs.creating("dev", account: Self.account, create) } + #expect(await next() == .entered(1)) + let joined = Task { @MainActor in try await runs.creating("dev", account: Self.account, create) } + #expect(await next() == .joined("dev")) + runs.forgetAll() + resume.value?.resume() + await #expect(throws: CancellationError.self) { _ = try await first.value } + await #expect(throws: CancellationError.self) { _ = try await joined.value } + } } From 6a1a5eebef2dd0bdda24295c17fc1c15cb023090 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 03:38:27 -0700 Subject: [PATCH 26/33] cloud start-session: a cancelled or account-ended connect wait is not a failed connect Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../Sources/CmuxNextApp/Cloud/CloudStartSession.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift index 6fc0e67b3690..548e5f3270cc 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift @@ -334,10 +334,13 @@ struct CloudStartSession { if session.machine.status == .paused { session.attach(origin: origin) } else { session.connect(origin: origin) } } let daemon = session.daemon - guard await CloudMove.holds(within: Self.connectWait, until: { + let up = await CloudMove.holds(within: Self.connectWait, until: { session.resumeFailure != nil || (daemon.connection != nil && AgentTabStore.registryHost(of: daemon) != nil) - }) else { throw failure() } - if session.resumeFailure != nil { throw failure() } + }) + // A sign-out, team switch or cancellation during the wait is that, not a machine that + // didn't connect. + try Self.checked(account, now: services.cloud.account, request.asked(machine: session.machine.title)) + guard up, session.resumeFailure == nil else { throw failure() } } /// How long a machine has to come up (Move to Cloud's own wait). From 89c3db27a23590fe8f79224f9534e9ac33dd6e09 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 03:45:34 -0700 Subject: [PATCH 27/33] cloud start-session: a step that failed after cancellation or an account change says so Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextApp/Cloud/CloudStartSession.swift | 14 +++++++++++--- .../CloudStartSessionTests.swift | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift index 548e5f3270cc..551928622357 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift @@ -186,7 +186,7 @@ struct CloudStartSession { } catch is CancellationError { throw CancellationError() } catch { - throw ActionFailure(message: CloudStartSessionStrings.notConnected(name, id: id, agent: agent)) + try Self.failed(account, now: services.cloud.account, request.asked(machine: name), CloudStartSessionStrings.notConnected(name, id: id, agent: agent)) } try still(name) let started: AgentTabRemoteStart.Started @@ -214,7 +214,7 @@ struct CloudStartSession { } catch is CancellationError { throw CancellationError() } catch { - throw ActionFailure(message: Self.startFailure(error, name: name, id: id, agent: agent)) + try Self.failed(account, now: services.cloud.account, request.asked(machine: name), Self.startFailure(error, name: name, id: id, agent: agent)) } // Where a hook that saves the session's trajectory off the machine (hq#4381) would go, once // acpmux has one: the session started, so its id is known here. None exists on base yet. @@ -247,6 +247,13 @@ struct CloudStartSession { guard account == now else { throw AccountChanged(failure: ActionFailure(message: CloudStartSessionStrings.accountChanged(asked))) } } + /// A step's failure as `message`, unless the run was cancelled or its account ended meanwhile: + /// a wait that ended for that (its own nil or error) is never reported as the step failing. + static func failed(_ account: [String?], now: [String?], _ asked: CloudStartSessionStrings.Asked, _ message: String) throws -> Never { + try checked(account, now: now, asked) + throw ActionFailure(message: message) + } + /// Between steps: the refusal itself. static func checked(_ account: [String?], now: [String?], _ asked: CloudStartSessionStrings.Asked) throws { do { try check(account, now: now, asked) } catch let changed as AccountChanged { throw changed.failure } @@ -318,7 +325,8 @@ struct CloudStartSession { throw changed.failure } catch { let detail = (error as? ActionFailure)?.message ?? (error as? any LocalizedError)?.errorDescription ?? String(describing: error) - throw ActionFailure(message: CloudStartSessionStrings.createFailed(name, quoted: Self.shellQuoted(name), agent: request.agent, reason: Self.reason(detail))) + try Self.failed(account, now: cloud.account, request.asked(machine: name), + CloudStartSessionStrings.createFailed(name, quoted: Self.shellQuoted(name), agent: request.agent, reason: Self.reason(detail))) } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index 99635b0d26ce..3e6e29cb011f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -375,6 +375,22 @@ struct CloudStartSessionTests { #expect(error is CancellationError) } + /// A step that failed while the run was cancelled or its account ended says that, never that + /// the step itself failed (a machine that didn't connect, an agent that didn't start). + @Test func aStepFailureAfterCancellationOrAnAccountChangeSaysSo() async throws { + let asked = Self.asked, step = CloudStartSessionStrings.notConnected("dev", id: "vm-x", agent: "claude") + #expect(throws: ActionFailure(message: step)) { try CloudStartSession.failed(Self.account, now: Self.account, asked, step) } + #expect(throws: ActionFailure(message: CloudStartSessionStrings.accountChanged(asked))) { + try CloudStartSession.failed(Self.account, now: ["user-1", "team-2"], asked, step) + } + let cancelled = Task { @MainActor () -> (any Error)? in + withUnsafeCurrentTask { $0?.cancel() } + do { try CloudStartSession.failed(Self.account, now: Self.account, asked, step) } catch { return error } + } + let error = await cancelled.value + #expect(error is CancellationError, "a cancelled run's step failure: \(String(describing: error))") + } + /// `--name` takes or creates only from a list the server answered just now for this account. @Test func onlyAFreshListOfThisAccountIsCurrent() { let now = ContinuousClock.now From 2d353a9faffce541bf57070b0f99c24870dccbbd Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 04:06:52 -0700 Subject: [PATCH 28/33] cloud start-session: a person's gesture is the outermost run's, passed explicitly Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextApp/Cloud/CloudStartSession.swift | 21 +++++++++++-------- .../CloudStartSessionTests.swift | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift index 551928622357..69a74b335e58 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift @@ -58,7 +58,8 @@ struct CloudStartSession { let key = CloudStartSessionRuns.Key(request, caller: nil, account: services.cloud.account) CloudHandlers.result("start cloud session", context) { try await runs.answer(key, within: Self.answerWithin, pending: Self.pending(request, services), forgotten: Self.forgotten(request, services)) { - try await start.run(request, invocation, context, account: key.account) + // A person's gesture only as the outermost run's origin says (a script's nested run is not one). + try await start.run(request, invocation, context, account: key.account, byPerson: CloudHandlers.origin(of: invocation) == .user) } } } @@ -96,7 +97,7 @@ struct CloudStartSession { asks.show(tab: tab.id, token: token, .working, CloudStartSessionStrings.starting(agent: request.agent, machine: named)) do { // The account the agent asked in: a switch while the card was open refuses. - let fields = try await start.run(request, asPerson, context, account: key.account, askedBy: caller, revealing: false) + let fields = try await start.run(request, asPerson, context, account: key.account, byPerson: true, askedBy: caller, revealing: false) asks.finish(tab: tab.id, token: token, failure: nil) return fields } catch is CancellationError { @@ -163,14 +164,16 @@ struct CloudStartSession { /// `account`: the user and team signed in when it was asked. It is checked before each step /// that changes anything and after each wait, so a team switch or sign-out mid-run starts /// nothing in the account signed in now. - func run(_ request: Request, _ invocation: ActionInvocation, _ context: AppActionContext, account: [String?], askedBy: String? = nil, + /// `byPerson`: the person's own gesture (a palette or menu run, or Start on an agent's card); + /// it decides how the machine is created and connected. + func run(_ request: Request, _ invocation: ActionInvocation, _ context: AppActionContext, account: [String?], byPerson: Bool, askedBy: String? = nil, revealing: Bool = true) async throws -> [String: ActionResultValue] { let still = { [services] (machine: String) throws in try Self.checked(account, now: services.cloud.account, request.asked(machine: machine)) } try still(request.name ?? request.machineID ?? "") - let session = try await machine(request, invocation, account: account, askedBy: askedBy) + let session = try await machine(request, invocation, account: account, byPerson: byPerson, askedBy: askedBy) let name = session.machine.title, id = session.machineID, agent = request.agent try still(name) - try await connected(session, invocation, request, account: account) + try await connected(session, request, account: account, byPerson: byPerson) try still(name) let daemon = session.daemon guard let host = AgentTabStore.registryHost(of: daemon) else { @@ -291,7 +294,7 @@ struct CloudStartSession { static let listFresh: Duration = .seconds(15) /// The machine named by id, else by name, else a new one with that name. - private func machine(_ request: Request, _ invocation: ActionInvocation, account: [String?], askedBy: String?) async throws -> CloudMachineSession { + private func machine(_ request: Request, _ invocation: ActionInvocation, account: [String?], byPerson: Bool, askedBy: String?) async throws -> CloudMachineSession { let machines = services.machines let cloud = services.cloud if let id = request.machineID { @@ -317,7 +320,7 @@ struct CloudStartSession { if let named = machines.cloud.first(where: { $0.machine.title == name }) { return named } try still() // The answer of a create asked in another account never joins this one's list. - return try await cloud.createMachine(name: name, startedByPerson: invocation.origin == .user, askedBy: askedBy ?? invocation.callerAgentSession, proceed: still) + return try await cloud.createMachine(name: name, startedByPerson: byPerson, askedBy: askedBy ?? invocation.callerAgentSession, proceed: still) } } catch is CancellationError { throw CancellationError() @@ -332,13 +335,13 @@ struct CloudStartSession { /// The machine connected with its store's id: a paused one resumes, a new one comes up. Nothing /// here waits on a client snapshot (hq#4381): the machine's own acpmux keeps its sessions. - private func connected(_ session: CloudMachineSession, _ invocation: ActionInvocation, _ request: Request, account: [String?]) async throws { + private func connected(_ session: CloudMachineSession, _ request: Request, account: [String?], byPerson: Bool) async throws { let failure = { ActionFailure(message: CloudStartSessionStrings.notConnected(session.machine.title, id: session.machineID, agent: request.agent)) } if session.daemon.connection == nil { await session.refreshIfMaybePaused() // The resume or connect below changes the machine: only for the account that asked. try Self.checked(account, now: services.cloud.account, request.asked(machine: session.machine.title)) - let origin = CloudHandlers.connectOrigin(for: invocation) + let origin: CloudLinkOrigin = byPerson ? .user : .script if session.machine.status == .paused { session.attach(origin: origin) } else { session.connect(origin: origin) } } let daemon = session.daemon diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index 3e6e29cb011f..dfd523ba8c52 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -335,7 +335,7 @@ struct CloudStartSessionTests { #expect(asked != services.cloud.account) do { _ = try await CloudStartSession(services: services).run(request, Self.invocation(["agent": .string("claude"), "name": .string(name)]), - AppActionContext(services: services), account: asked) + AppActionContext(services: services), account: asked, byPerson: false) Issue.record("a run asked in another account went on") } catch let failure as ActionFailure { #expect(failure.message == CloudStartSessionStrings.accountChanged(request.asked(machine: name))) From 4a8efc9f0c895f6511f92c44eaedfedefabba6fb Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 04:21:25 -0700 Subject: [PATCH 29/33] cloud start-session asks: mark the caller's one-shot deadline for the wakeup lint Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift index 4a7a192b9009..e7c01cff45f1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSessionAsks.swift @@ -242,6 +242,7 @@ final class CloudStartSessionRuns { run.waiters[caller] = (continuation, forgotten) // task-owner: this caller's answer deadline; cancelled below once the run ends first. let deadline = Task { @MainActor [sleep] in + // wakeup-allow: one-shot answer deadline of one caller; cancelled when its run ends first await sleep(within) continuation.yield(nil) } From 5de27f0665f54380753d6a317459b5b7fb0b44b6 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 04:23:10 -0700 Subject: [PATCH 30/33] cloud start-session: closing the tab during its start says the tab closed Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextApp/Cloud/CloudStartSession.swift | 19 +++++++++-- .../CloudStartSessionTests.swift | 34 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift index 69a74b335e58..d75c6c738894 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudStartSession.swift @@ -206,8 +206,10 @@ struct CloudStartSession { case let remote as AgentTabRemoteHost: // Each restart and start try is for the account that asked, checked right before it. let command = request.command - started = try await remote.startNow(cwd: request.cwd, harness: agent) { @MainActor [services] in - try Self.check(account, now: services.cloud.account, CloudStartSessionStrings.Asked(machine: name, agent: agent, command: command)) + started = try await Self.starting(name: name, id: id, agent: agent, account: account, now: { services.cloud.account }, asked: request.asked(machine: name)) { + try await remote.startNow(cwd: request.cwd, harness: agent) { @MainActor [services] in + try Self.check(account, now: services.cloud.account, CloudStartSessionStrings.Asked(machine: name, agent: agent, command: command)) + } } case let refused as AgentTabRefusedHost: throw refused.refusal default: throw AgentRemoteStartRefusal.startUnsupported(machine: name) @@ -240,6 +242,19 @@ struct CloudStartSession { } } + /// The session's start on the machine. Closing the new chat's tab cancels its start + /// (``AgentTabStore``'s release of the tab): that says the tab closed. The run's own + /// cancellation stays one, and an ended account says so. + static func starting(name: String, id: String, agent: String, account: [String?], now: () -> [String?], asked: CloudStartSessionStrings.Asked, + _ start: () async throws -> AgentTabRemoteStart.Started) async throws -> AgentTabRemoteStart.Started { + do { + return try await start() + } catch is CancellationError { + try check(account, now: now(), asked) + throw AccountChanged(failure: ActionFailure(message: CloudStartSessionStrings.tabClosed(name, id: id, agent: agent))) + } + } + /// A refusal that passes through a step's error mapping as it is: the account changed since /// the run was asked, or a step's own precondition failed. struct AccountChanged: Error { var failure: ActionFailure } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift index dfd523ba8c52..40739deaf455 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudStartSessionTests.swift @@ -197,6 +197,40 @@ struct CloudStartSessionTests { #expect(error is CancellationError, "a cancelled run's error: \(String(describing: error))") } + /// Closing the tab while its start is going on (the store cancels that start) says the tab + /// closed; an ended account says so; the run's own cancellation stays one. + @Test func closingTheTabDuringTheStartSaysSo() async throws { + let asked = CloudStartSession.Request(machineID: "vm-devbox", name: nil, agent: "codex", cwd: nil).asked(machine: "devbox") + let closed: () async throws -> AgentTabRemoteStart.Started = { throw CancellationError() } + func refusal(now: [String?], _ start: () async throws -> AgentTabRemoteStart.Started) async -> String? { + do { + _ = try await CloudStartSession.starting(name: "devbox", id: "vm-devbox", agent: "codex", account: Self.account, now: { now }, asked: asked, start) + return nil + } catch let refusal as CloudStartSession.AccountChanged { + return refusal.failure.message + } catch { + return "\(error)" + } + } + #expect(await refusal(now: Self.account, closed) == CloudStartSessionStrings.tabClosed("devbox", id: "vm-devbox", agent: "codex")) + #expect(await refusal(now: ["user-1", "team-2"], closed) == CloudStartSessionStrings.accountChanged(asked)) + let started = try await CloudStartSession.starting(name: "devbox", id: "vm-devbox", agent: "codex", account: Self.account, now: { Self.account }, asked: asked) { + AgentTabRemoteStart.Started(surface: 2, session: "s-2") + } + #expect(started == AgentTabRemoteStart.Started(surface: 2, session: "s-2")) + let cancelled = Task { @MainActor () -> (any Error)? in + withUnsafeCurrentTask { $0?.cancel() } + do { + _ = try await CloudStartSession.starting(name: "devbox", id: "vm-devbox", agent: "codex", account: Self.account, now: { Self.account }, asked: asked, closed) + return nil + } catch { + return error + } + } + let error = await cancelled.value + #expect(error is CancellationError, "a cancelled run's start: \(String(describing: error))") + } + /// What a keyed start did on the machine, in order. nonisolated final class StartSteps: Sendable { let steps = Mutex([String]()) From 47971522482b113247857ef0422c515ba79b99a0 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 05:12:01 -0700 Subject: [PATCH 31/33] cloud start-session: regenerate the action list after taking base Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- plans/cmux-next/actions.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plans/cmux-next/actions.md b/plans/cmux-next/actions.md index 94664530264a..70592ee54db3 100644 --- a/plans/cmux-next/actions.md +++ b/plans/cmux-next/actions.md @@ -45,13 +45,13 @@ Declare its placements (menu, group, rank) and whether the CLI offers it, or nam - `sidebar`: 87 - `notifications`: 21 - `agents`: 94 -- `cloud`: 60 +- `cloud`: 61 - `remote`: 18 - `settings`: 123 -## Counts (1214 actions) +## Counts (1215 actions) -Palette 1164, CLI verbs 698, right-click 571, MCP tools 616. +Palette 1165, CLI verbs 699, right-click 572, MCP tools 617. ## Menus @@ -68,7 +68,7 @@ Palette 1164, CLI verbs 698, right-click 571, MCP tools 616. - **browserLink**: browser.link.openInNewTab browser.link.openInNewWindow browser.link.openInNewSpace browser.link.openInNewWorkspace browser.link.openInSplit openLinkInDefaultBrowser | browser.link.openInIncognitoWindow | browser.link.saveAs browser.link.copy browser.link.copyText - **browserImage**: browser.image.openInNewTab | browser.image.saveAs browser.image.copy browser.image.copyAddress - **browserSelection**: browser.selection.copy home.sendSelectionToChief home.sendSelectionTo | browser.selection.search browser.selection.lookUp -- **cloudMachine**: cloudNewTerminal cloudOpenMachine cloudSSH cloudSetupGitHub | cloudRenameMachine | [connection] > (cloudResizeMachine palette.cloud.status palette.cloud.snapshot palette.cloud.fork palette.cloud.handoff palette.cloud.ports palette.cloud.promoteTemplate palette.cloud.restore cloudPauseMachine cloudResumeMachine cloudSetIdlePause cloudOpenBrowser cloudPortList cloudPortForward cloudPortClose | palette.cloud.deleteSnapshot) | [copy] > (cloudCopyMachineID cloudCopyLink cloudCopyPort) [tools] > (cloudFilesList cloudFileRead cloudFileWrite cloudFileMkdir cloudFileRemove cloudFileStat cloudPrepareSCP cloudTunnelAttach cloudTunnelDetach cloudTunnelRotateKey cloudNetworkList cloudFirewallList cloudFirewallGet cloudFirewallCreate cloudFirewallDelete | palette.cloud.tools) | cloudKillMachine +- **cloudMachine**: cloudNewTerminal cloudOpenMachine cloudSSH cloudSetupGitHub | cloudRenameMachine | [connection] > (cloudResizeMachine palette.cloud.status palette.cloud.snapshot palette.cloud.fork palette.cloud.handoff palette.cloud.ports palette.cloud.promoteTemplate palette.cloud.restore cloudPauseMachine cloudResumeMachine cloudSetIdlePause cloudOpenBrowser cloudPortList cloudPortForward cloudPortClose cloudStartSession | palette.cloud.deleteSnapshot) | [copy] > (cloudCopyMachineID cloudCopyLink cloudCopyPort) [tools] > (cloudFilesList cloudFileRead cloudFileWrite cloudFileMkdir cloudFileRemove cloudFileStat cloudPrepareSCP cloudTunnelAttach cloudTunnelDetach cloudTunnelRotateKey cloudNetworkList cloudFirewallList cloudFirewallGet cloudFirewallCreate cloudFirewallDelete | palette.cloud.tools) | cloudKillMachine - **sshMachine**: remote.newWorkspace | remote.reconnect remote.disconnect remote.install machine.doctor | remote.copyError | remote.forget - **machineSection**: [appearance] > (machine.setIcon machine.clearIcon) | machine.reconnect | remote.removeGroup - **newTab**: newSurface openBrowser.chromium palette.newAgentChat macScreen.open | browserProfile.newTab newTab.page | agentPane.togglePointerInspector From 4da4779d525eef6c3f602d568ddc19ecb881d0e0 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 05:58:18 -0700 Subject: [PATCH 32/33] cloud start-session: no async closure default argument (cx-bsue); document Agent in de and fr Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../Sources/CmuxNextApp/AgentTabRemoteHost.swift | 10 +++++++--- scripts/localization-allowed-omissions.json | 7 +++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabRemoteHost.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabRemoteHost.swift index 8987c71e0ea7..2d9d918ff836 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabRemoteHost.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabRemoteHost.swift @@ -67,7 +67,7 @@ nonisolated struct AgentTabRemoteHost: AgentPaneHostProviding { let started: AgentTabRemoteStart.Started do { started = try await Self.keyedStart(cwd, harness, approvals: approvals, - servesKeyedReason: servesKeyedReason, start: start) + servesKeyedReason: servesKeyedReason, proceed: Self.proceedAlways, start: start) } catch let refusal as AgentRemoteStartRefusal { throw AgentSessionRemoteError(code: refusal.code, message: servesTrust ? refusal.userMessage : refusal.withoutTrust.userMessage, folder: refusal.folder) } @@ -86,11 +86,15 @@ nonisolated struct AgentTabRemoteHost: AgentPaneHostProviding { return handshake } + /// A start with no step to check first (the page's own first send). A named function, not a + /// default `{}`: an async closure default argument can overrun its context (cx-bsue). + @Sendable static func proceedAlways() async throws {} + /// Starts the new chat now, as the page's first send would (`cmux cloud start-session`): the /// same keyed start, in `cwd` (nil: the tab's seed folder, then its workspace's) with `harness`. /// `proceed` runs before each step that changes that machine (a keyed restart, each start /// try): its throw stops the start there, and what it threw is what this throws. - func startNow(cwd: String?, harness: String, proceed: @escaping @Sendable () async throws -> Void = {}) async throws -> AgentTabRemoteStart.Started { + func startNow(cwd: String?, harness: String, proceed: @escaping @Sendable () async throws -> Void) async throws -> AgentTabRemoteStart.Started { guard let start else { throw AgentRemoteStartRefusal.startUnsupported(machine: machine) } return try await Self.keyedStart(cwd, harness, approvals: approvals, servesKeyedReason: servesKeyedReason, proceed: proceed, start: start) } @@ -102,7 +106,7 @@ nonisolated struct AgentTabRemoteHost: AgentPaneHostProviding { /// restart; after the restart (or without one) the chat starts as it is. private static func keyedStart( _ cwd: String?, _ harness: String?, approvals: AgentPaneRemoteApprovals?, servesKeyedReason: Bool, - proceed: @escaping @Sendable () async throws -> Void = {}, + proceed: @escaping @Sendable () async throws -> Void, start unchecked: @escaping @Sendable (String?, String?, AgentSessionAttachClient.PersonKey?) async throws -> AgentTabRemoteStart.Started ) async throws -> AgentTabRemoteStart.Started { let start = { @Sendable (cwd: String?, harness: String?, key: AgentSessionAttachClient.PersonKey?) in diff --git a/scripts/localization-allowed-omissions.json b/scripts/localization-allowed-omissions.json index b9187c2ada2f..424a06d7e5af 100644 --- a/scripts/localization-allowed-omissions.json +++ b/scripts/localization-allowed-omissions.json @@ -6941,5 +6941,12 @@ "identityLocales": { "de": "Text is the German noun, spelled as in English; the translated entry is still required." } + }, + "argument.cloudStartSession.agent": { + "source": "Agent", + "identityLocales": { + "de": "Agent is the standard German word for an AI agent, spelled as in English; cmux NEXT's German strings use it for the same harness argument (argument.newTab.agent, argument.agent.harness.id).", + "fr": "Agent is the standard French word for an AI agent, spelled as in English; cmux NEXT's French strings use it for the same harness argument (argument.newTab.agent, argument.agent.harness.id)." + } } } From f8ebfb902eea569913feb183b294f325169e6a1f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 06:21:43 -0700 Subject: [PATCH 33/33] cloud start-session: translate Agent and Start where they matched English, instead of an allowlist entry Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- .../CmuxNextActions/CloudSessionActions.xcstrings | 12 ++++++------ .../CmuxNextApp/Resources/CloudSessions.xcstrings | 4 ++-- scripts/localization-allowed-omissions.json | 7 ------- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/CloudSessionActions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/CloudSessionActions.xcstrings index df275e74e978..7f4b14ce4ae1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/CloudSessionActions.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/CloudSessionActions.xcstrings @@ -34,19 +34,19 @@ "localizations": { "en": {"stringUnit": {"state": "translated", "value": "Agent"}}, "ja": {"stringUnit": {"state": "translated", "value": "エージェント"}}, - "de": {"stringUnit": {"state": "needs_review", "value": "Agent"}}, - "fr": {"stringUnit": {"state": "needs_review", "value": "Agent"}}, + "de": {"stringUnit": {"state": "needs_review", "value": "KI-Agent"}}, + "fr": {"stringUnit": {"state": "needs_review", "value": "Agent IA"}}, "es": {"stringUnit": {"state": "needs_review", "value": "Agente"}}, "ar": {"stringUnit": {"state": "needs_review", "value": "الوكيل"}}, "ko": {"stringUnit": {"state": "needs_review", "value": "에이전트"}}, "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "代理"}}, "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "代理"}}, - "bs": {"stringUnit": {"state": "needs_review", "value": "Agent"}}, - "da": {"stringUnit": {"state": "needs_review", "value": "Agent"}}, + "bs": {"stringUnit": {"state": "needs_review", "value": "AI agent"}}, + "da": {"stringUnit": {"state": "needs_review", "value": "AI-agent"}}, "it": {"stringUnit": {"state": "needs_review", "value": "Agente"}}, "km": {"stringUnit": {"state": "needs_review", "value": "ភ្នាក់ងារ"}}, - "nb": {"stringUnit": {"state": "needs_review", "value": "Agent"}}, - "pl": {"stringUnit": {"state": "needs_review", "value": "Agent"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "KI-agent"}}, + "pl": {"stringUnit": {"state": "needs_review", "value": "Agent AI"}}, "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Agente"}}, "ru": {"stringUnit": {"state": "needs_review", "value": "Агент"}}, "th": {"stringUnit": {"state": "needs_review", "value": "เอเจนต์"}}, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings index 3d5667c6e978..a2fcb78ce6ca 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/CloudSessions.xcstrings @@ -15,10 +15,10 @@ "zh-Hans": {"stringUnit": {"state": "needs_review", "value": "启动"}}, "zh-Hant": {"stringUnit": {"state": "needs_review", "value": "啟動"}}, "bs": {"stringUnit": {"state": "needs_review", "value": "Pokreni"}}, - "da": {"stringUnit": {"state": "needs_review", "value": "Start"}}, + "da": {"stringUnit": {"state": "needs_review", "value": "Kør"}}, "it": {"stringUnit": {"state": "needs_review", "value": "Avvia"}}, "km": {"stringUnit": {"state": "needs_review", "value": "ចាប់ផ្ដើម"}}, - "nb": {"stringUnit": {"state": "needs_review", "value": "Start"}}, + "nb": {"stringUnit": {"state": "needs_review", "value": "Kjør"}}, "pl": {"stringUnit": {"state": "needs_review", "value": "Uruchom"}}, "pt-BR": {"stringUnit": {"state": "needs_review", "value": "Iniciar"}}, "ru": {"stringUnit": {"state": "needs_review", "value": "Запустить"}}, diff --git a/scripts/localization-allowed-omissions.json b/scripts/localization-allowed-omissions.json index 424a06d7e5af..b9187c2ada2f 100644 --- a/scripts/localization-allowed-omissions.json +++ b/scripts/localization-allowed-omissions.json @@ -6941,12 +6941,5 @@ "identityLocales": { "de": "Text is the German noun, spelled as in English; the translated entry is still required." } - }, - "argument.cloudStartSession.agent": { - "source": "Agent", - "identityLocales": { - "de": "Agent is the standard German word for an AI agent, spelled as in English; cmux NEXT's German strings use it for the same harness argument (argument.newTab.agent, argument.agent.harness.id).", - "fr": "Agent is the standard French word for an AI agent, spelled as in English; cmux NEXT's French strings use it for the same harness argument (argument.newTab.agent, argument.agent.harness.id)." - } } }