From 3e95edb4256adb84727fd68075574d6778f5e49a Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:18:29 -0700 Subject: [PATCH 01/13] cmux NEXT: New Cloud Space, a space that is one Cloud machine A Cloud space follows one Cloud machine's session and makes it the space's default session, in one create-profile command. Switching to it shows the machine's own workspaces, terminals and browser tabs; New Workspace, Cmd-N, an empty space's first workspace, the CLI and the palette create on the machine (startWorkspace resolves the space's default session). A paused machine resumes on that create, and the workspace is made once it connects, never on the Mac instead. The session resolves through the live daemon, else the home registry's transport record, so a paused machine still resolves after a relaunch. Entry points: palette, File > New, a Cloud machine's right-click menu, `cmux space new-cloud` and MCP. Co-Authored-By: Claude Opus 5.5 --- .../ActionSurfaceCatalog+Exemptions.swift | 2 +- .../ActionSurfaceCatalog+Menus.swift | 2 + .../Catalog/ProfileActionCatalog.swift | 10 ++ .../CmuxNextActions/ProfileActions.xcstrings | 131 ++++++++++++++++++ .../Sources/CmuxNextApp/AppActions.swift | 1 + .../Cloud/CloudMachineSession.swift | 17 +++ .../CmuxNextApp/Cloud/MachineRegistry.swift | 31 +++++ .../CmuxNextApp/Handlers/RoomHandlers.swift | 60 ++++++++ .../Windows/WindowManager+Creation.swift | 8 +- .../Sources/CmuxNextApp/WorkspaceSpawn.swift | 18 +++ .../DaemonConnection+Profiles.swift | 6 +- .../Requests/Profiles/ProfileCommands.swift | 9 +- .../CmuxNextAppTests/CloudSpaceTests.swift | 101 ++++++++++++++ 13 files changed, 388 insertions(+), 8 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift index 826540389892..8408fa18f8dd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift @@ -50,7 +50,7 @@ nonisolated extension ActionSurfaceCatalog { "space.moveRight", "space.moveToStart", "space.moveToEnd", "space.move", "space.switch", "workspace.moveToSpace", "workspace.duplicateToSpace", "workspaceGroup.moveToSpace", "space.setTheme", "space.clearTheme", // Every space action is a verb (Lawrence 14223: no exceptions): the space keys and the color family too. - "space.next", "space.previous", "space.selectByNumber", "space.openInNewWindow", "space.newIncognito", "space.color.grey", "space.color.blue", + "space.next", "space.previous", "space.selectByNumber", "space.openInNewWindow", "space.newIncognito", "space.newCloud", "space.color.grey", "space.color.blue", "space.color.red", "space.color.yellow", "space.color.green", "space.color.pink", "space.color.purple", "space.color.cyan", "space.color.orange", "space.newWindow", "space.newWorkspace", "space.rename", "space.setColor", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Menus.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Menus.swift index ad4150954275..45ce2a82089b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Menus.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Menus.swift @@ -133,6 +133,8 @@ nonisolated extension ActionSurfaceCatalog { // the space actions stay in the palette and the CLI (`.minimalMenu`). "space.new": [p(.sidebarBackground, .create, 6, folder: .new)], "space.newIncognito": [p(.sidebarBackground, .create, 6, folder: .new)], + // A Cloud machine opens as its own space from its menu too (RoomHandlers.bindCloudSpace). + "space.newCloud": [p(.sidebarBackground, .create, 6, folder: .new), p(.cloudMachine, .create, 4)], "space.newWorkspace": [p(.profile, .create, 0, label: SpaceMenuLabels.newWorkspace)], "space.openInNewWindow": [p(.profile, .create, 1, label: SpaceMenuLabels.openInNewWindow)], "space.setIcon": [p(.profile, .identity, 100, label: IconMenuLabels.setIcon)], diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/ProfileActionCatalog.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/ProfileActionCatalog.swift index 301b00f0bc24..ebf8ade70576 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/ProfileActionCatalog.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/ProfileActionCatalog.swift @@ -29,6 +29,16 @@ nonisolated enum ProfileActionCatalog: ActionCatalogGroup { surfaces: [.palette, .keyboard, .menu, .contextMenu], arguments: [CatalogArgument.nameString.optional], cliName: "space new-incognito", mainMenu: .window ), + // Cloud space: a space that is one Cloud machine (its target, else the one New Cloud Workspace uses, else a + // new one). It follows the machine's session and creates there, so switching to it shows the machine's + // own workspaces, terminals and browser tabs (RoomHandlers.bindCloudSpace). + ActionDescriptor( + id: "space.newCloud", + title: String(localized: "action.space.newCloud", defaultValue: "New Cloud Space", table: "ProfileActions", bundle: .module), + keywords: ["room", "profile", "space", "cloud", "vm", "remote", "machine"], category: .workspace, symbol: "cloud", + surfaces: [.palette, .keyboard, .menu, .contextMenu], arguments: [CatalogArgument.nameString.optional], + targets: [.machine], cliName: "space new-cloud", mainMenu: .window + ), // Open Space in New Window (spaces-overhaul-DESIGN.md rule 4): a new window shows the space and the // window it came from keeps it. The space's right-click menu, a dot dragged out of the switcher, // the palette, the CLI and MCP run it. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ProfileActions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ProfileActions.xcstrings index 1cd73d70f5b7..5fdd390467de 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ProfileActions.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ProfileActions.xcstrings @@ -7730,6 +7730,137 @@ } } } + }, + "action.space.newCloud" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مساحة سحابية جديدة" + } + }, + "bs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Novi prostor u oblaku" + } + }, + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nyt cloud-space" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Neuer Cloud-Space" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Cloud Space" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nuevo espacio en la nube" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nouvel espace cloud" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nuovo spazio cloud" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "新規クラウドスペース" + } + }, + "km" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Space ពពកថ្មី" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "새 클라우드 스페이스" + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nytt skyområde" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nowa przestrzeń w chmurze" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Novo Espaço na Nuvem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Новое облачное пространство" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "สเปซคลาวด์ใหม่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Yeni Bulut Alanı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Новий хмарний простір" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Không gian đám mây mới" + } + }, + "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/AppActions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions.swift index 45145a381f12..869fe113d460 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions.swift @@ -51,6 +51,7 @@ enum AppActions { AutoArchiveActions(coordinator: services.autoArchive, host: services).bind(into: registry) NotificationSoundActions(host: services, gate: services.notifications.soundGate).bind(into: registry) RoomHandlers.bind(into: registry, context: context) + RoomHandlers.bindCloudSpace(into: registry, context: context) RoomMenuHandlers.bind(into: registry, context: context) ThemeHandlers.bind(into: registry, context: context) RemoteLocalhostHandlers.bind(into: registry, context: context) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift index ea1c293aa49d..6e0d39bf8bc1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift @@ -1,3 +1,4 @@ +import CmuxNextActions import CmuxNextCloud import CmuxNextDaemon import CmuxNextSidebar @@ -406,3 +407,19 @@ final class CloudMachineSession { } } } + +extension CloudMachineSession { + /// Wakes this machine for a Cloud space (a paused machine resumes, an ended link connects) and returns once its + /// daemon is connected. A failed resume ends the wait at once with its reason; the bound is `CloudMove`'s. + func connectedForSpace() async throws { + if daemon.connection == nil { + await refreshIfMaybePaused() + if machine.status == .paused { attach(origin: .user) } else { connect(origin: .user) } + } + let daemon = daemon + guard await CloudMove.holds(within: .seconds(180), until: { [weak self] in + self?.resumeFailure != nil || daemon.connection != nil + }) else { throw DaemonError.notConnected } + if let failure = resumeFailure { throw ActionFailure(message: failure) } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/MachineRegistry.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/MachineRegistry.swift index 8101909a5c7d..ac06cb689dd3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/MachineRegistry.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/MachineRegistry.swift @@ -228,3 +228,34 @@ nonisolated struct MachineRoutingRefusal: Error, Equatable { /// The machine whose tree lists the tab. let machineID: String } + +// Where a space's new workspaces go (plans/cmux-next/data-model.md 4 rule 3): a space with a default session +// creates there. A Cloud space follows one Cloud machine's session and creates on it, so the window shows that +// machine's own workspaces, terminals and browser tabs. The session is the stable key; the machine is found +// through the daemon that serves it now, else through the home registry's record of how to reach it, so a +// paused machine (no daemon identity yet this launch) still resolves to its Cloud session and wakes. +extension MachineRegistry { + /// The space's default session (nil: the local daemon, or the space's machine is gone). + func creationDaemon(for profile: ProfileID) -> DaemonService? { + guard let session = local.store.profile(profile)?.defaultSessionID else { return nil } + return daemon(forSession: session) + } + + /// The daemon serving `sessionID` now, else the Cloud machine the home registry recorded for it. + func daemon(forSession sessionID: String) -> DaemonService? { + if let live = daemon(session: sessionID) { return live } + let record = local.store.personal.session(sessionID.lowercased()) ?? local.store.personal.session(sessionID) + return record.flatMap(Self.cloudMachineID(of:)).flatMap(self.session)?.daemon + } + + /// The Cloud machine id a session record's transport names (`SessionRegistrar.transport`), nil for any other. + nonisolated static func cloudMachineID(of record: SessionRecord) -> String? { + guard case let .object(fields)? = record.transport, fields["kind"]?.stringValue == "cloud" else { return nil } + return fields["machine"]?.stringValue.flatMap { $0.isEmpty ? nil : $0 } + } + + /// The Cloud machine behind `daemon`, if it is one. + func cloudSession(of daemon: DaemonService) -> CloudMachineSession? { + daemon.isLocal ? nil : session(daemon.machineID) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift index e1d3bfc9c871..d060eb628eee 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift @@ -1,5 +1,6 @@ import AppKit import CmuxNextActions +import CmuxNextCloud import CmuxNextDaemon import CmuxNextDesign import CmuxNextSidebar @@ -308,3 +309,62 @@ enum RoomDefaultsUpdates { } } } + +/// New Cloud Space: a space that is one Cloud machine. It follows the machine's session, so every workspace on the +/// machine (its terminals and browser tabs) shows in it and nowhere else, and its default session is the machine, +/// so New Workspace in it creates there (`MachineRegistry.creationDaemon`). Switching to it shows the machine's +/// whole tree in the window; switching back shows the Mac's. One path for the palette, the File menu, a Cloud +/// machine's right-click menu, `cmux space new-cloud` and MCP. +extension RoomHandlers { + static func bindCloudSpace(into registry: ActionRegistry, context: AppActionContext) { + let cloud = context.services.cloud + let bound = registry.bindEffect("space.newCloud", requires: DaemonCapability.profiles.rawValue, daemon: context.services.machines.local, + unavailable: { cloud.unavailableReason ?? (cloud.isSignedIn ? nil : CloudStrings.signInFirst) }, + effect: { invocation in try newCloudSpace(invocation, context) }) + assert(bound, "space.newCloud is not in the action catalog") + } + + /// The machine (the target, else the one New Cloud Workspace would use, else a new one) wakes and connects, + /// then the space is made, or the existing space of that machine is reused, and shown when the run may change + /// the view. + private static func newCloudSpace(_ invocation: ActionInvocation, _ context: AppActionContext) throws -> ActionEffect { + try context.requireRooms() + let target = try CloudHandlers.workspaceMachine(invocation, context) + let services = context.services + let name = invocation["name"]?.stringValue.flatMap { $0.isEmpty ? nil : $0 } + let window = invocation.allowsViewChange ? context.activeWindow?.state.id : nil + // action-wait-allow: the space follows a session id that exists only once the Cloud machine is up and connected. + return .waiting(.network) { + do { + let machine = try await target ?? services.cloud.createMachine(name: nil, startedByPerson: invocation.origin == .user, + askedBy: invocation.callerAgentSession) + try await machine.connectedForSpace() + let daemon = machine.daemon + guard await CloudMove.holds(within: .seconds(30), until: { daemon.store.registryID != nil }), + let session = daemon.store.registryID else { throw ActionFailure(message: CloudStrings.notConnected) } + let profile = try await space(following: session, named: name ?? machine.machine.title, services: services) + if let window, let state = services.windows.states[window] { services.windows.switchProfile(profile, in: state) } + return nil + } catch { + return ActionWorkFailure("space.newCloud", error) + } + } + } + + /// The space whose default session is `session`, else a new one following it, with the next free color. + private static func space(following session: String, named name: String, services: AppServices) async throws -> ProfileID { + let local = services.machines.local + if let existing = local.store.profiles.first(where: { $0.defaultSessionID?.lowercased() == session.lowercased() }) { + return existing.id + } + guard let connection = local.connection else { throw DaemonError.notConnected } + let used = Set(local.store.profiles.compactMap(\.color)) + let id = ProfileID.generate() + _ = try await connection.createProfile(name: name, id: id, color: (GroupColor.automatic(used: used) ?? .grey).rawValue, icon: "cloud", + defaultSessionID: session, follows: [session]) + // The reply comes before the home store mirrors the space (`personal-changed`); entering it earlier would + // fall back as from a deleted space (cx-d8x5). + await local.store.mirrored(profile: id) + return id + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Creation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Creation.swift index 8ac9d3cad9dd..373abb9920d0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Creation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Creation.swift @@ -10,18 +10,18 @@ import CmuxNextDaemon // workspace, whichever of the command reply and the daemon delta comes // first. extension WindowManager { - /// Creates a workspace with one terminal on `daemon` (default: the local - /// daemon), placed in window `windowID` when given (see `claimNew`). + /// Creates a workspace with one terminal on `daemon` (default: the space's + /// default session, else the local daemon), placed in window `windowID` when given (see `claimNew`). /// Returns its id. func createWorkspace(cwd: String? = nil, on daemon: DaemonService? = nil, into windowID: String? = nil, frame: CGRect? = nil, newTabPage: Bool = false, inputToken: String? = nil) async -> String? { - let daemon = daemon ?? services.daemon var spawn = WorkspaceSpawn(cwd: cwd, newTabPage: newTabPage) spawn.inputToken = inputToken do { + // No daemon asked for: the space's own (a Cloud space's machine), else the local daemon (`startWorkspace`). return try await createWorkspace(spawn, on: daemon, into: windowID, frame: frame) } catch { - daemon.logger.error("create workspace failed: \(String(describing: error), privacy: .public)") + (daemon ?? services.daemon).logger.error("create workspace failed: \(String(describing: error), privacy: .public)") return nil } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceSpawn.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceSpawn.swift index 33377302256c..1e258d1db9b1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceSpawn.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceSpawn.swift @@ -21,8 +21,26 @@ extension WindowManager { frame: CGRect? = nil) -> () async throws -> String { var spawn = spawn spawn.followClassicMode(services) + if daemon == nil, let space = spaceDaemon(spawn, into: windowID) { + // A Cloud space creates on its machine. A paused machine or an ended link wakes on this create, and the + // workspace is made once the machine connects, never on the Mac instead. + if space.connection == nil, let machine = services.machines.cloudSession(of: space) { + return { [self] in + try await machine.connectedForSpace() + return try await WorkspaceSpawner(windows: self, spawn: spawn, daemon: space, windowID: windowID, frame: frame).start()() + } + } + return WorkspaceSpawner(windows: self, spawn: spawn, daemon: space, windowID: windowID, frame: frame).start() + } return WorkspaceSpawner(windows: self, spawn: spawn, daemon: daemon ?? services.daemon, windowID: windowID, frame: frame).start() } + + /// The daemon a create with no machine asked for goes to: the default session of the space it is born in + /// (`spawn.profile`, else its window's, else the active window's), nil for the local daemon. + private func spaceDaemon(_ spawn: WorkspaceSpawn, into windowID: String?) -> DaemonService? { + let profile = spawn.profile ?? windowID.flatMap { states[$0]?.profileID } ?? active?.state.profileID + return profile.flatMap(services.machines.creationDaemon(for:)) + } } /// One workspace create: one terminal, or the New Tab page diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection+Profiles.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection+Profiles.swift index 5e8da58917ca..ab3642f5e651 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection+Profiles.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection+Profiles.swift @@ -21,10 +21,12 @@ extension DaemonConnection { @discardableResult public func createProfile(name: String, id: ProfileID = .generate(), color: String? = nil, icon: String? = nil, theme: String? = nil, index: Int? = nil, browserProfileID: BrowserProfileKey? = nil, - defaults: ProfileDefaults? = nil) async throws -> ProfileSnapshot { + defaults: ProfileDefaults? = nil, defaultSessionID: String? = nil, + follows: [String]? = nil) async throws -> ProfileSnapshot { try requireProfiles() return try await request(CreateProfileRequest(name: name, profile: id, color: color, icon: icon, theme: theme, index: index, - browserProfileID: browserProfileID, defaults: defaults)).profile + browserProfileID: browserProfileID, defaults: defaults, + defaultSessionID: defaultSessionID, follows: follows)).profile } @discardableResult diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Requests/Profiles/ProfileCommands.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Requests/Profiles/ProfileCommands.swift index a579e0a06eba..8651afc9f4dd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Requests/Profiles/ProfileCommands.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Requests/Profiles/ProfileCommands.swift @@ -22,9 +22,14 @@ public struct CreateProfileRequest: DaemonRequest { public var index: Int? public var browserProfileID: BrowserProfileKey? public var defaults: ProfileDefaults? + /// The session New Workspace creates in (a Cloud space's machine); nil for the local daemon. + public var defaultSessionID: String? + /// Sessions whose unpinned workspaces this profile shows (data-model.md 3.2). + public var follows: [String]? public init(name: String, profile: ProfileID? = nil, color: String? = nil, icon: String? = nil, theme: String? = nil, - index: Int? = nil, browserProfileID: BrowserProfileKey? = nil, defaults: ProfileDefaults? = nil) { + index: Int? = nil, browserProfileID: BrowserProfileKey? = nil, defaults: ProfileDefaults? = nil, + defaultSessionID: String? = nil, follows: [String]? = nil) { self.name = name self.profile = profile self.color = color @@ -33,5 +38,7 @@ public struct CreateProfileRequest: DaemonRequest { self.index = index self.browserProfileID = browserProfileID self.defaults = defaults + self.defaultSessionID = defaultSessionID + self.follows = follows } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift new file mode 100644 index 000000000000..cee129bc5539 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift @@ -0,0 +1,101 @@ +import AppKit +import CmuxNextActions +@testable import CmuxNextApp +import CmuxNextCloud +@testable import CmuxNextDaemon +import Foundation +import Testing + +/// A Cloud space is one Cloud machine (RoomHandlers.bindCloudSpace): it follows the machine's session and New Workspace in +/// it creates on that machine (`MachineRegistry.creationDaemon`), also while the machine is paused and this run has +/// no daemon identity for it yet, through the home registry's record of the session's transport. A space without a +/// default session, or whose machine is gone, creates on the Mac as before. +@MainActor @Suite(.serialized) +struct CloudSpaceTests { + static let cloudSpace = ProfileID(rawValue: "p-cloud") + static let plainSpace = ProfileID(rawValue: "p-plain") + static let goneSpace = ProfileID(rawValue: "p-gone") + + /// This Mac's home daemon with three spaces: one on Cloud machine `vm-space` (session `s-vm`), one plain, and + /// one on session `s-gone`, whose machine is no longer in the registry. + static func services() -> AppServices { + _ = NSApplication.shared + let services = AppServices(environment: AppEnvironment.current([:])) + var tree = DaemonTree(registryID: "s-local") + tree.personal = PersonalState(sessions: [ + SessionRecord(id: "s-local", transport: .object(["kind": .string("local")]), migrated: true), + SessionRecord(id: "s-vm", transport: .object(["kind": .string("cloud"), "machine": .string("vm-space")]), migrated: true), + SessionRecord(id: "s-gone", transport: .object(["kind": .string("cloud"), "machine": .string("vm-gone")]), migrated: true), + ], profiles: [ + ProfileSnapshot(id: .defaultProfile, name: "Default"), + ProfileSnapshot(id: cloudSpace, name: "VM", icon: "cloud", index: 1, defaultSessionID: "s-vm", follows: ["s-vm"]), + ProfileSnapshot(id: plainSpace, name: "Work", index: 2), + ProfileSnapshot(id: goneSpace, name: "Old VM", index: 3, defaultSessionID: "s-gone", follows: ["s-gone"]), + ]) + services.machines.local.store.apply(snapshot: tree) + return services + } + + /// A Cloud machine `id` this run has not connected (paused, or its link has not answered). + static func cloudMachine(_ services: AppServices, id: String) -> CloudMachineSession { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("cn-cloud-space-\(UUID().uuidString.prefix(8))") + let paths = CloudPaths(root: root), binary = URL(fileURLWithPath: "/usr/bin/false"), api = services.cloud.api + let hub = CloudTunnelHub(api: api, paths: paths, binary: binary, deviceName: "cn-cloud-space-test") + let link = CloudMachineLink(machineID: id, api: api, hub: hub, paths: paths, binary: binary, deviceName: "cn-cloud-space-test") + let session = CloudMachineSession(machine: CloudMachine(id: id, displayName: "Space VM"), link: link, mainWork: services.mainWork) + services.machines.add(session) + return session + } + + @Test func aCloudSpaceCreatesOnItsMachineEvenBeforeItConnects() throws { + let services = Self.services() + let machine = Self.cloudMachine(services, id: "vm-space") + #expect(machine.daemon.identity == nil, "the machine has not connected in this run") + let daemon = try #require(services.machines.creationDaemon(for: Self.cloudSpace), "a Cloud space resolved to no machine") + #expect(daemon === machine.daemon, "a Cloud space's new workspace must go to its machine, not the Mac") + #expect(services.machines.cloudSession(of: daemon) === machine) + } + + @Test func aSpaceWithoutADefaultSessionOrWithItsMachineGoneCreatesOnTheMac() { + let services = Self.services() + _ = Self.cloudMachine(services, id: "vm-space") + #expect(services.machines.creationDaemon(for: Self.plainSpace) == nil) + #expect(services.machines.creationDaemon(for: .defaultProfile) == nil) + #expect(services.machines.creationDaemon(for: Self.goneSpace) == nil, "a deleted machine's space falls back to the Mac") + #expect(services.machines.cloudSession(of: services.machines.local) == nil) + } + + @Test func theSpaceFollowsTheMachineSoItsWorkspacesLeaveTheDefaultSpace() { + let services = Self.services() + #expect(services.machines.local.store.personal.follows[Self.cloudSpace] == ["s-vm"]) + let membership = RoomMembership(follows: services.machines.local.store.personal.follows) + let onTheMachine = RoomMembership.Workspace(session: "s-vm", key: "w1") + #expect(membership.rooms(of: onTheMachine) == [Self.cloudSpace]) + #expect(membership.rooms(of: .init(session: "s-local", key: "w2")) == [.defaultProfile]) + } + + @Test func onlyACloudTransportNamesAMachine() { + #expect(MachineRegistry.cloudMachineID(of: SessionRecord(id: "a", transport: .object(["kind": .string("cloud"), "machine": .string("vm-1")]))) == "vm-1") + #expect(MachineRegistry.cloudMachineID(of: SessionRecord(id: "a", transport: .object(["kind": .string("local")]))) == nil) + #expect(MachineRegistry.cloudMachineID(of: SessionRecord(id: "a", transport: .object(["kind": .string("cloud"), "machine": .string("")]))) == nil) + #expect(MachineRegistry.cloudMachineID(of: SessionRecord(id: "a")) == nil) + } + + /// `create-profile` makes the space, its follow and its default session in one command (the daemon reads + /// `default_session_id` and `follows`), so it never shows for a moment as an empty Mac space. + @Test func createProfileSendsItsSessionAndFollowsInOneCommand() throws { + let request = CreateProfileRequest(name: "VM", profile: Self.cloudSpace, icon: "cloud", defaultSessionID: "s-vm", follows: ["s-vm"]) + let data = try WireCoding.encoder().encode(request) + let sent = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(sent["default_session_id"] as? String == "s-vm") + #expect(sent["follows"] as? [String] == ["s-vm"]) + let plain = try #require(JSONSerialization.jsonObject(with: try WireCoding.encoder().encode(CreateProfileRequest(name: "Work"))) as? [String: Any]) + #expect(plain["default_session_id"] == nil && plain["follows"] == nil, "a plain space sends neither") + } + + @Test func newCloudSpaceIsOnEverySurface() throws { + let descriptor = try #require(ActionCatalog.all.first { $0.id == "space.newCloud" }) + #expect(descriptor.cliName == "space new-cloud") + #expect(descriptor.targets == [.machine]) + } +} From c14447c1a831b2a5f15e1eb912fd58bcb9077aaf Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:19:54 -0700 Subject: [PATCH 02/13] cmux NEXT Cloud space: create the machine outside nil-coalescing (async in an autoclosure) Co-Authored-By: Claude Opus 5.5 --- .../Sources/CmuxNextApp/Handlers/RoomHandlers.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift index d060eb628eee..a7a3a00ffcc0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift @@ -336,8 +336,13 @@ extension RoomHandlers { // action-wait-allow: the space follows a session id that exists only once the Cloud machine is up and connected. return .waiting(.network) { do { - let machine = try await target ?? services.cloud.createMachine(name: nil, startedByPerson: invocation.origin == .user, - askedBy: invocation.callerAgentSession) + let machine: CloudMachineSession + if let target { + machine = target + } else { + machine = try await services.cloud.createMachine(name: nil, startedByPerson: invocation.origin == .user, + askedBy: invocation.callerAgentSession) + } try await machine.connectedForSpace() let daemon = machine.daemon guard await CloudMove.holds(within: .seconds(30), until: { daemon.store.registryID != nil }), From eefffde143a8c2a8bac503b479f06d1ba731ee78 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:28:16 -0700 Subject: [PATCH 03/13] cmux NEXT Cloud space: Mac-bound creates stay on the Mac; a paused space shows its workspaces; an unknown machine refuses Review findings: - A create carrying this Mac's folder, command, chat or session (Finder's New Workspace Here, cmux open , a resumed or imported chat) stays on the Mac. - Cmd-N decides its page and key hold on the daemon the space creates on. - Entering a space whose machine has not loaded waits for its tree and adopts the workspaces it already has; only an empty machine gets a new one. - A space naming a session no listed machine serves refuses the create instead of pinning a Mac workspace into it (MachineRegistry.SpaceCreation). - The session resolves through the shared transport reader (any machine kind); the space follows the identity's lowercased session id; a machine the person disconnected fails at once instead of waiting. Co-Authored-By: Claude Opus 5.5 --- .../CmuxNextApp/AppActions+Workspaces.swift | 5 ++- .../Cloud/CloudMachineSession.swift | 2 + .../CmuxNextApp/Cloud/MachineRegistry.swift | 34 +++++++++------ .../CmuxNextApp/Handlers/RoomHandlers.swift | 3 +- .../Windows/WindowManager+Profiles.swift | 28 +++++++++++++ .../Sources/CmuxNextApp/WorkspaceSpawn.swift | 35 ++++++++++------ .../CmuxNextAppTests/CloudSpaceTests.swift | 42 ++++++++++++------- 7 files changed, 107 insertions(+), 42 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift index 67f474c2b6d8..ee390f447307 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift @@ -148,11 +148,14 @@ extension AppActions { services.registry.track(Task { ActionWorkFailure(WorkspaceVerbStrings.machineNotConnected) }) return } + // With no machine asked for, the create goes where the space creates (a Cloud space's machine), so the page + // decision and the key hold below are that daemon's. + let spaceDaemon = windows.spaceCreation(spawn, into: target).daemon ?? services.daemon // A person's Cmd-N onto the New Tab page in the window they type in: every key from this // moment waits for the new page's field, in order (cx-kql5h), not for the workspace to show. let input = services.keyRouter.newTabInputCoordinator let opensPage = show && hasOpenWindow && spawn.opensNewTabPage && spawn.command == nil && spawn.firstChat == nil - && spawn.firstSession == nil && services.agentTabs.opensFirstPage(on: daemon ?? services.daemon) + && spawn.firstSession == nil && services.agentTabs.opensFirstPage(on: daemon ?? spaceDaemon) let keys = opensPage && target == windows.active?.state.id ? input.beginIncoming(in: windows.active?.window) : nil spawn.inputToken = keys // In this turn: the row, and for Cmd-N's page the workspace itself, show before any daemon reply. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift index 6e0d39bf8bc1..29b5ee710b94 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift @@ -412,6 +412,8 @@ extension CloudMachineSession { /// Wakes this machine for a Cloud space (a paused machine resumes, an ended link connects) and returns once its /// daemon is connected. A failed resume ends the wait at once with its reason; the bound is `CloudMove`'s. func connectedForSpace() async throws { + // A machine the person disconnected stays so until they connect it (its menu's Connect): no wait for it. + if disconnected { throw ActionFailure(message: CloudStrings.notConnected) } if daemon.connection == nil { await refreshIfMaybePaused() if machine.status == .paused { attach(origin: .user) } else { connect(origin: .user) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/MachineRegistry.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/MachineRegistry.swift index ac06cb689dd3..886248d2c9ff 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/MachineRegistry.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/MachineRegistry.swift @@ -40,6 +40,20 @@ final class MachineRegistry { self.local = local } + /// Where a create in a space goes (`creation(in:)`). + enum SpaceCreation { + /// The space has no default session: this Mac. + case mac + /// The space's default session's daemon. + case machine(DaemonService) + /// The space names a session no listed machine serves (the machine list has not come yet, the account + /// signed out, or the machine was deleted): the create is refused, never made on the Mac instead. + case unresolved + + /// The machine's daemon, nil for the other cases. + var daemon: DaemonService? { if case let .machine(daemon) = self { daemon } else { nil } } + } + /// Local first, then Cloud machines, then SSH machines, then servers. var daemons: [DaemonService] { [local] + remoteDaemons } @@ -233,25 +247,19 @@ nonisolated struct MachineRoutingRefusal: Error, Equatable { // creates there. A Cloud space follows one Cloud machine's session and creates on it, so the window shows that // machine's own workspaces, terminals and browser tabs. The session is the stable key; the machine is found // through the daemon that serves it now, else through the home registry's record of how to reach it, so a -// paused machine (no daemon identity yet this launch) still resolves to its Cloud session and wakes. +// paused machine (no daemon identity yet this launch) still resolves to its session and wakes. extension MachineRegistry { - /// The space's default session (nil: the local daemon, or the space's machine is gone). - func creationDaemon(for profile: ProfileID) -> DaemonService? { - guard let session = local.store.profile(profile)?.defaultSessionID else { return nil } - return daemon(forSession: session) + func creation(in profile: ProfileID) -> SpaceCreation { + guard let session = local.store.profile(profile)?.defaultSessionID else { return .mac } + guard let daemon = daemon(forSession: session) else { return .unresolved } + return daemon.isLocal ? .mac : .machine(daemon) } - /// The daemon serving `sessionID` now, else the Cloud machine the home registry recorded for it. + /// The daemon serving `sessionID` now, else the machine the home registry recorded for it. func daemon(forSession sessionID: String) -> DaemonService? { if let live = daemon(session: sessionID) { return live } let record = local.store.personal.session(sessionID.lowercased()) ?? local.store.personal.session(sessionID) - return record.flatMap(Self.cloudMachineID(of:)).flatMap(self.session)?.daemon - } - - /// The Cloud machine id a session record's transport names (`SessionRegistrar.transport`), nil for any other. - nonisolated static func cloudMachineID(of record: SessionRecord) -> String? { - guard case let .object(fields)? = record.transport, fields["kind"]?.stringValue == "cloud" else { return nil } - return fields["machine"]?.stringValue.flatMap { $0.isEmpty ? nil : $0 } + return Self.machineID(transport: record?.transport).flatMap(anyDaemon(machine:)) } /// The Cloud machine behind `daemon`, if it is one. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift index a7a3a00ffcc0..1fe805851e01 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift @@ -345,8 +345,9 @@ extension RoomHandlers { } try await machine.connectedForSpace() let daemon = machine.daemon + // The session id as `MachineRegistry.daemon(session:)` matches it (the identity's, lowercased). guard await CloudMove.holds(within: .seconds(30), until: { daemon.store.registryID != nil }), - let session = daemon.store.registryID else { throw ActionFailure(message: CloudStrings.notConnected) } + let session = daemon.identity?.sessionID ?? daemon.store.registryID?.lowercased() else { throw ActionFailure(message: CloudStrings.notConnected) } let profile = try await space(following: session, named: name ?? machine.machine.title, services: services) if let window, let state = services.windows.states[window] { services.windows.switchProfile(profile, in: state) } return nil diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift index 7a8549b468e6..6b65f10e3ee6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift @@ -41,6 +41,13 @@ extension WindowManager { // soon as the daemon mirrors it. state.enterProfile(profile) recordSaver.stateDidChange(state) + // A space on a machine whose tree has not loaded (a paused Cloud machine, a link still connecting) may + // hold workspaces already: wake it and show them once its tree loads, and create one only if it has none. + if case let .machine(daemon) = services.machines.creation(in: profile), !daemon.store.isLoaded { + let windowID = state.id + Task { [services] in await enterLoaded(profile, on: daemon, in: windowID); withExtendedLifetime(services) {} } + return + } Task { [services] in await createWorkspace(into: state.id); withExtendedLifetime(services) {} } return } @@ -49,6 +56,27 @@ extension WindowManager { select(remembered ?? visible.first, in: state) } + /// The rest of a switch into a space whose machine had not loaded: once its tree loads, the window shows the + /// space's workspaces no other window shows, else a new one. Nothing happens if the window left the space. + private func enterLoaded(_ profile: ProfileID, on daemon: DaemonService, in windowID: String) async { + do { + if let machine = services.machines.cloudSession(of: daemon) { try await machine.connectedForSpace() } + } catch { + return services.registry.refuse(RefusalStrings.describe(error)) + } + guard await CloudMove.holds(within: .seconds(60), until: { daemon.store.isLoaded }) else { + return services.registry.refuse(CloudStrings.notConnected) + } + guard let state = states[windowID], state.profileID == profile else { return } + let adoptable = adoptableWorkspaces(in: profile, for: windowID) + guard !adoptable.isEmpty else { + _ = await createWorkspace(into: windowID) + return + } + transition { $0.move(adoptable, to: windowID) } + select(adoptable.first, in: state) + } + /// Next (+1) or previous (-1) profile of the local daemon, clamped at /// the ends. Returns false when there is none that way. @discardableResult diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceSpawn.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceSpawn.swift index 1e258d1db9b1..6733a41efe31 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceSpawn.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceSpawn.swift @@ -1,3 +1,4 @@ +import CmuxNextActions import CmuxNextAppCore import CmuxNextAgentPane import CmuxNextBridge @@ -21,25 +22,33 @@ extension WindowManager { frame: CGRect? = nil) -> () async throws -> String { var spawn = spawn spawn.followClassicMode(services) - if daemon == nil, let space = spaceDaemon(spawn, into: windowID) { - // A Cloud space creates on its machine. A paused machine or an ended link wakes on this create, and the - // workspace is made once the machine connects, never on the Mac instead. - if space.connection == nil, let machine = services.machines.cloudSession(of: space) { - return { [self] in - try await machine.connectedForSpace() - return try await WorkspaceSpawner(windows: self, spawn: spawn, daemon: space, windowID: windowID, frame: frame).start()() + if daemon == nil { + switch spaceCreation(spawn, into: windowID) { + case .mac: break + case .unresolved: return { throw ActionFailure(message: CloudStrings.notConnected) } + case let .machine(space): + // A Cloud space creates on its machine. A paused machine or an ended link wakes on this create, + // and the workspace is made once the machine connects, never on the Mac instead. + if space.connection == nil, let machine = services.machines.cloudSession(of: space) { + return { [self] in + try await machine.connectedForSpace() + return try await WorkspaceSpawner(windows: self, spawn: spawn, daemon: space, windowID: windowID, frame: frame).start()() + } } + return WorkspaceSpawner(windows: self, spawn: spawn, daemon: space, windowID: windowID, frame: frame).start() } - return WorkspaceSpawner(windows: self, spawn: spawn, daemon: space, windowID: windowID, frame: frame).start() } return WorkspaceSpawner(windows: self, spawn: spawn, daemon: daemon ?? services.daemon, windowID: windowID, frame: frame).start() } - /// The daemon a create with no machine asked for goes to: the default session of the space it is born in - /// (`spawn.profile`, else its window's, else the active window's), nil for the local daemon. - private func spaceDaemon(_ spawn: WorkspaceSpawn, into windowID: String?) -> DaemonService? { - let profile = spawn.profile ?? windowID.flatMap { states[$0]?.profileID } ?? active?.state.profileID - return profile.flatMap(services.machines.creationDaemon(for:)) + /// Where a create with no machine asked for goes: the default session of the space it is born in + /// (`spawn.profile`, else its window's, else the active window's). A create that carries something of this + /// Mac (a folder, a command, a chat or a session to open: Finder's New Workspace Here, `cmux open `, a + /// chat resumed or imported) stays on the Mac whatever the space. + func spaceCreation(_ spawn: WorkspaceSpawn, into windowID: String?) -> MachineRegistry.SpaceCreation { + guard spawn.cwd == nil, spawn.command == nil, spawn.firstChat == nil, spawn.firstSession == nil else { return .mac } + guard let profile = spawn.profile ?? windowID.flatMap({ states[$0]?.profileID }) ?? active?.state.profileID else { return .mac } + return services.machines.creation(in: profile) } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift index cee129bc5539..1351e946eee2 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift @@ -1,6 +1,7 @@ import AppKit import CmuxNextActions @testable import CmuxNextApp +import CmuxNextAppCore import CmuxNextCloud @testable import CmuxNextDaemon import Foundation @@ -36,6 +37,9 @@ struct CloudSpaceTests { return services } + static func isMac(_ creation: MachineRegistry.SpaceCreation) -> Bool { if case .mac = creation { true } else { false } } + static func isUnresolved(_ creation: MachineRegistry.SpaceCreation) -> Bool { if case .unresolved = creation { true } else { false } } + /// A Cloud machine `id` this run has not connected (paused, or its link has not answered). static func cloudMachine(_ services: AppServices, id: String) -> CloudMachineSession { let root = FileManager.default.temporaryDirectory.appendingPathComponent("cn-cloud-space-\(UUID().uuidString.prefix(8))") @@ -51,20 +55,37 @@ struct CloudSpaceTests { let services = Self.services() let machine = Self.cloudMachine(services, id: "vm-space") #expect(machine.daemon.identity == nil, "the machine has not connected in this run") - let daemon = try #require(services.machines.creationDaemon(for: Self.cloudSpace), "a Cloud space resolved to no machine") - #expect(daemon === machine.daemon, "a Cloud space's new workspace must go to its machine, not the Mac") - #expect(services.machines.cloudSession(of: daemon) === machine) + #expect(services.machines.creation(in: Self.cloudSpace).daemon === machine.daemon, + "a Cloud space's new workspace must go to its machine, not the Mac") + #expect(services.machines.cloudSession(of: machine.daemon) === machine) + #expect(services.windows.spaceCreation(WorkspaceSpawn(profile: Self.cloudSpace), into: nil).daemon === machine.daemon) } - @Test func aSpaceWithoutADefaultSessionOrWithItsMachineGoneCreatesOnTheMac() { + @Test func aPlainSpaceCreatesOnTheMacAndAnUnknownMachineIsRefusedNotMadeOnTheMac() { let services = Self.services() _ = Self.cloudMachine(services, id: "vm-space") - #expect(services.machines.creationDaemon(for: Self.plainSpace) == nil) - #expect(services.machines.creationDaemon(for: .defaultProfile) == nil) - #expect(services.machines.creationDaemon(for: Self.goneSpace) == nil, "a deleted machine's space falls back to the Mac") + #expect(Self.isMac(services.machines.creation(in: Self.plainSpace))) + #expect(Self.isMac(services.machines.creation(in: .defaultProfile))) + #expect(Self.isUnresolved(services.machines.creation(in: Self.goneSpace)), + "a space whose machine is not listed must refuse, never pin a Mac workspace into it") #expect(services.machines.cloudSession(of: services.machines.local) == nil) } + /// Finder's New Workspace Here, `cmux open `, a resumed or imported chat: what they open is on this Mac. + @Test func aCreateCarryingSomethingOfThisMacStaysOnTheMacInACloudSpace() { + let services = Self.services() + _ = Self.cloudMachine(services, id: "vm-space") + var folder = WorkspaceSpawn(opening: "/Users/someone/project") + folder.profile = Self.cloudSpace + #expect(Self.isMac(services.windows.spaceCreation(folder, into: nil))) + var command = WorkspaceSpawn(profile: Self.cloudSpace) + command.command = "make" + #expect(Self.isMac(services.windows.spaceCreation(command, into: nil))) + var session = WorkspaceSpawn(profile: Self.cloudSpace) + session.firstSession = "claude-session-1" + #expect(Self.isMac(services.windows.spaceCreation(session, into: nil))) + } + @Test func theSpaceFollowsTheMachineSoItsWorkspacesLeaveTheDefaultSpace() { let services = Self.services() #expect(services.machines.local.store.personal.follows[Self.cloudSpace] == ["s-vm"]) @@ -74,13 +95,6 @@ struct CloudSpaceTests { #expect(membership.rooms(of: .init(session: "s-local", key: "w2")) == [.defaultProfile]) } - @Test func onlyACloudTransportNamesAMachine() { - #expect(MachineRegistry.cloudMachineID(of: SessionRecord(id: "a", transport: .object(["kind": .string("cloud"), "machine": .string("vm-1")]))) == "vm-1") - #expect(MachineRegistry.cloudMachineID(of: SessionRecord(id: "a", transport: .object(["kind": .string("local")]))) == nil) - #expect(MachineRegistry.cloudMachineID(of: SessionRecord(id: "a", transport: .object(["kind": .string("cloud"), "machine": .string("")]))) == nil) - #expect(MachineRegistry.cloudMachineID(of: SessionRecord(id: "a")) == nil) - } - /// `create-profile` makes the space, its follow and its default session in one command (the daemon reads /// `default_session_id` and `follows`), so it never shows for a moment as an empty Mac space. @Test func createProfileSendsItsSessionAndFollowsInOneCommand() throws { From 6512ca05b8003d9ab1beabb2adb7b19c545be00e Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:38:54 -0700 Subject: [PATCH 04/13] cmux NEXT Cloud space: never an empty window; first launch, opened files and scripts stay on the Mac Re-review findings: - Switching into a space whose machine is not listed is refused; the window keeps its space (regression test: switchingIntoASpaceWhoseMachineIsUnknown...). - A wake or load that fails returns the window to the space it came from, and says why only while the window still shows the Cloud space. - First launch's workspace, a file opened from this Mac, a browser-profile create and a script's create without --session stay on this Mac. - A resume failure from an earlier attempt no longer ends a new wait at once. - The space follows the tree's registry_id, as membership qualifies it. Co-Authored-By: Claude Opus 5.5 --- .../CmuxNextApp/AppActions+Workspaces.swift | 5 ++++- .../Cloud/CloudMachineSession.swift | 2 ++ .../CmuxNextApp/Handlers/RoomHandlers.swift | 4 ++-- .../Onboarding/ExternalOpenController.swift | 3 ++- .../CmuxNextApp/Windows/FirstWorkspace.swift | 3 ++- .../Windows/WindowManager+Profiles.swift | 22 +++++++++++++------ .../Sources/CmuxNextApp/WorkspaceSpawn.swift | 8 ++++--- .../CmuxNextAppTests/CloudSpaceTests.swift | 18 +++++++++++++++ 8 files changed, 50 insertions(+), 15 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift index ee390f447307..5434ee753ce9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift @@ -143,7 +143,10 @@ extension AppActions { let target: String? = show || !hasOpenWindow ? windows.targetWindow(preferring: windows.active?.state.id) : nil // `machine` (App machine id; the CLI's `--session`): born on that // session's daemon (plans/cmux-next/data-model.md 1.3). - let daemon = (invocation["machine"]?.targetValue?.id ?? invocation["machine"]?.stringValue).flatMap(services.machines.daemon(machine:)) + let asked = (invocation["machine"]?.targetValue?.id ?? invocation["machine"]?.stringValue).flatMap(services.machines.daemon(machine:)) + // With none asked for, a person's create (or one naming a space) goes where the space creates; a script's + // stays on this Mac, so a `cmux new-workspace` never wakes a paused Cloud machine of the space shown. + let daemon = asked ?? (invocation.origin == .user || spawn.profile != nil ? nil : services.daemon) if invocation["machine"] != nil, daemon?.connection == nil { services.registry.track(Task { ActionWorkFailure(WorkspaceVerbStrings.machineNotConnected) }) return diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift index 29b5ee710b94..9426dd245490 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift @@ -414,6 +414,8 @@ extension CloudMachineSession { func connectedForSpace() async throws { // A machine the person disconnected stays so until they connect it (its menu's Connect): no wait for it. if disconnected { throw ActionFailure(message: CloudStrings.notConnected) } + // A failure left from an earlier attempt must not end this wait before this attempt's own resume runs. + resumeFailure = nil if daemon.connection == nil { await refreshIfMaybePaused() if machine.status == .paused { attach(origin: .user) } else { connect(origin: .user) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift index 1fe805851e01..082d4ff10389 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift @@ -345,9 +345,9 @@ extension RoomHandlers { } try await machine.connectedForSpace() let daemon = machine.daemon - // The session id as `MachineRegistry.daemon(session:)` matches it (the identity's, lowercased). + // The session id as membership qualifies workspaces (`WindowProfiles`): the tree's `registry_id`. guard await CloudMove.holds(within: .seconds(30), until: { daemon.store.registryID != nil }), - let session = daemon.identity?.sessionID ?? daemon.store.registryID?.lowercased() else { throw ActionFailure(message: CloudStrings.notConnected) } + let session = daemon.store.registryID else { throw ActionFailure(message: CloudStrings.notConnected) } let profile = try await space(following: session, named: name ?? machine.machine.title, services: services) if let window, let state = services.windows.states[window] { services.windows.switchProfile(profile, in: state) } return nil diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Onboarding/ExternalOpenController.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Onboarding/ExternalOpenController.swift index 72a00c8117db..c1d14a546062 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Onboarding/ExternalOpenController.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Onboarding/ExternalOpenController.swift @@ -141,7 +141,8 @@ final class ExternalOpenController { let logger = services.daemon.logger Task { [weak self] in do { - _ = try await windows.createWorkspace(WorkspaceSpawn(), into: target) + // The file was opened from this Mac: its workspace is this Mac's, whatever space the window shows. + _ = try await windows.createWorkspace(WorkspaceSpawn(), on: windows.services.daemon, into: target) } catch { logger.error("create workspace for an opened file failed: \(String(describing: error), privacy: .public)") } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/FirstWorkspace.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/FirstWorkspace.swift index b4f8a4a09bff..064d7b709460 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/FirstWorkspace.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/FirstWorkspace.swift @@ -21,7 +21,8 @@ struct FirstWorkspaceLaunch { guard services.daemon.store.workspaces.needsFirstWorkspace(leftover: leftover, home: services.home.homeWorkspaceID) else { return nil } let landing = FirstWorkspaceLanding(launchIntoHome: HomeTunables.launchIntoHome.value, classic: services.home.dock.isClassic) if let home = await landing.home(ensured: services.home.homeWorkspaceID != nil, listed: { services.home.homeWorkspace?.id }) { return home } - return await manager.createWorkspace(newTabPage: true) + // This Mac's own tree is empty: its first workspace is this Mac's, whatever space the window shows. + return await manager.createWorkspace(on: manager.services.daemon, newTabPage: true) } /// Shows `workspaceID` (the one `create` made) in its window. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift index 6b65f10e3ee6..32f6fc8d005f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift @@ -39,13 +39,17 @@ extension WindowManager { // a new workspace (no empty window, REWRITE.md round 1). It is // claimed before the create command, so the switch shows it as // soon as the daemon mirrors it. + // A space naming a machine no listed machine serves (the list has not come, the account signed out) + // has nothing to show and nowhere to create: the window stays where it is. + if case .unresolved = services.machines.creation(in: profile) { return services.registry.refuse(CloudStrings.notConnected) } + let previous = state.profileID state.enterProfile(profile) recordSaver.stateDidChange(state) // A space on a machine whose tree has not loaded (a paused Cloud machine, a link still connecting) may // hold workspaces already: wake it and show them once its tree loads, and create one only if it has none. if case let .machine(daemon) = services.machines.creation(in: profile), !daemon.store.isLoaded { let windowID = state.id - Task { [services] in await enterLoaded(profile, on: daemon, in: windowID); withExtendedLifetime(services) {} } + Task { [services] in await enterLoaded(profile, on: daemon, in: windowID, from: previous); withExtendedLifetime(services) {} } return } Task { [services] in await createWorkspace(into: state.id); withExtendedLifetime(services) {} } @@ -57,17 +61,21 @@ extension WindowManager { } /// The rest of a switch into a space whose machine had not loaded: once its tree loads, the window shows the - /// space's workspaces no other window shows, else a new one. Nothing happens if the window left the space. - private func enterLoaded(_ profile: ProfileID, on daemon: DaemonService, in windowID: String) async { + /// space's workspaces no other window shows, else a new one. If the machine never loads, the window goes back + /// to `previous` (never left empty) with the reason. Nothing happens if the window left the space meanwhile. + private func enterLoaded(_ profile: ProfileID, on daemon: DaemonService, in windowID: String, from previous: ProfileID) async { + var failure: String? do { if let machine = services.machines.cloudSession(of: daemon) { try await machine.connectedForSpace() } + if await !CloudMove.holds(within: .seconds(60), until: { daemon.store.isLoaded }) { failure = CloudStrings.notConnected } } catch { - return services.registry.refuse(RefusalStrings.describe(error)) - } - guard await CloudMove.holds(within: .seconds(60), until: { daemon.store.isLoaded }) else { - return services.registry.refuse(CloudStrings.notConnected) + failure = RefusalStrings.describe(error) } guard let state = states[windowID], state.profileID == profile else { return } + if let failure { + switchProfile(previous, in: state) + return services.registry.refuse(failure) + } let adoptable = adoptableWorkspaces(in: profile, for: windowID) guard !adoptable.isEmpty else { _ = await createWorkspace(into: windowID) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceSpawn.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceSpawn.swift index 6733a41efe31..e0b59016688c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceSpawn.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceSpawn.swift @@ -43,10 +43,12 @@ extension WindowManager { /// Where a create with no machine asked for goes: the default session of the space it is born in /// (`spawn.profile`, else its window's, else the active window's). A create that carries something of this - /// Mac (a folder, a command, a chat or a session to open: Finder's New Workspace Here, `cmux open `, a - /// chat resumed or imported) stays on the Mac whatever the space. + /// Mac (a folder, a command, a chat or a session to open, a browser profile: Finder's New Workspace Here, + /// `cmux open `, a chat resumed or imported) stays on the Mac whatever the space. func spaceCreation(_ spawn: WorkspaceSpawn, into windowID: String?) -> MachineRegistry.SpaceCreation { - guard spawn.cwd == nil, spawn.command == nil, spawn.firstChat == nil, spawn.firstSession == nil else { return .mac } + guard spawn.cwd == nil, spawn.command == nil, spawn.firstChat == nil, spawn.firstSession == nil, spawn.browserProfile == nil else { + return .mac + } guard let profile = spawn.profile ?? windowID.flatMap({ states[$0]?.profileID }) ?? active?.state.profileID else { return .mac } return services.machines.creation(in: profile) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift index 1351e946eee2..a8f905d404d9 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift @@ -86,6 +86,24 @@ struct CloudSpaceTests { #expect(Self.isMac(services.windows.spaceCreation(session, into: nil))) } + /// A space whose machine is not listed (the machine list has not come, the account signed out) has nothing to + /// show and nowhere to create: the switch is refused and the window keeps its space, never an empty one. + @Test func switchingIntoASpaceWhoseMachineIsUnknownKeepsTheWindowWhereItIs() { + let services = Self.services() + let state = services.windows.state(for: "w-cloud-space") + #expect(state.profileID == .defaultProfile) + services.windows.switchProfile(Self.goneSpace, in: state) + #expect(state.profileID == .defaultProfile, "the window entered a space it can show nothing in") + } + + @Test func aBrowserProfileCreateStaysOnTheMac() { + let services = Self.services() + _ = Self.cloudMachine(services, id: "vm-space") + var browser = WorkspaceSpawn(profile: Self.cloudSpace) + browser.browserProfile = "work" + #expect(Self.isMac(services.windows.spaceCreation(browser, into: nil))) + } + @Test func theSpaceFollowsTheMachineSoItsWorkspacesLeaveTheDefaultSpace() { let services = Self.services() #expect(services.machines.local.store.personal.follows[Self.cloudSpace] == ["s-vm"]) From 5aa55b43ac9b9127ab513e843d724512939488c1 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:43:05 -0700 Subject: [PATCH 05/13] cmux NEXT Cloud space: a failed wake falls back without another wait Third review: the switch back to the previous space could start that space's own wait (another unloaded Cloud space) and loop between the two. fallBack goes to the previous space only when the window shows something there now, else to default, else to default with a new workspace on this Mac. Regression test: aFailedCloudSpaceFallsBackToDefaultNotIntoAnotherWait. Co-Authored-By: Claude Opus 5.5 --- .../Windows/WindowManager+Profiles.swift | 17 ++++++++++++++++- .../CmuxNextAppTests/CloudSpaceTests.swift | 11 +++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift index 32f6fc8d005f..968cc02c1371 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift @@ -73,7 +73,7 @@ extension WindowManager { } guard let state = states[windowID], state.profileID == profile else { return } if let failure { - switchProfile(previous, in: state) + fallBack(preferring: previous, in: state) return services.registry.refuse(failure) } let adoptable = adoptableWorkspaces(in: profile, for: windowID) @@ -85,6 +85,21 @@ extension WindowManager { select(adoptable.first, in: state) } + /// Leaves a Cloud space whose machine could not load, never through another wait: to `previous` when the window + /// shows something there now, else to `default` when it does, else to `default` with a new workspace on this + /// Mac. A wait into `previous` (another Cloud space that has not loaded) could fail back here and loop. + func fallBack(preferring previous: ProfileID, in state: WindowState) { + let members = registry.members(of: state.id), machines = services.machines + for target in [previous, .defaultProfile] where target != state.profileID + && !WindowProfiles.visible(members, profile: target, machines: machines).isEmpty { + return switchProfile(target, in: state) + } + state.enterProfile(.defaultProfile) + recordSaver.stateDidChange(state) + let windowID = state.id + Task { [services] in _ = await createWorkspace(on: services.daemon, into: windowID) } + } + /// Next (+1) or previous (-1) profile of the local daemon, clamped at /// the ends. Returns false when there is none that way. @discardableResult diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift index a8f905d404d9..20ab194cae0b 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift @@ -96,6 +96,17 @@ struct CloudSpaceTests { #expect(state.profileID == .defaultProfile, "the window entered a space it can show nothing in") } + /// A Cloud space whose machine failed to load falls back without another wait: its previous space here is + /// another Cloud space with nothing shown, so the window goes to `default`, never back and forth between them. + @Test func aFailedCloudSpaceFallsBackToDefaultNotIntoAnotherWait() { + let services = Self.services() + _ = Self.cloudMachine(services, id: "vm-space") + let state = services.windows.state(for: "w-cloud-fallback") + state.enterProfile(Self.goneSpace) + services.windows.fallBack(preferring: Self.cloudSpace, in: state) + #expect(state.profileID == .defaultProfile, "the window went into another Cloud space's wait: \(state.profileID)") + } + @Test func aBrowserProfileCreateStaysOnTheMac() { let services = Self.services() _ = Self.cloudMachine(services, id: "vm-space") From 0e379d2cf1b9369188f3070de81fa1e3dac81950 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:45:02 -0700 Subject: [PATCH 06/13] cmux NEXT Cloud space: the fallback adopts default's workspaces from a closed window before making one Co-Authored-By: Claude Opus 5.5 --- .../CmuxNextApp/Windows/WindowManager+Profiles.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift index 968cc02c1371..b69d3bb0fd85 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift @@ -86,12 +86,14 @@ extension WindowManager { } /// Leaves a Cloud space whose machine could not load, never through another wait: to `previous` when the window - /// shows something there now, else to `default` when it does, else to `default` with a new workspace on this + /// can show something there now, else to `default` when it can, else to `default` with a new workspace on this /// Mac. A wait into `previous` (another Cloud space that has not loaded) could fail back here and loop. func fallBack(preferring previous: ProfileID, in state: WindowState) { let members = registry.members(of: state.id), machines = services.machines + // Visible here, or adoptable from a closed window: both take switchProfile's non-empty path, never a wait. for target in [previous, .defaultProfile] where target != state.profileID - && !WindowProfiles.visible(members, profile: target, machines: machines).isEmpty { + && (!WindowProfiles.visible(members, profile: target, machines: machines).isEmpty + || !adoptableWorkspaces(in: target, for: state.id).isEmpty) { return switchProfile(target, in: state) } state.enterProfile(.defaultProfile) From d7126edfdfc8c5b10361f54eb05edc0a9f42ab1b Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:26:41 -0700 Subject: [PATCH 07/13] cmux NEXT Cloud space: a new workspace opens the agent New Tab page, its chats on the machine A Cloud machine whose daemon starts and attaches chats (agent-session-start-v1, agent-session-attach-v1) now opens the New Tab page first, as this Mac does, instead of a terminal. Its first page is built for that machine (its folders, no Mac home or projects, not warmed in this Mac's acpmux), and its tab is a registry: record, so the first send starts the chat in the machine's acpmux (the same path Cmd-T in a remote pane already takes). A machine without those capabilities keeps the terminal. Cmd-N holds typed keys only for a connected machine, never through a resume. Tests: aCloudWorkspacesFirstPageShowsTheMachineNotThisMac, aCloudMachineThatCannotStartChatsOpensATerminal. Co-Authored-By: Claude Opus 5.5 --- .../CmuxNextApp/AgentTabs+Header.swift | 2 +- .../CmuxNextApp/AgentTabs+Wiring.swift | 40 ++++++++++++------- .../Sources/CmuxNextApp/AgentTabs.swift | 8 +++- .../CmuxNextApp/AppActions+Workspaces.swift | 4 +- .../NewTab/NewTabPageMachine.swift | 5 ++- .../Sources/CmuxNextApp/NewTabPage.swift | 4 +- .../AgentPaneRelaunchWakeTests.swift | 2 +- .../AgentTabHarnessOnCreateTests.swift | 2 +- .../CmuxNextAppTests/CloudSpaceTests.swift | 19 +++++++++ .../SpareNewTabChatOpensTabsTests.swift | 2 +- 10 files changed, 63 insertions(+), 25 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift index 32da98572a89..0872ba19d5de 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift @@ -68,7 +68,7 @@ extension AgentTabStore { private func openNewTabColumn(beside provisional: String, cwd: String? = nil, split: Bool = true) -> Bool { let chat = resolve(provisional) // The page opens on the chat's machine: that machine's folders, never this Mac's (cx-k21v). - guard let newTab = firstPageNewTab?(cwd ?? lookup(chat)?.store.tab(id: chat)?.cwd, chat) else { return false } + guard let newTab = firstPageNewTab?(cwd ?? lookup(chat)?.store.tab(id: chat)?.cwd, chat, nil) else { return false } return openBeside(provisional, split: split) { pane, daemon in try self.open(in: pane, of: daemon, newTab: newTab) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift index efb8ab6d76d1..83dd9560957a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift @@ -24,11 +24,11 @@ extension AgentTabStore { func openFirstPage(workspace: WorkspaceHandle, cwd: String?, on daemon: DaemonService, chat: AgentPaneSeed? = nil) async throws -> SurfaceID? { guard let connection = daemon.connection, let localHost, canHost(on: daemon) else { throw DaemonError.notConnected } - // A workspace of another machine: its chat runs there (cx-k21v), never in this Mac's acpmux, - // and it gets no New Tab page (that page runs here). Callers open a terminal there instead - // of a first page (``opensFirstPage(on:chat:)``). + // A workspace of another machine: its chat runs there (cx-k21v), never in this Mac's acpmux. + // It gets the New Tab page only where that machine starts chats itself (a Cloud space's + // machine, ``opensRemotePage``); callers open a terminal there otherwise (``opensFirstPage(on:chat:)``). let remote = !daemon.isLocal - guard !remote || chat != nil else { throw DaemonError.notConnected } + guard !remote || chat != nil || opensRemotePage(daemon) else { throw DaemonError.notConnected } // A chat of this Mac (an outside chat it resumes, a past chat of its list) never opens in a // workspace of another machine (cx-k21v). if remote, chat?.adopt != nil || chat?.pastChat != nil { throw AgentTabRefusal(message: RefusalStrings.agentTabMachineMismatch) } @@ -55,7 +55,7 @@ extension AgentTabStore { return response.surface } seeds[key] = AgentPaneSeedSource(AgentPaneSeed(cwd: cwd)) - let made = firstPage(cwd: cwd, token: firstPageTokens[workspace]) + let made = firstPage(cwd: cwd, token: firstPageTokens[workspace], machine: remote ? daemon : nil) // The tree listed the tab first and its view already shows the page (with this // workspace's token): it keeps that page, since a new one remounts the screen and drops // what was typed (cx-kql5h); only the actions take this folder. @@ -76,17 +76,21 @@ extension AgentTabStore { } /// Whether a new or empty workspace of `daemon` opens with an agent tab: this Mac's opens the - /// New Tab page; another machine's opens only a chat it asked for (its terminal otherwise). - func opensFirstPage(on daemon: DaemonService, chat: Bool = false) -> Bool { canHost(on: daemon) && (daemon.isLocal || chat) } + /// New Tab page, and so does a Cloud machine's whose daemon starts chats there + /// (``opensRemotePage``); another machine's opens only a chat it asked for (its terminal otherwise). + func opensFirstPage(on daemon: DaemonService, chat: Bool = false) -> Bool { + canHost(on: daemon) && (daemon.isLocal || chat || opensRemotePage(daemon)) + } /// The chat seed of new `workspace`, held before its tab is known: the /// tree can list the tab before new-conversation-tab replies, and a view /// built then takes it (``firstChatSeed(of:in:)``). func seedFirstChat(_ seed: AgentPaneSeed, in workspace: WorkspaceHandle) { firstChats[workspace] = AgentPaneSeedSource(seed) } - /// A new workspace's first New Tab page, carrying that workspace's key-hold token. - func firstPage(cwd: String?, token: String?) -> (page: AgentPaneNewTab, handler: NewTabPageHandler)? { - guard var made = firstPageNewTab?(cwd, nil) else { return nil } + /// A new workspace's first New Tab page, carrying that workspace's key-hold token. `machine`: the + /// other machine the workspace is on (its folders, no Mac home); nil for this Mac. + func firstPage(cwd: String?, token: String?, machine: DaemonService? = nil) -> (page: AgentPaneNewTab, handler: NewTabPageHandler)? { + guard var made = firstPageNewTab?(cwd, nil, machine) else { return nil } if let token { made.page.inputToken = token onFirstPageToken?(token) @@ -143,12 +147,13 @@ extension AgentTabStore { NewTabPage.replace(key, with: request, cwd: request.cwd ?? cwd, in: controller, keep: tabs?.sessions[key] != nil) } } - tabs.firstPageNewTab = { [weak services] cwd, beside in + tabs.firstPageNewTab = { [weak services] cwd, beside, machine in guard let services else { return nil } - // A page beside a chat of another machine shows that machine's folders (cx-k21v). - // (A chat of this Mac keeps the plain page, as before.) + // A page beside a chat of another machine shows that machine's folders (cx-k21v), and so + // does the first page of a new workspace there (`machine`). (A chat of this Mac keeps the + // plain page, as before.) let tab = beside.flatMap { services.locateTab($0)?.0 }.flatMap { services.machines.daemon(forTab: $0).isLocal ? nil : $0 } - var page = NewTabPage.page(services, selected: tab) + var page = NewTabPage.page(services, selected: tab, machine: machine) page.cwd = cwd ?? page.cwd // Keys typed since Cmd-N wait for this page's field (NewTabInputCoordinator, cx-kql5h); // the token comes per workspace (``firstPage(cwd:token:)``). @@ -237,6 +242,13 @@ extension AgentTabStore { guard let daemon = try? services.machines.agentDaemon(forTab: tab) else { return nil } return services.agentGit.link(for: daemon) } + // A Cloud machine whose daemon starts and attaches chats there opens the New Tab page first, + // as this Mac does: a Cloud space's new workspace is the agent page, its chats on the machine. + tabs.opensRemotePage = { [weak services] daemon in + guard let services, services.machines.cloudSession(of: daemon) != nil else { return false } + return daemon.supports(DaemonCapability.agentSessionAttach.rawValue) && daemon.supports(DaemonCapability.agentSessionStart.rawValue) + && AgentTabStore.registryHost(of: daemon) != nil + } tabs.remoteMachineName = { [weak services] daemon in services?.machines.machineName(daemon.machineID).flatMap(AgentTabStore.displayName) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift index abaaf6efae9b..4d445258b0c5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift @@ -151,8 +151,12 @@ final class AgentTabStore { /// Opens a redeemed Run attempt's terminal, owned by an enrolled worker daemon, in the tab's pane. var runAttach: (@MainActor (_ key: String, _ request: AgentPaneRunAttach) async throws -> Void)? /// The New Tab page a new workspace's first tab shows, starting in the given folder. - /// `(cwd, beside)`: `beside` is the tab whose machine the page opens on (nil: this Mac). - var firstPageNewTab: ((String?, String?) -> (page: AgentPaneNewTab, handler: NewTabPageHandler)?)? + /// `(cwd, beside, machine)`: `beside` is the tab whose machine the page opens on, else `machine` + /// (a new workspace of another machine, before its tab is mirrored); nil for both: this Mac. + var firstPageNewTab: ((String?, String?, DaemonService?) -> (page: AgentPaneNewTab, handler: NewTabPageHandler)?)? + /// Whether a new or empty workspace of another machine opens the New Tab page, its chats run + /// there (a Cloud machine whose daemon starts them); otherwise it opens a terminal. + var opensRemotePage: @MainActor (DaemonService) -> Bool = { _ in false } /// Tabs opened as the chooser page, and the actions for their selected kind. var newTabPages: [String: (page: AgentPaneNewTab, handler: NewTabPageHandler)] = [:] { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift index 5434ee753ce9..437075235644 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift @@ -159,7 +159,9 @@ extension AppActions { let input = services.keyRouter.newTabInputCoordinator let opensPage = show && hasOpenWindow && spawn.opensNewTabPage && spawn.command == nil && spawn.firstChat == nil && spawn.firstSession == nil && services.agentTabs.opensFirstPage(on: daemon ?? spaceDaemon) - let keys = opensPage && target == windows.active?.state.id ? input.beginIncoming(in: windows.active?.window) : nil + // A Cloud space's machine still waking would hold the keys for its whole resume: only a connected one holds them. + let keys = opensPage && target == windows.active?.state.id && (daemon ?? spaceDaemon).connection != nil + ? input.beginIncoming(in: windows.active?.window) : nil spawn.inputToken = keys // In this turn: the row, and for Cmd-N's page the workspace itself, show before any daemon reply. let create = BenchSpans.measure("cmdN.start") { windows.startWorkspace(spawn, on: daemon, into: target) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabPageMachine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabPageMachine.swift index 854a21b955e8..13ed9304b104 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabPageMachine.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabPageMachine.swift @@ -17,8 +17,9 @@ struct NewTabPageMachine { /// `pane`: the pane the page opens in. With no selected tab (an empty pane) its machine /// decides: a pane of another machine uses its workspace's folder there, never this Mac's. - @MainActor init(_ services: AppServices, selected: TabModel?, pane: PaneController? = nil) { - guard let daemon = selected.map(services.machines.daemon(forTab:)) ?? pane?.daemon, !daemon.isLocal else { + /// `machine`: with neither, the machine of a new workspace whose tab is not mirrored yet. + @MainActor init(_ services: AppServices, selected: TabModel?, pane: PaneController? = nil, machine: DaemonService? = nil) { + guard let daemon = selected.map(services.machines.daemon(forTab:)) ?? pane?.daemon ?? machine, !daemon.isLocal else { // Beside an agent chat, the chat's exact folder (it has no daemon folder). (cwd, home, remoteFolders) = (NewTerminalStart.inherited(besides: selected, in: pane?.pane ?? selected.flatMap { services.locateTab($0.id)?.1 }, services: services) ?? selected?.cwd, NSHomeDirectory(), nil) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTabPage.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTabPage.swift index 863935bdf0ca..d71961e3eb69 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTabPage.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTabPage.swift @@ -173,11 +173,11 @@ enum NewTabPage { /// `window` is where the page shows (the active one when nil; a panel counts as the window /// that owns it): its dots' last pick comes first, then the saved `tabs.newTabTemplate` (cx-l1jv). static func page(_ services: AppServices, selected: TabModel?, window: NSWindow? = nil, - pane: PaneController? = nil) -> AgentPaneNewTab { + pane: PaneController? = nil, machine: DaemonService? = nil) -> AgentPaneNewTab { let selectedID = selected?.id let hotkeys = newActions.compactMapValues { services.registry.shortcutDisplay(for: $0) } // An SSH or Cloud tab's page shows that machine's folder, never this Mac's home (cx-gaq9). - let machine = NewTabPageMachine(services, selected: selected, pane: pane) + let machine = NewTabPageMachine(services, selected: selected, pane: pane, machine: machine) var bar = omnibar(services, excluding: selectedID) // A page on another machine offers that machine's folders only (cx-k21v). if let remote = machine.remoteFolders { (bar.folders, bar.projects) = (remote, remote) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneRelaunchWakeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneRelaunchWakeTests.swift index a85760aba31d..90b695e04840 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneRelaunchWakeTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneRelaunchWakeTests.swift @@ -101,7 +101,7 @@ struct AgentPaneRelaunchWakeTests { tabs.localHost = Self.thisMac tabs.lookup = { key in store.tab(id: key)?.agentSession.map { ($0, store) } } let handler = NewTabPageHandler(open: { _, _ in }, jump: { _, _ in }, editShortcut: { _ in }) - tabs.firstPageNewTab = { _, _ in (AgentPaneNewTab(kind: .agent), handler) } + tabs.firstPageNewTab = { _, _, _ in (AgentPaneNewTab(kind: .agent), handler) } tabs.blankChatHandler = { _ in handler } let spare = try #require(tabs.makeSpare(AgentPaneNewTab(kind: .agent))) tabs.takeSpare = { spare } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentTabHarnessOnCreateTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentTabHarnessOnCreateTests.swift index 2ef245c7bebe..bbe53380a0c8 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentTabHarnessOnCreateTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentTabHarnessOnCreateTests.swift @@ -46,7 +46,7 @@ struct AgentTabHarnessOnCreateTests { private static func wired(_ tabs: AgentTabStore, _ store: DaemonStore, binds: @escaping (Bind) -> Void) { tabs.lookup = { key in store.tab(id: key)?.agentSession.map { ($0, store) } } - tabs.firstPageNewTab = { _, _ in + tabs.firstPageNewTab = { _, _, _ in (AgentPaneNewTab(kind: .agent), NewTabPageHandler(open: { _, _ in }, jump: { _, _ in }, editShortcut: { _ in })) } tabs.bind = { _, _, expected, session, harness in diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift index 20ab194cae0b..2cf665e84a8f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift @@ -107,6 +107,25 @@ struct CloudSpaceTests { #expect(state.profileID == .defaultProfile, "the window went into another Cloud space's wait: \(state.profileID)") } + /// A Cloud space's first New Tab page is the machine's: its folders, never this Mac's home or projects, and + /// not warmed in this Mac's acpmux (its chats start on the machine). + @Test func aCloudWorkspacesFirstPageShowsTheMachineNotThisMac() { + let services = Self.services() + let machine = Self.cloudMachine(services, id: "vm-space") + let context = NewTabPageMachine(services, selected: nil, machine: machine.daemon) + #expect(context.home == nil, "a Cloud page offered this Mac's home") + #expect(context.remoteFolders != nil, "a Cloud page listed this Mac's projects") + #expect(NewTabPageMachine(services, selected: nil).home == NSHomeDirectory(), "this Mac's page lost its home") + } + + /// A machine whose daemon cannot start chats there (an older cmux-tui, not connected) keeps the terminal. + @Test func aCloudMachineThatCannotStartChatsOpensATerminal() { + let services = Self.services() + let machine = Self.cloudMachine(services, id: "vm-space") + #expect(!services.agentTabs.opensRemotePage(machine.daemon)) + #expect(!services.agentTabs.opensRemotePage(services.machines.local)) + } + @Test func aBrowserProfileCreateStaysOnTheMac() { let services = Self.services() _ = Self.cloudMachine(services, id: "vm-space") diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareNewTabChatOpensTabsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareNewTabChatOpensTabsTests.swift index 2812fd56f0fc..1057137f774d 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareNewTabChatOpensTabsTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareNewTabChatOpensTabsTests.swift @@ -44,7 +44,7 @@ struct SpareNewTabChatOpensTabsTests { try #require(tabs.canHostChat, "no agent page source") tabs.lookup = { key in store.tab(id: key)?.agentSession.map { ($0, store) } } var opened: [AgentPaneOpenTab] = [] - tabs.firstPageNewTab = { _, _ in (AgentPaneNewTab(kind: .agent), Self.handler { opened.append($0) }) } + tabs.firstPageNewTab = { _, _, _ in (AgentPaneNewTab(kind: .agent), Self.handler { opened.append($0) }) } tabs.blankChatHandler = { _ in Self.handler { opened.append($0) } } let spare = try #require(tabs.makeSpare(AgentPaneNewTab(kind: .agent))) tabs.takeSpare = { spare } From 65dbcf4191118ee7075809480846235cddd52563 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:41:35 -0700 Subject: [PATCH 08/13] cmux NEXT Cloud space: a plain space dot, and the machine leaves Default A Cloud space gets no icon: it shows its color dot like every space. The home daemon makes default follow each session it first records, so the machine's workspaces also showed in Default; New Cloud Space now takes the machine's session out of default's follows (also for an existing Cloud space). Co-Authored-By: Claude Opus 5.5 --- .../CmuxNextApp/Handlers/RoomHandlers.swift | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift index 082d4ff10389..edc108bb48f2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift @@ -360,17 +360,27 @@ extension RoomHandlers { /// The space whose default session is `session`, else a new one following it, with the next free color. private static func space(following session: String, named name: String, services: AppServices) async throws -> ProfileID { let local = services.machines.local + guard let connection = local.connection else { throw DaemonError.notConnected } + let id: ProfileID if let existing = local.store.profiles.first(where: { $0.defaultSessionID?.lowercased() == session.lowercased() }) { - return existing.id + id = existing.id + } else { + // A space's dot in its own color, like any other space (no icon). + let used = Set(local.store.profiles.compactMap(\.color)) + id = .generate() + _ = try await connection.createProfile(name: name, id: id, color: (GroupColor.automatic(used: used) ?? .grey).rawValue, + defaultSessionID: session, follows: [session]) + // The reply comes before the home store mirrors the space (`personal-changed`); entering it earlier would + // fall back as from a deleted space (cx-d8x5). + await local.store.mirrored(profile: id) + } + // The home daemon makes `default` follow every session it first records (put-session), so the machine's + // workspaces would show on the Mac's space too: the machine is the Cloud space's alone. + let defaultFollows = local.store.personal.follows[.defaultProfile] ?? [] + if let followed = defaultFollows.first(where: { $0.lowercased() == session.lowercased() }) { + _ = try await connection.request(SetProfileFollowsRequest(profile: .defaultProfile, + sessionIDs: defaultFollows.subtracting([followed]).sorted())) } - guard let connection = local.connection else { throw DaemonError.notConnected } - let used = Set(local.store.profiles.compactMap(\.color)) - let id = ProfileID.generate() - _ = try await connection.createProfile(name: name, id: id, color: (GroupColor.automatic(used: used) ?? .grey).rawValue, icon: "cloud", - defaultSessionID: session, follows: [session]) - // The reply comes before the home store mirrors the space (`personal-changed`); entering it earlier would - // fall back as from a deleted space (cx-d8x5). - await local.store.mirrored(profile: id) return id } } From 64d4b88bb718d0a8bfce7d5ac76253ee79edfdd5 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:54:25 +0000 Subject: [PATCH 09/13] test(cloud-space): a script's create on an unknown machine is refused, never made on this Mac (red) Review of the merge bf39da2bb94 found this in the PR's routing: with no space and a non-person origin, an unresolved `machine` fell back to this Mac's connected daemon and passed the not-connected check, so `cmux new-workspace --session --command ...` ran the command on the person's Mac. A fake local daemon (with profiles-v1, so the old path pins and creates) records the commands it gets; the test awaits the handler's captured work, then expects the machineNotConnected refusal and no create-workspace. Red here. --- .../CmuxNextAppTests/CloudSpaceTests.swift | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift index 2cf665e84a8f..d4868b5ff54b 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift @@ -155,6 +155,64 @@ struct CloudSpaceTests { #expect(plain["default_session_id"] == nil && plain["follows"] == nil, "a plain space sends neither") } + private struct Commands: DaemonRequest { + typealias Response = CmuxNextDaemon.JSONValue + static let command = "test-commands" + } + + /// This Mac's daemon, faked: it answers every command and lists the commands it was sent. + private static let localDaemon = #""" + import json, sys + print('{"ok":true}', file=sys.stderr, flush=True) + commands = [] + for line in sys.stdin: + frame = json.loads(line) + command = frame.get('cmd') + commands.append(command) + if command == 'identify': + data = {'app': 'cmux-tui', 'protocol': 12, 'generation': 'cloud-space-test', 'capabilities': ['profiles-v1']} + elif command == 'test-commands': + data = {'commands': commands} + elif command in ('list-workspaces',): + data = {'workspaces': []} + elif command == 'create-workspace': + data = {'workspace': 5, 'key': frame.get('key'), 'workspace_revision': 1, 'replayed': False} + else: + data = {} + print(json.dumps({'id': frame.get('id'), 'ok': True, 'data': data}), flush=True) + """# + + /// A script's create that names a machine this app cannot resolve (unknown, or Cloud off) is refused: it never + /// falls back to this Mac's connected daemon, so a command meant for that machine never runs on the person's + /// Mac (fail closed, REVIEW.md 5). + @Test func aScriptsCreateOnAnUnknownMachineIsRefusedNotMadeOnTheMac() async throws { + let services = Self.services() + let local = services.machines.local + let connection = DaemonConnection( + endpoint: .init(socketPath: "/tmp/cmux-cloud-space-\(UUID().uuidString).sock", + bridge: .fakePython(["-c", Self.localDaemon])), + configuration: .init(requiredCapabilities: [], requestTimeout: .seconds(1), spawnTimeout: .seconds(1), + terminalEnvironment: nil)) + let identity = try await connection.start() + local.start(first: { .success((connection, identity)) }, makeConnection: { connection }) + defer { local.shutdownConnection() } + await local.firstConnection() + try #require(local.connection != nil, "the test needs this Mac's daemon connected") + + let invocation = ActionInvocation(arguments: ["machine": .string("vm-unknown"), "command": .string("echo hi")], + origin: .script) + // The handler's own work, awaited to its end: no yield count decides the result. + let work = services.registry.capturingWork { _ = services.registry.perform("newTab", invocation: invocation) } + try #require(work.count == 1, "the create reported \(work.count) works") + var failures: [ActionWorkFailure?] = [] + for task in work { failures.append(await task.value) } + let sent = try await connection.request(Commands()) + await connection.closeAndWait() + let commands = sent["commands"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(failures.map { $0?.message } == [WorkspaceVerbStrings.machineNotConnected], "the create was not refused: \(failures)") + #expect(!commands.contains("create-workspace"), "a create for an unknown machine was made on this Mac: \(commands)") + } + @Test func newCloudSpaceIsOnEverySurface() throws { let descriptor = try #require(ActionCatalog.all.first { $0.id == "space.newCloud" }) #expect(descriptor.cliName == "space new-cloud") From 5d39eb03f43133deff623e46fa1a0fa7ca8f4e04 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:54:25 +0000 Subject: [PATCH 10/13] fix(cloud-space): a named machine that does not resolve is refused, never made on this Mac (fail closed) The not-connected check reads the daemon the request named (`asked`), not the fallback daemon a script's create without a machine goes to. --- .../CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift index 1d2fe94624f2..aceff6ae77dd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift @@ -156,7 +156,8 @@ extension AppActions { // With none asked for, a person's create (or one naming a space) goes where the space creates; a script's // stays on this Mac, so a `cmux new-workspace` never wakes a paused Cloud machine of the space shown. let daemon = asked ?? (invocation.origin == .user || spawn.profile != nil ? nil : services.daemon) - if invocation["machine"] != nil, daemon?.connection == nil { + // A named machine that does not resolve (unknown, or Cloud off) is refused, never made on this Mac instead. + if invocation["machine"] != nil, asked?.connection == nil { services.registry.track(Task { ActionWorkFailure(WorkspaceVerbStrings.machineNotConnected) }) return } From 27b2e57b1dac5eb9397277fab990e3250d60e3b0 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:56:04 +0000 Subject: [PATCH 11/13] arch-ratchet: a reviewed app_lines exception of +248 for New Cloud Space (owner austinywang, expiry 2026-11-08) Lead decision 2026-11-01 07:30Z. Receipt: the independent review of the code head 5d39eb03f43. Austin moves the cloud-space and window-space logic into packages before the expiry. --- plans/cmux-next/arch-ratchet.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/plans/cmux-next/arch-ratchet.json b/plans/cmux-next/arch-ratchet.json index a4463860d8e2..892a60f20cab 100644 --- a/plans/cmux-next/arch-ratchet.json +++ b/plans/cmux-next/arch-ratchet.json @@ -771,6 +771,16 @@ "owner": "aid-68ba07270dde07b96ff4", "receipt": "Review (codex gpt-6-astra high fast): SHIP at 75ee0affe9dc80b0a7550cc2533d46b54538403b", "expiry": "2026-12-31" + }, + { + "date": "2026-11-01", + "row": "app_lines", + "allow": 248, + "why": "#27810 (New Cloud Space, a space that is one Cloud machine; lead decision 2026-11-01 07:30Z): RoomHandlers.bindCloudSpace and its space(following:) (+76), WindowManager+Profiles enterLoaded/fallBack/SpaceCreation (+53), MachineRegistry.creation(in:)/daemon(forSession:)/cloudSession(of:) (+39), WorkspaceSpawn Cloud-space routing (+29), CloudMachineSession.connectedForSpace (+21), AgentTabs+Wiring/AppActions+Workspaces glue and the fail-closed machine check. They are methods and stored state of App types (WindowManager, MachineRegistry, CloudMachineSession, RoomHandlers) that no package can host yet, so a move needs host protocols first. The owner moves the cloud-space and window-space logic into packages before the expiry.", + "author": "austinywang", + "owner": "austinywang", + "receipt": "Review (claude-review-subagent-cw): SHIP at 5d39eb03f43133deff623e46fa1a0fa7ca8f4e04", + "expiry": "2026-11-08" } ], "escape_reviews": [ From a5a5f98e74b47258b2d994b284dc5ddadfc9db10 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:10:21 +0000 Subject: [PATCH 12/13] test(cloud-space): the unknown-machine create test binds the real actions and requires that New Workspace ran The test's services never bound AppActions, so perform("newTab") returned false and captured no work on both the red and the green (CloudSpaceTests.swift:206, work.count 0). It now binds them, requires the run, and has a one-minute limit so the red's create on this Mac cannot hang the suite. --- .../CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift index d4868b5ff54b..652c9c231967 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudSpaceTests.swift @@ -185,8 +185,10 @@ struct CloudSpaceTests { /// A script's create that names a machine this app cannot resolve (unknown, or Cloud off) is refused: it never /// falls back to this Mac's connected daemon, so a command meant for that machine never runs on the person's /// Mac (fail closed, REVIEW.md 5). - @Test func aScriptsCreateOnAnUnknownMachineIsRefusedNotMadeOnTheMac() async throws { + @Test(.timeLimit(.minutes(1))) func aScriptsCreateOnAnUnknownMachineIsRefusedNotMadeOnTheMac() async throws { let services = Self.services() + AppActions.bind(services) // the real New Workspace handler, as the app binds it + services.windows.ordersWindowsIn = false let local = services.machines.local let connection = DaemonConnection( endpoint: .init(socketPath: "/tmp/cmux-cloud-space-\(UUID().uuidString).sock", @@ -202,7 +204,9 @@ struct CloudSpaceTests { let invocation = ActionInvocation(arguments: ["machine": .string("vm-unknown"), "command": .string("echo hi")], origin: .script) // The handler's own work, awaited to its end: no yield count decides the result. - let work = services.registry.capturingWork { _ = services.registry.perform("newTab", invocation: invocation) } + var performed = false + let work = services.registry.capturingWork { performed = services.registry.perform("newTab", invocation: invocation) } + try #require(performed, "New Workspace did not run") try #require(work.count == 1, "the create reported \(work.count) works") var failures: [ActionWorkFailure?] = [] for task in work { failures.append(await task.value) } From 3f3693f3171f316d5217b1a0a7d8b6659a9573ed Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:46:26 +0000 Subject: [PATCH 13/13] arch-ratchet: take out the #27810 app_lines exception; #27810 waits for Austin's package move (merge service rule, cmux-ad 12:25Z) --- plans/cmux-next/arch-ratchet.json | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/plans/cmux-next/arch-ratchet.json b/plans/cmux-next/arch-ratchet.json index 892a60f20cab..a4463860d8e2 100644 --- a/plans/cmux-next/arch-ratchet.json +++ b/plans/cmux-next/arch-ratchet.json @@ -771,16 +771,6 @@ "owner": "aid-68ba07270dde07b96ff4", "receipt": "Review (codex gpt-6-astra high fast): SHIP at 75ee0affe9dc80b0a7550cc2533d46b54538403b", "expiry": "2026-12-31" - }, - { - "date": "2026-11-01", - "row": "app_lines", - "allow": 248, - "why": "#27810 (New Cloud Space, a space that is one Cloud machine; lead decision 2026-11-01 07:30Z): RoomHandlers.bindCloudSpace and its space(following:) (+76), WindowManager+Profiles enterLoaded/fallBack/SpaceCreation (+53), MachineRegistry.creation(in:)/daemon(forSession:)/cloudSession(of:) (+39), WorkspaceSpawn Cloud-space routing (+29), CloudMachineSession.connectedForSpace (+21), AgentTabs+Wiring/AppActions+Workspaces glue and the fail-closed machine check. They are methods and stored state of App types (WindowManager, MachineRegistry, CloudMachineSession, RoomHandlers) that no package can host yet, so a move needs host protocols first. The owner moves the cloud-space and window-space logic into packages before the expiry.", - "author": "austinywang", - "owner": "austinywang", - "receipt": "Review (claude-review-subagent-cw): SHIP at 5d39eb03f43133deff623e46fa1a0fa7ca8f4e04", - "expiry": "2026-11-08" } ], "escape_reviews": [