Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
76a9d35
build: let MarkdownWebRenderer's onViewAttachedToWindow default be ov…
austinywang Sep 1, 2026
51404a9
Merge origin/main into issue-11347-cloud-cli-parity-loop
austinywang Sep 1, 2026
d722991
cloud tree parity: fixes from the live CLI loop (#11347)
austinywang Sep 1, 2026
9548307
docs: sidebar-parity, command reference, CLI contract, bundled skill …
austinywang Sep 1, 2026
630e591
Merge origin/main (#11345 combined parity follow-ups) into issue-1134…
austinywang Sep 1, 2026
fbb2947
test: rename the port-row lookup so it does not shadow the merged byI…
austinywang Sep 1, 2026
90150ef
cloud tree parity: review follow-ups (#11347)
austinywang Sep 1, 2026
88091e5
test: initialize the socket-test fixture's locals before its stored p…
austinywang Sep 1, 2026
59edd65
cloud tree parity: cubic review follow-ups (#11347)
austinywang Sep 1, 2026
c4f2be8
test: CloudTreeNativeDragOwnershipTests' fixture gains projectInLocal…
austinywang Sep 1, 2026
a16e698
pbxproj: re-wire SurfaceRefreshCoalescer.swift (the previous insertio…
austinywang Sep 1, 2026
174b674
test: port rows are in the tree (drop the stale no-ports assertion); …
austinywang Sep 1, 2026
ede04c2
cloud tree parity: coalescer/refresh races, app-wide surface_id, loca…
austinywang Sep 1, 2026
7e82f4a
Merge remote-tracking branch 'origin/main' into issue-11347-cloud-cli…
austinywang Sep 1, 2026
be1ea4a
Merge remote-tracking branch 'origin/main' into issue-11347-cloud-cli…
austinywang Sep 1, 2026
7734930
Merge remote-tracking branch 'origin/main' into issue-11347-cloud-cli…
austinywang Sep 2, 2026
d82cf62
Merge remote-tracking branch 'origin/main' into issue-11347-cloud-cli…
austinywang Sep 2, 2026
bf531f9
Merge main and preserve Cloud CLI parity through current owners
lawrencecchen Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions CLI/CMUXCLI+VMTui.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1112,8 +1112,7 @@ extension CMUXCLI {
}
params["reuse"] = true
}
// --no-open: stage the workspace on the machine headlessly (it shows in
// `vm tree` and the sidebar; nothing opens or focuses locally).
// --no-open stages the workspace without opening or focusing locally.
if noOpen { params["open"] = false }
let response = try client.sendV2(method: "vm.workspace_new", params: params, responseTimeout: 240)
if jsonOutput { print(jsonString(response)); return }
Expand All @@ -1127,12 +1126,13 @@ extension CMUXCLI {
case "open":
guard positional.count == 2 else { throw CLIError(message: Self.vmWorkspaceUsage) }
var params: [String: Any] = ["id": machine, "workspace_id": positional[1]]
// "Open All Here" / "Open All in New Tabs" / a drop on a pane edge: the same
// destination flags `surface open` takes, on top of the remote workspace.
here = here || tabs || pane != nil || localWorkspace != nil
if direction != nil, pane == nil {
throw CLIError(message: "vm workspace open: --left/--right/--up/--down need --pane <id|ref>\n\n\(Self.vmWorkspaceUsage)")
}
if tabs, direction != nil {
throw CLIError(message: String(localized: "cli.vm.workspace.open.tabsAndSide", defaultValue: "vm workspace open: --tabs and a pane side (--left/--right/--up/--down) are two different placements; pass one") + "\n\n\(Self.vmWorkspaceUsage)")
}
if here {
params["here"] = true
if let localWorkspace { params["target_workspace_id"] = localWorkspace }
Expand Down
1 change: 1 addition & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"sourceLanguage": "en",
"strings": {
"cli.vm.workspace.open.tabsAndSide": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"vm workspace open: --tabs and a pane side (--left/--right/--up/--down) are two different placements; pass one"}},"ja":{"stringUnit":{"state":"translated","value":"vm workspace open: --tabs とペインの方向 (--left/--right/--up/--down) は異なる配置です。どちらか一方を指定してください"}},"de":{"stringUnit":{"state":"translated","value":"vm workspace open: --tabs und eine Fensterseite (--left/--right/--up/--down) sind unterschiedliche Platzierungen; geben Sie nur eine an"}},"fr":{"stringUnit":{"state":"translated","value":"vm workspace open : --tabs et un côté du volet (--left/--right/--up/--down) sont deux placements différents ; choisissez-en un seul"}},"ar":{"stringUnit":{"state":"translated","value":"vm workspace open: الخيار --tabs واتجاه الجزء (--left/--right/--up/--down) موضعان مختلفان؛ حدد أحدهما فقط"}},"es":{"stringUnit":{"state":"translated","value":"vm workspace open: --tabs y un lado del panel (--left/--right/--up/--down) son dos ubicaciones diferentes; indique solo una"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"vm workspace open:--tabs 和窗格方向 (--left/--right/--up/--down) 是两种不同的放置方式;请只指定一种"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"vm workspace open:--tabs 和窗格方向 (--left/--right/--up/--down) 是兩種不同的放置方式;請只指定一種"}},"ko":{"stringUnit":{"state":"translated","value":"vm workspace open: --tabs와 창 방향 (--left/--right/--up/--down)은 서로 다른 배치 방식입니다. 하나만 지정하세요"}}}},
"cli.sshPtyAttach.incompatibleDaemon": {
"extractionState": "manual",
"localizations": {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Cloud/MachinesPanelViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ final class MachinesPanelViewModel: ObservableObject {
treeTask?.cancel()
treeTask = Task { [weak self] in
if force {
await SurfaceCatalog.shared.refreshAll()
await CmuxTuiSurfaceProviderRegistry.shared.refreshEverything(catalog: SurfaceCatalog.shared)
Comment thread
austinywang marked this conversation as resolved.
Comment thread
austinywang marked this conversation as resolved.
}
guard !Task.isCancelled, let self else { return }
self.treeErrorDescription = nil
Expand Down
23 changes: 13 additions & 10 deletions Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,9 @@ import CmuxFoundation
import CmuxSettings
import Foundation

/// Owns one ``CmuxTuiSurfaceProvider`` per cloud machine and keeps the catalog's machine
/// list in step with the control plane: registers a provider for every machine the
/// account can see, unregisters deleted ones, and drives refreshes on the same 45 s
/// cadence the Machines panel uses. Signing out tears everything down.
///
/// The periodic fleet read is the only Cloud API traffic an idle app makes, so it
/// runs only while ``CloudActivationPolicy`` allows background Cloud work (Cloud
/// Machines on, or this Mac used Cloud before) and follows the Beta Features
/// toggle at runtime. Demand-driven reads (`refresh(force:)`, a `cmux vm` verb)
/// are explicit user actions and are not gated here.
/// Registers one provider per visible machine and removes deleted machines.
/// Polls every 45 seconds while CloudActivationPolicy permits background work;
/// explicit reads are user actions. Signing out tears down all providers.
@MainActor
final class CmuxTuiSurfaceProviderRegistry {
static let shared = CmuxTuiSurfaceProviderRegistry()
Expand Down Expand Up @@ -297,6 +290,16 @@ final class CmuxTuiSurfaceProviderRegistry {
}
}

/// Shared explicit Refresh for the sidebar and unfiltered catalog queries.
/// Discover newly created machines before refreshing the remaining providers.
func refreshEverything(catalog: SurfaceCatalog) async {
let listed = await refresh(force: true)
let refreshedCloud = listed ? Set(providers.keys) : []
await catalog.refreshAll(force: true, where: { machine in
machine.cloudMachineID.map { !refreshedCloud.contains($0) } ?? true
})
}

func provider(machineID: String) -> CmuxTuiSurfaceProvider? {
providers[machineID]
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/Surfaces/SurfaceCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,8 @@ final class SurfaceCatalog {
await provider.refresh(force: force)
}

func refreshAll(force: Bool = false) async {
for provider in providers.values {
func refreshAll(force: Bool = false, where include: (SurfaceMachineID) -> Bool = { _ in true }) async {
for provider in providers.values where include(provider.machine) {
await provider.refresh(force: force)
}
}
Expand Down
5 changes: 4 additions & 1 deletion Sources/Surfaces/SurfaceCatalogQueryService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import Foundation
@MainActor
struct SurfaceCatalogQueryService {
private let catalog: SurfaceCatalog
private let refreshCatalog: @MainActor () async -> Void
private let discoverCloudMachine: @MainActor (String) async -> Void

init(
catalog: SurfaceCatalog,
refreshCatalog: (@MainActor () async -> Void)? = nil,
discoverCloudMachine: @escaping @MainActor (String) async -> Void
) {
self.catalog = catalog
self.refreshCatalog = refreshCatalog ?? { await catalog.refreshAll(force: true) }
self.discoverCloudMachine = discoverCloudMachine
}

Expand All @@ -32,7 +35,7 @@ struct SurfaceCatalogQueryService {
_ = await provider(for: machine)
await catalog.refresh(machine: machine, force: true)
} else {
await catalog.refreshAll(force: true)
await refreshCatalog()
}
}
return catalog.export
Expand Down
6 changes: 3 additions & 3 deletions Sources/Surfaces/SurfaceSocketCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ extension TerminalController {
}
}

// MARK: - vm.* wrappers (kept for existing callers; same catalog underneath)

nonisolated func socketWorkerVMTreeResponse(id: Any?, params: [String: Any]) -> String {
if ManagedDevicePolicy().isEnforced(.disableCloud) || !CloudMachinesFeature.offMainIsEnabled() {
return v2Error(id: id, code: "cloud_disabled", message: CloudMachinesFeature.disabledMessage)
Expand Down Expand Up @@ -920,7 +918,9 @@ extension TerminalController {

@MainActor
private static func surfaceCatalogQuery(catalog: SurfaceCatalog) -> SurfaceCatalogQueryService {
SurfaceCatalogQueryService(catalog: catalog) { machineID in
SurfaceCatalogQueryService(catalog: catalog, refreshCatalog: {
await CmuxTuiSurfaceProviderRegistry.shared.refreshEverything(catalog: catalog)
}) { machineID in
_ = await CmuxTuiSurfaceProviderRegistry.shared.providerRefreshingIfMissing(machineID: machineID)
}
}
Expand Down
26 changes: 26 additions & 0 deletions cmuxTests/CmuxTuiSurfaceProviderRegistryDiscoveryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,32 @@ import Testing
@MainActor
@Suite
struct CmuxTuiSurfaceProviderRegistryDiscoveryTests {
@Test("Explicit Refresh discovers new machines and refreshes each provider once")
func explicitRefreshDiscoversTheFleet() async {
let catalog = SurfaceCatalog()
var lists = 0
var refreshed: [String] = []
let registry = CmuxTuiSurfaceProviderRegistry(
links: CloudMachineLinkManager(clientURL: nil, hub: nil, hostThemeColors: { nil }),
allowsBackgroundWork: { false },
listPage: {
lists += 1
return VMListPage(vms: [machine("vm-new")], limits: nil)
},
refreshProvider: { provider, force in
#expect(force)
refreshed.append(provider.machine.rawValue)
},
notificationCenter: NotificationCenter()
)
registry.start(catalog: catalog)
await registry.refreshEverything(catalog: catalog)
#expect(lists == 1)
#expect(refreshed == ["vm-new"])
#expect(catalog.snapshot.machines.map(\.id) == [.cloud("vm-new")])
await registry.accessDidEnd()
}

@Test("Discovering a new VM does not wait for another VM's blocked refresh")
func missingProviderDiscoveryDoesNotWaitForUnrelatedLinks() async {
let catalog = SurfaceCatalog()
Expand Down
20 changes: 20 additions & 0 deletions cmuxTests/SurfaceCatalogQueryServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ import Testing
@MainActor
@Suite
struct SurfaceCatalogQueryServiceTests {
@Test("Only an explicit unfiltered read runs the shared fleet refresh")
func unfilteredRefreshUsesSharedAction() async throws {
let catalog = SurfaceCatalog()
let provider = try CloudCatalogQueryTestProvider(machine: .cloud("vm-new"), catalog: catalog)
var refreshes = 0
let query = SurfaceCatalogQueryService(catalog: catalog, refreshCatalog: {
refreshes += 1
catalog.register(provider)
await catalog.refreshAll(force: true)
}) { _ in }
_ = await query.read(machine: nil, refresh: false)
#expect(refreshes == 0)
let result = await query.read(machine: nil, refresh: true)
#expect(refreshes == 1)
#expect(provider.forcedRefreshes == [true])
#expect(result.catalog.resources.map(\.id.key) == ["term-seeded"])
_ = await query.read(machine: provider.machine, refresh: true)
#expect(refreshes == 1, "A machine-scoped refresh never refreshes the fleet")
}

@Test("A just-created machine is discovered before its seeded terminal is resolved")
func refreshedReadDiscoversMissingCloudProvider() async throws {
let catalog = SurfaceCatalog()
Expand Down
4 changes: 2 additions & 2 deletions docs/cli-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,9 @@ VM subcommands:
| `cloud domains publish <vm> <port> [--domain <hostname>] [--access personal\|team\|public] [--team <id>]`, `vm domains publish …` | Create one HTTPS publication (`vm.publication_create`). Generated names need no DNS setup. `personal` is owner-only (default), `team` requires the selected team id, and `public` allows anyone with the URL. |
| `cloud domains access <hostname> <personal\|team\|public> [--team <id>]`, `vm domains access …` | Change an existing publication's viewer policy (`vm.publication_update`); the first argument is a publication hostname or id, not a VM id. |
| `cloud domains rm <hostname>`, `vm domains rm …` | Remove a publication (`vm.publication_delete`). |
| `vm tree [<machine>\|local] [--refresh] [--json]` | The surface catalog (`surface.catalog`), rendered Finder-style: **This Mac** first (its terminals grouped by the local workspace showing them, then its browsers), then every cloud machine — Workspaces, Ports, VNC Displays (one row per screen), and a final Terminals section containing every machine-owned terminal. Workspace folders include their terminal, browser, and display layout; a canonical `browser/port:<n>` resource is also listed in the machine's Ports folder. Every line carries an address `vm open` or `surface open` accepts. `--refresh` re-syncs every provider first. `--json` prints the catalog payload with `cloud_states` (`sync_mode`: `journaled` or `snapshot_only`, cursor `(generation, revision)`, freshness, and pending writes) and resources with exact `remote_views: [{tab_id, workspace: {id, name, index, focused}, screen_id?, pane_id?, name?, index?, focused?, screen_index?, pane_index?}]`. A snapshot-only VM remains readable but rejects revision-fenced rename writes until its daemon is upgraded. Same as `surface ls`. |
| `vm tree [<machine>\|local] [--refresh] [--json]` | The surface catalog (`surface.catalog`), rendered Finder-style: **This Mac** first (its terminals grouped by the local workspace showing them, then its browsers), then every cloud machine — Workspaces, Ports, VNC Displays (one row per screen), and a final Terminals section containing every machine-owned terminal. Workspace folders include their terminal, browser, and display layout; a canonical `browser/port:<n>` resource is also listed in the machine's Ports folder. Every line carries an address `vm open` or `surface open` accepts. `--refresh` without a machine filter runs the sidebar’s Refresh action: it re-reads the fleet and re-syncs every provider. With a machine filter it discovers a missing machine and refreshes only that provider; cached reads never wake machines. `--json` prints the catalog payload with `cloud_states` (`sync_mode`: `journaled` or `snapshot_only`, cursor `(generation, revision)`, freshness, and pending writes) and resources with exact `remote_views: [{tab_id, workspace: {id, name, index, focused}, screen_id?, pane_id?, name?, index?, focused?, screen_index?, pane_index?}]`. A snapshot-only VM remains readable but rejects revision-fenced rename writes until its daemon is upgraded. Same as `surface ls`. |
| `vm workspace new <machine> [--name <name>] [--json]` | `vm.workspace_new`: creates a cmux-tui workspace on the machine (its ⌘N, with a first terminal) and opens it as a new local workspace. Prints `OK workspace=<local id> remote_workspace=<ws id> machine=<id>`. |
| `vm workspace open <machine> <workspace> [--here] [--tabs] [--workspace <local>] [--pane <id\|ref> [--left\|--right\|--up\|--down]] [--json]` | `vm.workspace_open`: the machine workspace's terminals, browsers and pinned displays as a new local workspace, one pane each (what clicking the sidebar row does). `<workspace>` is the `ws_…` id or an unambiguous workspace name, resolved exactly like the sidebar row (every view of every terminal counts); the payload's `remote_workspace_id` is the resolved id. An existing workspace with nothing in it opens nothing and answers `Nothing to open: … cmux vm open <machine>/<ws> starts a terminal there`. `--here`/`--tabs`/`--pane`+side instead project the group into an existing local workspace (`here: true` + the `surface open` destination params; one pane at the destination, the rest as tabs) — the sidebar's "Open All Here" / "Open All in New Tabs" / drop on a pane edge. |
| `vm workspace open <machine> <workspace> [--here] [--tabs] [--workspace <local>] [--pane <id\|ref> [--left\|--right\|--up\|--down]] [--json]` | `vm.workspace_open`: the machine workspace's terminals, browsers and pinned displays as a new local workspace, one pane each (what clicking the sidebar row does). `<workspace>` is the `ws_…` id or an unambiguous workspace name, resolved exactly like the sidebar row (every view of every terminal counts); the payload's `remote_workspace_id` is the resolved id. An existing workspace with nothing in it opens nothing and answers `Nothing to open: … cmux vm open <machine>/<ws> starts a terminal there`. `--tabs` and a pane side are rejected together. `--here`/`--tabs`/`--pane`+side instead project the group into an existing local workspace (`here: true` + the `surface open` destination params; one pane at the destination, the rest as tabs) — the sidebar's "Open All Here" / "Open All in New Tabs" / drop on a pane edge. |
| `vm prompt [--json]` / `vm prompt --open <agent>` (alias `skill`) | `vm.cloud_prompt` / `vm.cloud_agent_open`: installs the bundled cmux-cloud skill file at `~/.config/cmux/skills/cmux-cloud.md` and prints the kickoff prompt for any agent (the Machines panel's "Copy Cloud Prompt"), or opens a local terminal running claude\|codex\|opencode with it ("Open Cloud Agent"). |
| `vm workspace rename <machine> <workspace-id> <name> [--json]` | `vm.workspace_rename`: renames the cmux-tui workspace (the sidebar row's "Rename…") through the catalog's machine-scoped rename lane. |
| `vm tab rename <machine> <tab-id> <name> [--json]` | `vm.tab_rename`: renames one exact cmux-tui tab placement through the catalog's machine-scoped rename lane. Pass `""` as `<name>` to clear that tab's custom label and restore its generated title. Use the tab id from `vm tree --json`; the daemon revision fence prevents overwriting a concurrent rename. |
Expand Down
4 changes: 2 additions & 2 deletions skills/cmux-cloud-vm/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ cmux vpn up # enroll this Mac and bring the tunnel up
cmux vpn down # take this build's tunnel down (sudo)
cmux self # INSIDE a machine: this machine's name, id, status, team (--json: {schema, machine, team, machines}); guest `cmux vm self` lists the team's machines with this one marked *
cmux vm tree # the surface catalog: This Mac (terminals by workspace, browsers), then every machine → Workspaces, Ports, VNC Displays, Terminals
cmux vm tree <id> --refresh # one machine (`local` for This Mac), re-synced first
cmux vm tree <id> --refresh # one machine (`local` for This Mac), re-synced first; omit the id to refresh the fleet too
cmux vm workspace new <id> [--name n] # a new cmux-tui workspace on the machine (⌘N there), opened as a new local workspace
cmux vm workspace open <id> <ws-id> # open a machine workspace as a NEW local workspace: one pane per terminal/browser (clicking its row)
cmux vm workspace open <id> <ws-id> --here [--workspace <local>] # into the current local workspace: one pane + the rest as tabs (drop a workspace row onto a pane)
Expand Down Expand Up @@ -705,7 +705,7 @@ cmux vpn status # this build's WireGuard tunnel to its pr
cmux vpn up # enroll this Mac and bring the tunnel up (sudo); a stale tunnel (rotated keys) is replaced. One tunnel per deployment (`cmux` for production, `cmux-staging`/`cmux-dev` for dev builds), so a dev build and the production app can both be up
cmux vpn down # take this build's tunnel down (sudo)
cmux vm tree # the surface catalog: This Mac (terminals by workspace, browsers), then every machine → Workspaces, Ports, VNC Displays, Terminals
cmux vm tree <id> --refresh # one machine (`local` for This Mac), re-synced first (fleet + provider refresh)
cmux vm tree <id> --refresh # one machine (`local` for This Mac), re-synced first; omit the id to refresh the fleet too (fleet + provider refresh)
cmux vm workspace new <id> [--name n] [--reuse] [--no-open] # a new cmux-tui workspace on the machine (⌘N there); --reuse returns the existing workspace of that name instead of a second one
cmux vm workspace open <id> <ws-id> # open a machine workspace as a NEW local workspace: one pane per terminal/browser (clicking its row)
cmux vm workspace open <id> <ws-id> --here [--workspace <local>] # into the current local workspace: one pane + the rest as tabs (drop a workspace row onto a pane)
Expand Down