Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
51e3f9f
cmux-next: add the Run board webview and its Run API client
teamleaderleo Oct 20, 2026
0afe8b1
cmux-next: open a redeemed Run attempt's terminal from the agent pane…
teamleaderleo Oct 20, 2026
471cca3
web: reap retained Run attempt VMs and accept worker artifact manifests
teamleaderleo Oct 20, 2026
eaca1ab
test(web): expect a changed event retry to conflict in the Run route …
teamleaderleo Oct 20, 2026
bfb7f1e
test(web): a promoted attempt's VM is not claimed by the retention re…
teamleaderleo Oct 20, 2026
09b8a00
fix(web): keep promoted runtimes' VMs out of the retention reaper
teamleaderleo Oct 20, 2026
656651c
fix(cmux-next): poll the current run and gate Attach on the Run view
teamleaderleo Oct 20, 2026
ff6c6a5
fix(cmux-next): run.attach opens only on a connected Cloud machine
teamleaderleo Oct 20, 2026
916c969
Merge feat-cmux-next into cmux-next-run-board-attach
teamleaderleo Oct 21, 2026
208bdff
cmux-next: keep CmuxNextApp from growing with run.attach
teamleaderleo Oct 21, 2026
6778d4a
cmux-next: run.attach's public init imports Foundation publicly
teamleaderleo Oct 21, 2026
be32a9a
test(web): the retention reaper claims an attempt on its own ephemera…
teamleaderleo Oct 21, 2026
5f31216
fix(web): the retention reaper skips only attempts promoted to a pers…
teamleaderleo Oct 21, 2026
00381f9
test(web): promotion is refused while the reaper holds the attempt's …
teamleaderleo Oct 21, 2026
ab26ae0
fix(web): refuse to promote an attempt while the reaper is cleaning u…
teamleaderleo Oct 21, 2026
63031b4
Merge feat-cmux-next into cmux-next-run-board-attach
teamleaderleo Oct 21, 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
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public nonisolated struct AgentPageOps {
static let methods: [String: String] = {
var methods = Dictionary(uniqueKeysWithValues: [
"pane.checkpointAvailability", "pane.framePacing", "pane.painted", "pane.renderRate", "pane.saveLog", "pane.saveFile", "pane.showContextUsage", "pane.showDictation", "pane.setDisplay",
"pane.edit", "pane.setQuickActions", "tab.open", "tab.typeAhead", "tab.jump",
"pane.edit", "pane.setQuickActions", "tab.open", "run.attach", "tab.typeAhead", "tab.jump",
"newTab.remember", "newTab.setTemplate", "nowPlaying.command", "newTab.inputReady", "newTab.touched", "newTab.context", "newTab.warm", "shortcut.edit", "action.run", "file.open", "browser.open",
"project.list", "project.icon", "project.browse", "workspace.chooseFolder", "workspace.useFolder", "chat.folder.choose", "chat.attach.choose", "app.action", "chats.open", "chats.openInTerminal", "chats.page",
"quick.dismiss", "quick.openInWindow", "quick.startInBackground", "quick.setOnTop", "quick.setCollapsed", "quick.floatingState", "quick.context", "pane.action", "pane.tabState", "pane.tabRowTools", "chat.toolCard.respond", "chat.upload", "chat.sideChat", "chat.subagentPane", "chat.processTerminal", "chat.openTab", "chat.newHere", "chat.unsentDraft", "chat.scrollAnchor", "chat.scrollAnchor.get",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ public final class AgentPaneModel {
@ObservationIgnored public var onNewTabInputReady: ((String) -> Void)?
/// The new tab page chose a terminal or browser (`tab.open`).
@ObservationIgnored public var onOpenTab: ((AgentPaneOpenTab) -> Void)?
/// A redeemed Run attempt asks the App to open its enrolled worker's terminal (`run.attach`).
@ObservationIgnored public var onRunAttach: (@MainActor (AgentPaneRunAttach) async throws -> Void)?
/// What the user typed after `!` so far (`tab.typeAhead`).
@ObservationIgnored public var onTypeAhead: ((String) -> Void)?
/// The agent picked on the new tab screen, to remember (`newTab.remember`).
Expand Down Expand Up @@ -259,6 +261,8 @@ public final class AgentPaneModel {
markPainted()
return AgentPaneReply.success()
case .openTab(let request): return request.open(in: self)
case .runAttach(let request): return await request.attach(in: self)
case .invalidRunAttach: return AgentPaneRunAttach.invalidReply
case .typeAhead(let text): return AgentPaneOpenTab.typeAhead(text, in: self)
case .touched: return AgentPaneReply.success()
// The shell box runs on this Mac: never for a chat on another machine (cx-k21v).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ extension AgentPaneRequest {
} else {
self = .unsupported(method)
}
case "run.attach":
self = AgentPaneRunAttach(params: params ?? [:]).map(AgentPaneRequest.runAttach) ?? .invalidRunAttach
case "tab.typeAhead":
if let text = params?["text"] as? String {
self = .typeAhead(String(text.prefix(Self.maximumOpenTabText)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ extension AgentPaneRequest: CustomStringConvertible, CustomDebugStringConvertibl
case .renderRate: "renderRate"
case .openTab: "openTab"
case .typeAhead: "typeAhead"
case .runAttach: "runAttach"
case .invalidRunAttach: "invalidRunAttach"
case .shellRun: "shellRun"
case .harnessUpdate: "harnessUpdate"
case .harnessRestart: "harnessRestart"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable {
/// The command typed after `!` so far, whole each time, while the
/// terminal that replaces the page is being made (`tab.typeAhead`).
case typeAhead(String)
/// Opens a redeemed durable Run attempt's terminal on its enrolled worker (`run.attach`).
case runAttach(AgentPaneRunAttach)
/// `run.attach` whose params break its contract or whose endpoint expired; nothing opens.
case invalidRunAttach
/// Shell mode (`!` first in the composer or the new tab field): run
/// `command` in `cwd` (``AgentPaneShell``); answers `{id}`. Only with a
/// real gesture in the pane.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
public import Foundation

/// `run.attach`: a redeemed durable Run attach ticket, passed to the App so it opens the attempt's
/// terminal on the enrolled worker daemon as a remote-terminal tab in this pane.
///
/// The App resolves `providerVMID` and `providerTerminalID` against the Cloud machines it already
/// knows and opens only on a connected one whose session is `session`. Every field is the page's
/// claim: the route, carrier and expiry checks here only refuse a malformed or stale request, they
/// do not verify the ticket. The route is never dialed and no route token is accepted, so no
/// credential reaches the App or a shell. `expiresAtUnix` is in seconds.
public nonisolated struct AgentPaneRunAttach: Equatable, Sendable {
public let runID: String
public let attemptID: String
public let provider: String?
public let providerVMID: String
public let providerWorkspaceID: String?
public let providerTerminalID: String
public let route: String
public let session: String
public let expiresAtUnix: Double

/// Nil when a field breaks the contract or the endpoint has expired at `now`.
public init?(params: [String: Any], now: Date = Date()) {
guard let runID = Self.identifier(params["runId"]),
let attemptID = Self.identifier(params["attemptId"]),
let providerVMID = Self.identifier(params["providerVmId"]),
let providerTerminalID = Self.identifier(params["providerTerminalId"]),
let session = Self.identifier(params["session"]),
let route = params["route"] as? String, route.count <= 4096,
let url = URL(string: route), ["ws", "wss"].contains(url.scheme?.lowercased()), url.host != nil,
params["trustedCarrier"] as? Bool == true,
let expiresAtUnix = (params["expiresAtUnix"] as? NSNumber)?.doubleValue, expiresAtUnix.isFinite,
expiresAtUnix > now.timeIntervalSince1970 else { return nil }
self.runID = runID
self.attemptID = attemptID
provider = (params["provider"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(128)) }
self.providerVMID = providerVMID
providerWorkspaceID = Self.identifier(params["providerWorkspaceId"])
self.providerTerminalID = providerTerminalID
self.route = route
self.session = session
self.expiresAtUnix = expiresAtUnix
}

/// A non-empty string of at most 256 characters.
private static func identifier(_ value: Any?) -> String? {
guard let value = value as? String, !value.isEmpty, value.count <= 256 else { return nil }
return value
}

/// The reply to a `run.attach` whose params ``init(params:now:)`` refused.
static var invalidReply: [String: Any] {
AgentPaneReply.failure(code: "run_attach_invalid", message: "The run attach endpoint is invalid or has expired.")
}

/// Opens the terminal through ``AgentPaneModel/onRunAttach``. Opening a tab is the person's
/// action, so it needs their fresh gesture in the pane, used once, like `tab.open` running a
/// line (cx-hnpxh): page script cannot attach a terminal on its own.
@MainActor func attach(in model: AgentPaneModel) async -> [String: Any] {
guard let onRunAttach = model.onRunAttach else { return AgentPaneModel.unsupported("run.attach") }
guard model.transport.gestures.consume() else { return AgentPaneModel.transportFailure(.gestureRequired) }
do {
try await onRunAttach(self)
return AgentPaneReply.success()
} catch {
return AgentPaneReply.failure(code: "run_attach_failed", message: String(describing: error))
}
}
}
37 changes: 21 additions & 16 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,12 @@ 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)
}
func opensFirstPage(on daemon: DaemonService, chat: Bool = false) -> Bool { canHost(on: daemon) && (daemon.isLocal || chat) }

/// 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)
}
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)? {
Expand Down Expand Up @@ -151,10 +147,7 @@ extension AgentTabStore {
tabs.onFirstPageToken = { [weak services] token in services?.keyRouter.newTabInputCoordinator.attachIncoming(token) }
tabs.resolveLocalHost = { [weak services] in
guard let services else { return nil }
guard let id = try? services.cloud.localDeviceID() else {
services.daemon.logger.error("agent tabs: no install id, new agent tabs are refused")
return nil
}
guard let id = try? services.cloud.localDeviceID() else { services.daemon.logger.error("agent tabs: no install id, new agent tabs are refused"); return nil }
return AgentSessionRef.host(installID: id)
}
tabs.openProcess = { [weak services] chat, viewer in
Expand All @@ -169,6 +162,22 @@ extension AgentTabStore {
guard let services, let (tab, pane) = services.locateTab(key) else { return nil }
return (pane: pane.handle, daemon: services.machines.daemon(forTab: tab))
}
// A redeemed Run attempt's terminal opens only on a Cloud machine (keyed by its provider VM
// id) whose daemon this Mac already has connected, with the session the ticket names; never
// on this Mac, an SSH host or a paired server. The page's ids are not verified against the
// Run API here, so this is the boundary. The tab's own daemon stores the remote-terminal
// reference (RemoteTerminalService.openTerminal does the same).
tabs.runAttach = { [weak services] key, request in
let vm = request.providerVMID, tabsCapability = DaemonCapabilities.shared.remoteTerminalTabs
guard let services, let (_, pane) = services.locateTab(key) else { throw DaemonError.notConnected }
let home = services.machines.daemon(forPane: pane)
guard let homeConnection = home.connection, services.machines.session(vm) != nil, let worker = services.machines.daemon(machine: vm), !worker.isLocal,
worker.connection != nil, let identity = worker.identity, let sessionID = identity.sessionID, identity.session == request.session else { throw DaemonError.notConnected }
guard home.supports(tabsCapability) else { throw DaemonError.missingCapabilities([tabsCapability]) }
let name = services.machines.machineName(vm) ?? vm
let ref = RemoteTerminalRef(sessionID: sessionID, terminalID: TerminalID(rawValue: request.providerTerminalID), sessionName: name)
_ = try await homeConnection.newRemoteTerminalTab(ref, in: pane.handle, title: RemoteStrings.terminalOn(name))
}
// A tab's git reads (Changes, file search) go to the machine whose acpmux runs its session
// (AgentPaneHostKind): a Cloud or SSH machine's session reads its folder there (cx-d0tq).
tabs.gitLink = { [weak services, weak tabs] key in
Expand Down Expand Up @@ -223,9 +232,7 @@ extension AgentTabStore {
// daemon that serves the verb; never a fallback to this Mac (cx-k21v). It starts at
// the page's first send, never when the tab opens.
guard started else { return nil }
guard daemon.supports(DaemonCapabilities.shared.agentSessionStart) else {
return AgentTabRefusedHost(refusal: .startUnsupported(machine: machine))
}
guard daemon.supports(DaemonCapabilities.shared.agentSessionStart) else { return AgentTabRefusedHost(refusal: .startUnsupported(machine: machine)) }
host.start = { [weak tabs] cwd, harness, personKey in
guard let tabs else { throw CancellationError() }
return try await tabs.startRemoteChat(key, machine: machine, cwd: cwd, harness: harness, personKey: personKey, endpoint: endpoint)
Expand Down Expand Up @@ -281,9 +288,7 @@ extension AgentTabStore {
}
}
tabs.bind = { [weak services] key, surface, expected, session, harness in
guard let services, let (tab, _) = services.locateTab(key), let connection = services.machines.daemon(forTab: tab).connection else {
return (.failed, nil)
}
guard let services, let (tab, _) = services.locateTab(key), let connection = services.machines.daemon(forTab: tab).connection else { return (.failed, nil) }
let request = BindConversationTabSessionRequest(surface: surface, session: session, expectedSession: expected, harness: harness)
let outcome: AgentSessionBindOutcome, sequence: UInt64? // one return: swiftlang/swift#92698 (cx-21wc5)
do {
Expand Down
11 changes: 5 additions & 6 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ final class AgentTabStore {
var remoteMachineName: @MainActor (DaemonService) -> String? = { _ in nil }
/// Shared conversion and project actions for a direct blank chat, without a chooser page.
var blankChatHandler: ((String) -> NewTabPageHandler?)?
/// 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)?)?
Expand Down Expand Up @@ -236,9 +238,7 @@ final class AgentTabStore {

/// True when a pane of `daemon` can get a new agent chat tab: this build has the page, this
/// Mac has an install id, and the daemon holds agent session tabs.
func canHost(on daemon: DaemonService) -> Bool {
canHostChat && localHost != nil && holdsTabs(daemon)
}
func canHost(on daemon: DaemonService) -> Bool { canHostChat && localHost != nil && holdsTabs(daemon) }

/// The tab showing acpmux session `session` of `host` (default this Mac), if any.
func tab(showing session: String, host: String? = nil) -> String? {
Expand Down Expand Up @@ -381,6 +381,7 @@ final class AgentTabStore {
let key = resolve(provisional)
(newTabPages[key]?.handler ?? blankChatHandler?(key))?.open(key, request)
}
model.onRunAttach = { [weak self] request in guard let self, let runAttach else { throw AgentPaneHostError.daemonStopped }; try await runAttach(resolve(provisional), request) }
model.onTypeAhead = { [weak self] text in self.map { $0.pageHandler(provisional)?.typeAhead($0.resolve(provisional), text) } }
model.onNewTabInputReady = { [weak self] token in self.map { $0.newTabPages[$0.resolve(provisional)]?.handler.inputReady($0.resolve(provisional), token) } }
model.onRememberNewTab = { [weak self, weak model] pick in
Expand Down Expand Up @@ -492,7 +493,5 @@ final class AgentTabStore {
if next != registry.context { registry.context = next }
}

func stopCustomizationWhenUnused() {
if views.isEmpty, standaloneViews.allObjects.isEmpty { customization.stop() }
}
func stopCustomizationWhenUnused() { if views.isEmpty, standaloneViews.allObjects.isEmpty { customization.stop() } }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import Foundation
import Testing
@testable import CmuxNextAgentPane

/// `run.attach` (the Run board's Attach): a redeemed ticket reaches the App only with a live,
/// trusted-carrier `ws`/`wss` endpoint and the person's fresh gesture in the pane; anything else is
/// refused before the App looks for the worker daemon.
@Suite(.serialized)
@MainActor
struct AgentPaneRunAttachTests {
private actor NoHost: AgentPaneHostProviding {
func handshake(sessionId: String?) async throws -> AgentPaneHandshake { throw AgentPaneHostError.daemonStopped }
}

private static func params(_ overrides: [String: Any] = [:]) -> [String: Any] {
var params: [String: Any] = [
"runId": "run-1", "attemptId": "attempt-1", "provider": "cloud-vm",
"providerVmId": "vm-1", "providerWorkspaceId": "workspace-1", "providerTerminalId": "terminal-1",
"route": "wss://vm.example/v1/link", "session": "cloud", "trustedCarrier": true,
"expiresAtUnix": Date().timeIntervalSince1970 + 300,
]
for (key, value) in overrides { params[key] = value }
return params
}

private static func request(_ params: [String: Any]) -> AgentPaneRequest {
AgentPaneRequest(body: ["method": "run.attach", "params": params])
}

private static func errorCode(_ reply: [String: Any]) -> String? {
(reply["error"] as? [String: Any])?["code"] as? String
}

@Test func decodesARedeemedTicket() {
guard case .runAttach(let attach) = Self.request(Self.params()) else {
Issue.record("run.attach did not decode")
return
}
#expect(attach.providerVMID == "vm-1")
#expect(attach.providerTerminalID == "terminal-1")
#expect(attach.session == "cloud")
#expect(attach.providerWorkspaceID == "workspace-1")
}

@Test func refusesExpiredUntrustedOrNonWebSocketEndpoints() {
let refused: [[String: Any]] = [
Self.params(["expiresAtUnix": Date().timeIntervalSince1970 - 1]),
Self.params(["trustedCarrier": false]),
Self.params(["route": "https://vm.example/v1/link"]),
Self.params(["providerTerminalId": ""]),
Self.params(["session": String(repeating: "s", count: 257)]),
]
for params in refused {
#expect(Self.request(params) == .invalidRunAttach)
}
}

/// The attaches the App was asked for.
private final class Recorder {
var attached: [AgentPaneRunAttach] = []
}

@Test func attachesOnlyWithAFreshGesture() async {
let model = AgentPaneModel(host: NoHost())
let recorder = Recorder()
model.onRunAttach = { [recorder] in recorder.attached.append($0) }

let withoutGesture = await model.respond(to: Self.request(Self.params()))
#expect(Self.errorCode(withoutGesture) == AgentPaneTransportError.gestureRequired.rawValue)
#expect(recorder.attached.isEmpty)

model.transport.gestures.record(click: true)
let withGesture = await model.respond(to: Self.request(Self.params()))
#expect(withGesture["ok"] as? Bool == true)
#expect(recorder.attached.map(\.attemptID) == ["attempt-1"])

// The gesture is used once.
let again = await model.respond(to: Self.request(Self.params()))
#expect(Self.errorCode(again) == AgentPaneTransportError.gestureRequired.rawValue)
}

@Test func reportsAnInvalidTicketAndAMissingHost() async {
let model = AgentPaneModel(host: NoHost())
let invalid = await model.respond(to: Self.request(Self.params(["trustedCarrier": false])))
#expect(Self.errorCode(invalid) == "run_attach_invalid")
let unwired = await model.respond(to: Self.request(Self.params()))
#expect(Self.errorCode(unwired) == "unsupported")
}
}
Loading