diff --git a/Packages/macOS/CmuxNext/Package.swift b/Packages/macOS/CmuxNext/Package.swift index cb18886ceb19..9ea1a69ff417 100644 --- a/Packages/macOS/CmuxNext/Package.swift +++ b/Packages/macOS/CmuxNext/Package.swift @@ -66,6 +66,8 @@ import PackageDescription // tab or its front tab, and the one-time rename; pure rules; the App supplies its pages and the rename command) // CmuxNextAgentActivity -> Design (Agent activity pane: computer use sessions, timeline, prototype layouts; // a projection of the CUA host; no daemon; the App supplies the source; plans/cmux-next/computer-use.md) +// CmuxNextAgentUpdates -> Actions, Daemon, DaemonService, Pages, Settings (Settings and palette agent CLI +// updates as owner-bound terminal jobs: intents, receipts, outcomes; the App supplies the route glue) // CmuxNextFileGrid -> system frameworks only (the thumbnail grid: tiles, thumbnail cache, drag // out, drop reading, Quick Look and the command menu; it reads and changes no files) // CmuxNextScreenshots -> FileGrid, Files (the Screenshots pane: source detection for macOS and the @@ -209,6 +211,7 @@ let package = Package( "CmuxNextHomeEngine", "CmuxNextHostedChief", "CmuxNextDaemonService", + "CmuxNextAgentUpdates", "CmuxNextFocus", "CmuxNextFocusUI", "CmuxNextComputerUse", @@ -1235,12 +1238,20 @@ let package = Package( dependencies: ["CmuxNextWakeups", "CmuxNextCompat", "CmuxNextControl", "CmuxNextActions", "CmuxNextSettings", "CmuxNextDaemon"], swiftSettings: daemonSwiftSettings ), + // Settings and palette agent CLI updates (moved out of CmuxNextApp/AgentHarnesses): + // the shared registry handler, owner routes, intents and terminal outcomes. + .target( + name: "CmuxNextAgentUpdates", + dependencies: ["CmuxNextActions", "CmuxNextDaemon", "CmuxNextDaemonService", "CmuxNextPages", "CmuxNextSettings"], + swiftSettings: uiSwiftSettings + ), .testTarget( name: "CmuxNextAppTests", dependencies: [ // sorted: scripts/cmux-next/check-package-dependency-lists.py "CmuxNextActions", "CmuxNextAgentCursor", "CmuxNextAgentLanes", + "CmuxNextAgentUpdates", "CmuxNextApp", "CmuxNextApps", "CmuxNextBrowserHistory", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry+Work.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry+Work.swift index d32eeb4e02a5..a39dc63579bb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry+Work.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry+Work.swift @@ -64,6 +64,10 @@ public nonisolated enum ActionWorkRefusal: Sendable, Hashable { /// tasks the handlers reported with `track(_:)`. Keyboard, menu, and /// palette runs capture nothing, so tracking costs them nothing. extension ActionRegistry { + /// Whether a caller awaits the work handlers report (`capturingWork`). A handler whose + /// failure would otherwise go unseen (a palette run) shows it itself when this is false. + public var isCapturingWork: Bool { capturedWork != nil } + /// Reports a task a handler started. Ignored unless a caller is capturing. public func track(_ task: ActionWork) { if capturedWork != nil { capturedWork?.append(task) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/AgentHarnessActionCatalog.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/AgentHarnessActionCatalog.swift index 6565828360d6..6108fd5b570c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/AgentHarnessActionCatalog.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/AgentHarnessActionCatalog.swift @@ -10,7 +10,7 @@ nonisolated enum AgentHarnessActionCatalog: ActionCatalogGroup { ActionEnumCase(value: "acp", title: t("argument.agent.harness.protocol.acp", "ACP")), ActionEnumCase(value: "terminal", title: t("argument.agent.harness.protocol.terminal", "Terminal")), ]) - return [ + var actions = [ // Without arguments (the palette, the model picker's +) it opens Settings > Agents > // Add; with a command, registry agent or example it writes the profile. ActionDescriptor( @@ -63,17 +63,21 @@ nonisolated enum AgentHarnessActionCatalog: ActionCatalogGroup { cliName: "agent check-harness", surfacePlan: ActionSurfacePlan(cli: .offered, contextMenuExemption: .noObject) ), - // Agent CLI health (cx-ny85): types every outdated agent CLI's own update into a - // terminal tab, like Settings > Agents > Update All; with nothing to update (or no - // window) it opens Settings > Agents. + // The native owner starts visible terminal jobs. Settings passes a profile id; + // the palette updates each observed outdated family on its captured machine. ActionDescriptor( id: "agent.harness.updateAll", title: t("action.agent.harness.updateAll", "Update Agent CLIs"), keywords: ["agent", "harness", "cli", "update", "upgrade", "version", "outdated", "npm", "brew", "codex", "claude", "gemini"], category: .agents, symbol: "arrow.down.circle", + arguments: [id], // A terminal tab the person watches: scripts run the updates themselves. surfacePlan: ActionSurfacePlan(cli: .exempt(.guiOnly), contextMenuExemption: .noObject) ), ] + for index in actions.indices where actions[index].id == "agent.harness.updateAll" { + actions[index].isPersonOnly = true + } + return actions } private static func t(_ key: StaticString, _ english: String.LocalizationValue) -> String { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentUpdates/AgentCLIUpdateActions+Route.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentUpdates/AgentCLIUpdateActions+Route.swift new file mode 100644 index 000000000000..d7c709c00d8d --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentUpdates/AgentCLIUpdateActions+Route.swift @@ -0,0 +1,69 @@ +import CmuxNextActions +import CmuxNextDaemon +public import CmuxNextDaemonService + +extension AgentCLIUpdateActions { + /// The app's daemons, read when an update captures its owner. + public struct Daemons { + let active: @MainActor () -> DaemonService? + let machine: @MainActor (String) -> DaemonService? + + public init(active: @escaping @MainActor () -> DaemonService?, machine: @escaping @MainActor (String) -> DaemonService?) { + self.active = active + self.machine = machine + } + } + + /// The palette's target machine, else the active daemon, while the app still routes to it. + public convenience init(daemons: Daemons, unsupported: @escaping @MainActor () -> String, + nothingToUpdate: @escaping @MainActor () -> String, + stillUpdating: @escaping @MainActor (String) -> String, localMachine: String) { + self.init(capture: { invocation in + let registered: @MainActor (DaemonService) -> Bool = { daemons.machine($0.machineID) === $0 } + if let target = invocation.target { + guard target.kind == .machine, let daemon = daemons.machine(target.id) else { throw DaemonError.notConnected } + return try Route.capture(daemon, registered: registered) + } + guard let daemon = daemons.active() else { throw DaemonError.notConnected } + return try Route.capture(daemon, registered: registered) + }, unsupported: unsupported, nothingToUpdate: nothingToUpdate, stillUpdating: stillUpdating, localMachine: localMachine) + } +} + +extension AgentCLIUpdateActions.Route { + /// Captures one daemon's connected machine/session owner and connection epoch. + /// `registered` says whether the app still routes that machine to this daemon. + public static func capture(_ daemon: DaemonService, registered: @escaping @MainActor (DaemonService) -> Bool) throws -> Self { + guard registered(daemon), !daemon.policyBlock.isBlocked, case .connected = daemon.store.connectionState, + let connection = daemon.connection, let identity = daemon.identity, + let machine = identity.machineResourceID, let session = identity.sessionResourceID else { + throw DaemonError.notConnected + } + let epoch = daemon.store.connectionEpoch + let current: @MainActor () -> Bool = { [weak daemon] in + guard let daemon, registered(daemon), !daemon.policyBlock.isBlocked, case .connected = daemon.store.connectionState else { + return false + } + return daemon.connection === connection && daemon.store.connectionEpoch == epoch + && daemon.identity?.machineResourceID == machine && daemon.identity?.sessionResourceID == session + } + var carrier: AgentCLIJobCarrier? + // The owner-checked carrier, opened by whichever call comes first: a route re-captured + // after a reconnect starts (a replayed key) or observes without reading the inventory. + let opened: @MainActor () async throws -> AgentCLIJobCarrier = { + guard current() else { throw DaemonError.notConnected } + if let carrier { return carrier } + let captured = try await connection.agentCLIJobs(expectedMachineID: machine, expectedSessionID: session) + guard current() else { throw DaemonError.notConnected } + carrier = captured + return captured + } + return Self(machine: machine, session: session, isCurrent: current, inventory: { + try await opened().inventory() + }, start: { cli, key in + try await opened().startUpdate(cli: cli, idempotencyKey: key) + }, observe: { receipt in + try await opened().observe(receipt) + }) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentUpdates/AgentCLIUpdateActions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentUpdates/AgentCLIUpdateActions.swift new file mode 100644 index 000000000000..d0c4ce3e07b9 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentUpdates/AgentCLIUpdateActions.swift @@ -0,0 +1,347 @@ +public import CmuxNextActions +public import CmuxNextDaemon +import CmuxNextDaemonService +public import CmuxNextPages +public import CmuxNextSettings +import CryptoKit +public import Foundation +import Observation + +/// The native UI adapter for Settings and palette updates. The owner chooses +/// commands, journals creation and reports terminal outcomes. +@MainActor +@Observable +public final class AgentCLIUpdateActions { + public typealias JSONValue = CmuxNextSettings.JSONValue + /// One captured machine/session owner; every call rechecks `isCurrent`. + public struct Route { + let machine: ResourceID + let session: ResourceID + let isCurrent: @MainActor () -> Bool + let inventory: @MainActor () async throws -> AcpmuxHarnessInventory + let start: @MainActor (AgentCLIFamily, String) async throws -> AgentCLIJobReceipt + let observe: @MainActor (AgentCLIJobReceipt) async throws -> AgentCLIJobStatus + + public init(machine: ResourceID, session: ResourceID, isCurrent: @escaping @MainActor () -> Bool, + inventory: @escaping @MainActor () async throws -> AcpmuxHarnessInventory, + start: @escaping @MainActor (AgentCLIFamily, String) async throws -> AgentCLIJobReceipt, + observe: @escaping @MainActor (AgentCLIJobReceipt) async throws -> AgentCLIJobStatus) { + self.machine = machine + self.session = session + self.isCurrent = isCurrent + self.inventory = inventory + self.start = start + self.observe = observe + } + } + + private let capture: @MainActor (ActionInvocation) throws -> Route + /// The app's localized refusal; the module holds no app string table. + let unsupported: @MainActor () -> String + /// The app's refusal when no listed CLI is one the owner can update. + let nothingToUpdate: @MainActor () -> String + /// The app's refusal while an earlier update of the same CLI has not ended. + let stillUpdating: @MainActor (String) -> String + /// The target Settings updates name (the app's local machine id). + let localMachine: String + private var intents: [String: Intent] = [:] + + @Observable final class Intent { + /// The captured owner; a reconnect to the same machine and session replaces it. + @ObservationIgnored var route: Route + let invocation: ActionInvocation + @ObservationIgnored var work: Task? + var jobs: [JSONValue] = [] + var receipts: [AgentCLIJobReceipt] = [] + /// Child keys of starts whose reply was lost, by family; refresh replays them. + @ObservationIgnored var unsent: [String: String] = [:] + + var profile: String? { invocation["id"]?.stringValue } + + init(route: Route, invocation: ActionInvocation) { + self.route = route + self.invocation = invocation + } + } + + public init(capture: @escaping @MainActor (ActionInvocation) throws -> Route, + unsupported: (@MainActor () -> String)? = nil, + nothingToUpdate: (@MainActor () -> String)? = nil, + stillUpdating: (@MainActor (String) -> String)? = nil, + localMachine: String = "local") { + self.capture = capture + let unsupported = unsupported ?? { RefusalStrings.needsAppCapability("agent-cli-update") } + self.unsupported = unsupported + self.nothingToUpdate = nothingToUpdate ?? unsupported + self.stillUpdating = stillUpdating ?? { "\($0): \(unsupported())" } + self.localMachine = localMachine + } + + public func bind(into registry: ActionRegistry) { + let run: @MainActor (ActionInvocation) throws -> Void = { [self, weak registry] invocation in + guard invocation.origin == .user, invocation.callerApp == nil, + ActionRunScope.current?.origin == .user else { + throw ActionFailure(message: RefusalStrings.personOnlyFromPage) + } + let key = DaemonCommandScope.current?.idempotencyKey ?? UUID().uuidString.lowercased() + let intent: Intent + if let existing = intents[key] { + guard existing.profile == invocation["id"]?.stringValue, existing.invocation.target == invocation.target else { + throw ActionFailure(message: unsupported()) + } + intent = existing + } else { + intent = Intent(route: try capture(invocation), invocation: invocation) + intents[key] = intent + } + if intent.work == nil { + // task-owner: one explicit UI intent, retained for same-key replies; + // observation and page cancellation never cancel the owner's process. + intent.work = Task { try await self.start(intent, key: key) } + } + guard let work = intent.work, let registry else { return } + guard registry.isCapturingWork else { + // task-owner: a run no caller waits for (the palette) shows its failure + // once the owner answers; it never cancels the update itself. + Task { [weak registry] in + do { _ = try await work.value } catch { registry?.refuse(Self.text(error)) } + } + return + } + registry.track(Task { + do { _ = try await work.value; return nil } + catch { return ActionWorkFailure(Self.text(error), mayHaveApplied: !intent.jobs.isEmpty, + terminalMayAppear: !intent.jobs.isEmpty) } + }) + } + registry.bind("agent.harness.updateAll", invoke: { [weak registry] invocation in + do { try run(invocation) } catch { registry?.refuse(RefusalStrings.describe(error)) } + }) + } + + /// Settings' Update (`id`) and Update All gestures (`cmux.settings.harnesses.run`). + public func runPage(_ params: JSONValue, context: PageCallContext, registry: ActionRegistry) async throws -> JSONValue { + try await runSettings(id: params["action"]?.stringValue == "update" ? params["id"]?.stringValue : nil, + context: context, registry: registry) + } + + /// Settings uses the same registry handler, with the host's context and + /// operation id kept outside page-controlled arguments. + func runSettings(id: String?, context: PageCallContext, registry: ActionRegistry) async throws -> JSONValue { + guard context.userGesture || context.isConfirmedUser else { + throw PageError(code: PageNativeOp.userOnlyCode, message: RefusalStrings.personOnlyFromPage) + } + let key = context.opid ?? UUID().uuidString.lowercased() + let scope = DaemonCommandScope(idempotencyKey: key) + let invocation = ActionInvocation(target: .init(kind: .machine, id: localMachine), + arguments: id.map { ["id": .string($0)] } ?? [:], origin: .user) + let work = DaemonCommandScope.$current.withValueInTask(scope) { + registry.capturingWork { registry.perform("agent.harness.updateAll", invocation: invocation) } + } + guard !work.isEmpty else { throw PageError.unavailable(unsupported()) } + for task in work { + if let failure = await task.value { throw PageError.unavailable(failure.message) } + } + return .array(intents[key]?.jobs ?? []) + } + + /// Explicit refresh observes retained terminals, never starts a new update. A start + /// whose reply was lost is sent again with its key; the owner replays it by that key. + public func refresh() async { + for intent in intents.values { + for (family, childKey) in intent.unsent.sorted(by: { $0.key < $1.key }) { + guard let route = owner(of: intent), let cli = AgentCLIFamily(rawValue: family) else { break } + let result: Result + do { result = .success(try await route.start(cli, childKey)) } catch { result = .failure(error) } + guard intent.unsent[family] == childKey, let index = Self.job(of: family, in: intent) else { continue } + switch result { + case .success(let receipt): + guard route.isCurrent() else { continue } + intent.unsent[family] = nil + guard receipt.machine == route.machine, receipt.session == route.session, receipt.cli == cli else { + intent.jobs.remove(at: index) + continue + } + intent.receipts.append(receipt) + if var job = intent.jobs[index].objectValue { + job["terminal"] = .string(receipt.path.terminalID.rawValue) + job["state"] = "created" + intent.jobs[index] = .object(job) + } + case .failure(let error): + // Only a refusal (`agent_cli_job.*`, answered before a creation is journaled) proves + // the replayed key created nothing. Busy can mean the key's original request still + // runs, and another code can come before the owner reads its journal (a handoff, + // a registry error) while the lost first start created the terminal: keep the job + // and replay the same key again next refresh. + guard Self.refusedBeforeCreation(error), !Self.busy(error) else { continue } + intent.unsent[family] = nil + intent.jobs.remove(at: index) + } + } + for receipt in intent.receipts { + guard let route = owner(of: intent) else { break } + do { + let status = try await route.observe(receipt) + guard route.isCurrent(), status.terminalID == receipt.path.terminalID, + let index = intent.jobs.firstIndex(where: { $0["terminal"]?.stringValue == status.terminalID.rawValue }), + var job = intent.jobs[index].objectValue else { continue } + // An end observation settles the job even when it has no process result. + job["state"] = .string(status.state.rawValue) + switch status.outcome { + case .exit(let code): job["exit_code"] = .number(Double(code)) + case .signal(let number): job["signal"] = .number(Double(number)) + case .unknown: job["outcome"] = "unknown" + case nil: break + } + intent.jobs[index] = .object(job) + } catch { + // Keep the receipt; a failed observation is no completion. + } + } + } + } + + public var state: JSONValue { + .array(intents.keys.sorted().flatMap { key in + guard let intent = intents[key], owner(of: intent) != nil else { return [JSONValue]() } + return intent.jobs + }) + } + + /// The intent's owner while the app still routes to it: the captured route, or after a + /// reconnect to the same machine and session a fresh capture, so its receipts stay + /// observable. Nil once that owner is gone; its jobs then neither show nor block. + /// Showing, refreshing and blocking all use this one rule. + private func owner(of intent: Intent) -> Route? { + if intent.route.isCurrent() { return intent.route } + guard let route = try? capture(intent.invocation), route.isCurrent(), + route.machine == intent.route.machine, route.session == intent.route.session else { return nil } + intent.route = route + return route + } + + private func start(_ intent: Intent, key: String) async throws -> JSONValue { + // The owner this intent captured. Showing or refreshing may swap `intent.route` for a + // re-capture after a reconnect, but a start never continues on another connection. + let route = intent.route + guard route.isCurrent() else { throw DaemonError.notConnected } + let inventory = try await route.inventory() + guard route.isCurrent() else { throw DaemonError.notConnected } + let rows = inventory.harnesses.filter { intent.profile == nil ? $0.outdated : $0.id == intent.profile } + let families = Set(rows.compactMap { row -> String? in + guard let family = row.family, family != .unknown, + Self.ownerCanRun(family, in: inventory.harnesses) else { return nil } + return family.rawValue + }).sorted() + guard !families.isEmpty, intent.profile == nil || families.count == 1 else { + throw ActionFailure(message: families.isEmpty ? nothingToUpdate() : unsupported()) + } + // An earlier job blocks its CLI only until it ends. Nothing else polls (Settings > Agents + // refreshes only while open), so learn whether a blocking job has ended before refusing. + if families.contains(where: { blocked($0, on: route.machine, besides: intent) }) { + await refresh() + } + // One family's refusal never stops the others; the batch reports every failure. + var failures: [String] = [] + for family in families { + guard route.isCurrent(), let cli = AgentCLIFamily(rawValue: family) else { throw DaemonError.notConnected } + guard !blocked(family, on: route.machine, besides: intent) else { + failures.append(stillUpdating(family)) + continue + } + // Batch children derive from the original intent and family, not row order. + let childKey = intent.profile != nil ? key + : SHA256.hash(data: Data("cmux/agent-update/\(family)/\(key)".utf8)).map { String(format: "%02x", $0) }.joined() + var job: [String: JSONValue] = ["machine": .string(route.machine.rawValue), + "session": .string(route.session.rawValue), "cli": .string(family), "state": "unknown", + "profiles": .array(rows.filter { $0.family?.rawValue == family }.map { .string($0.id) })] + intent.jobs.append(.object(job)) + let receipt: AgentCLIJobReceipt + do { + receipt = try await route.start(cli, childKey) + } catch { + // This key is fresh, so an owner answer (a refusal, a failed creation, a request + // turned away) leaves no job to block this CLI, except `mutation.indeterminate`: + // the effect may have run, so like a lost reply the job stays unknown, keeps + // blocking the CLI and refresh sends the same key again. + if Self.answered(error) { + if let index = Self.job(of: family, in: intent) { intent.jobs.remove(at: index) } + } else { + intent.unsent[family] = childKey + } + failures.append("\(family): \(Self.text(error))") + guard route.isCurrent() else { throw error } + continue + } + guard let index = Self.job(of: family, in: intent) else { throw DaemonError.notConnected } + guard receipt.machine == route.machine, receipt.session == route.session, receipt.cli == cli else { + // Another owner's terminal is not this update's, and this route cannot observe it. + intent.jobs.remove(at: index) + throw DaemonError.malformedResponse("Agent update receipt owner mismatch") + } + intent.receipts.append(receipt) + job["terminal"] = .string(receipt.path.terminalID.rawValue) + job["state"] = "created" + intent.jobs[index] = .object(job) + guard route.isCurrent() else { throw DaemonError.notConnected } + } + guard failures.isEmpty else { throw ActionFailure(message: failures.joined(separator: "\n")) } + return .array(intent.jobs) + } + + /// Whether another intent's job for this CLI on this machine has not ended while its owner is still routed. + private func blocked(_ family: String, on machine: ResourceID, besides intent: Intent) -> Bool { + intents.values.contains { other in + other !== intent && other.jobs.contains { job in + job["machine"]?.stringValue == machine.rawValue && job["cli"]?.stringValue == family + && ["unknown", "created", "pending"].contains(job["state"]?.stringValue ?? "") + } && owner(of: other) != nil + } + } + + /// The intent's job for one family that has no terminal yet; an intent runs each family once. + private static func job(of family: String, in intent: Intent) -> Int? { + intent.jobs.lastIndex { $0["cli"]?.stringValue == family && $0["terminal"] == nil } + } + + /// The rows of a `_acpmux/harness/health` answer by harness id; nil when it is not one. + public nonisolated static func healthRows(_ data: Data) -> [String: JSONValue]? { + guard let rows = (try? JSONValue.parse(data))?["harnesses"]?.arrayValue else { return nil } + return Dictionary(rows.compactMap { row in row["id"]?.stringValue.map { ($0, row) } }, uniquingKeysWith: { first, _ in first }) + } + + static func text(_ error: any Error) -> String { + (error as? ActionFailure)?.message ?? (error as? DaemonError)?.description ?? error.localizedDescription + } + + /// The owner prepares only npm and native installations (acpmux `fixed_argv`), + /// and refuses a family when any of its rows is another channel or current. + static func ownerCanRun(_ family: AgentCLIFamily, in rows: [AcpmuxHarnessInventory.Harness]) -> Bool { + let members = rows.filter { $0.family == family } + return !members.isEmpty && members.allSatisfy { $0.outdated && ($0.channel == .npm || $0.channel == .native) } + } + + /// Whether the owner answered a first start (a fresh key) with a coded failure that leaves + /// nothing worth tracking. `mutation.indeterminate` does not: the owner says the effect may + /// have run (a failed creation's reserved terminal may be live). Nor does a failure with no + /// owner answer (a lost reply). Rarely another code follows a terminal that went public + /// (a failed launch activation); its job is dropped. A replay needs `refusedBeforeCreation`. + static func answered(_ error: any Error) -> Bool { + guard case DaemonError.command(_, _, let code?, _, _) = error else { return false } + return !code.isEmpty && code != "mutation.indeterminate" + } + + /// The owner answers `agent_cli_job.*` before it journals a creation, so no + /// terminal exists for the key. Any other failure may follow a creation. + static func refusedBeforeCreation(_ error: any Error) -> Bool { + guard case DaemonError.command(_, _, let code?, _, _) = error else { return false } + return code.hasPrefix("agent_cli_job.") + } + + /// The owner's execution lock or inventory reservation is held. + static func busy(_ error: any Error) -> Bool { + guard case DaemonError.command(_, _, let code?, _, _) = error else { return false } + return code == "agent_cli_job.busy" + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentHarnesses/AgentCLIUpdateActions+App.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentHarnesses/AgentCLIUpdateActions+App.swift new file mode 100644 index 000000000000..226157df87d5 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentHarnesses/AgentCLIUpdateActions+App.swift @@ -0,0 +1,11 @@ +import CmuxNextAgentUpdates + +extension AgentCLIUpdateActions { + /// The module captures the owner route; the app supplies its daemons and strings. + convenience init(services: AppServices) { + self.init(daemons: .init(active: { [weak services] in services?.activeDaemon }, + machine: { [weak services] in services?.machines.daemon(machine: $0) }), + unsupported: { AgentHarnessStrings.unsupported }, nothingToUpdate: { AgentHarnessStrings.nothingToUpdate }, + stillUpdating: AgentHarnessStrings.stillUpdating, localMachine: MachineRegistry.localID) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentHarnesses/AgentCLIUpdates.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentHarnesses/AgentCLIUpdates.swift deleted file mode 100644 index fefaa86952bc..000000000000 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentHarnesses/AgentCLIUpdates.swift +++ /dev/null @@ -1,39 +0,0 @@ -import CmuxNextSettings -import Foundation - -/// One-click agent CLI updates (cx-ny85): the lines Settings > Agents (Update, Update All) and the -/// palette's Update Agent CLIs type into a terminal tab, from the daemon's `_acpmux/harness/health` -/// rows. Each word is single-quoted; an update that needs sudo is never typed (Settings shows its -/// command for the person to run). -nonisolated enum AgentCLIUpdates { - /// Programs that raise rights; never typed, whatever the daemon says. - private static let elevating: Set = ["sudo", "doas", "su", "pkexec"] - - /// The rows of a `_acpmux/harness/health` answer by harness id; nil when it is not one. - static func rows(_ data: Data) -> [String: JSONValue]? { - guard let rows = (try? JSONValue.parse(data))?["harnesses"]?.arrayValue else { return nil } - return Dictionary(rows.compactMap { row in row["id"]?.stringValue.map { ($0, row) } }, uniquingKeysWith: { first, _ in first }) - } - - /// The line that updates one health row: nil when it is not outdated, needs sudo (or does not - /// say), runs a program that raises rights, or holds a character no shell quotes alike. - static func line(_ row: JSONValue) -> String? { - guard row["outdated"]?.boolValue == true, let plan = row["update"], plan["elevated"]?.boolValue == false, - let argv = plan["argv"]?.arrayValue?.compactMap(\.stringValue), let program = argv.first, - !elevating.contains((program as NSString).lastPathComponent) else { return nil } - let unsafe: (Character) -> Bool = { character in - character == "'" || character == "\\" || character.isNewline - || character.unicodeScalars.contains { $0.properties.generalCategory == .control } - } - guard !argv.contains(where: { $0.contains(where: unsafe) }) else { return nil } - return argv.map { "'" + $0 + "'" }.joined(separator: " ") - } - - /// Update All: every row's line once, in order, each running whether the one before failed; - /// nil when none can be typed. - static func allLine(_ rows: [JSONValue]) -> String? { - var lines: [String] = [] - for row in rows { if let line = line(row), !lines.contains(line) { lines.append(line) } } - return lines.isEmpty ? nil : lines.joined(separator: "; ") - } -} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentHarnesses/AgentHarnessCenter.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentHarnesses/AgentHarnessCenter.swift index f81bb5da09c9..faaa97f638ce 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentHarnesses/AgentHarnessCenter.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentHarnesses/AgentHarnessCenter.swift @@ -1,4 +1,5 @@ import CmuxNextAgentPane +import CmuxNextAgentUpdates import CmuxNextSettings import Foundation import Observation @@ -12,6 +13,7 @@ import Observation @MainActor @Observable final class AgentHarnessCenter { + private(set) var updates: AgentCLIUpdateActions? /// What Settings draws (`cmux.settings.agents.state`). private(set) var pageState: JSONValue = ["status": "loading", "harnesses": .array([])] @@ -34,6 +36,7 @@ final class AgentHarnessCenter { convenience init(services: AppServices) { self.init(environment: { [weak services] in services.flatMap(QuitAgents.environment) }) + updates = AgentCLIUpdateActions(services: services) } /// The backup of the last removal this app made, for Undo and `agent.harness.restore`. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentHarnesses/AgentHarnessHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentHarnesses/AgentHarnessHandlers.swift index f2bc57a238ba..1674415fca8d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentHarnesses/AgentHarnessHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentHarnesses/AgentHarnessHandlers.swift @@ -42,7 +42,7 @@ enum AgentHarnessHandlers { } track(registry) { try await center.restore(backup: backup) } }) - registry.bind("agent.harness.updateAll", run: { _ in track(registry) { try await updateAll(context, center) } }) + center.updates?.bind(into: registry) registry.bind("agent.harness.doctor", run: { invocation in guard let id = nonEmpty(invocation.arguments["id"]?.stringValue) else { guard invocation.origin == .user else { throw ActionFailure(message: AgentHarnessStrings.needsAgent) } @@ -62,24 +62,6 @@ enum AgentHarnessHandlers { }) } - /// Update Agent CLIs: the listed harnesses' health rows by id (as Settings > Agents lists - /// them), every outdated one's update typed into a new terminal tab of the focused pane - /// (`AgentCLIUpdates`, shared with Settings > Agents > Update All). Nothing to update, no - /// window, or no answer (no daemon, an acpmux without health) opens Settings > Agents, which - /// says which. - private static func updateAll(_ context: AppActionContext, _ center: AgentHarnessCenter) async throws -> JSONValue { - let listed = Set((try? await center.listed()) ?? []) - let rows = ((try? await center.health()) ?? []) - .filter { listed.contains($0["id"]?.stringValue ?? "") } - .sorted { ($0["id"]?.stringValue ?? "") < ($1["id"]?.stringValue ?? "") } - guard let line = AgentCLIUpdates.allLine(rows), let pane = context.services.windows.active?.focusedPane else { - try openSettings(context, panel: nil) - return .null - } - pane.newTerminalTab(typing: line + "\r") - return .null - } - /// Settings > Agents, with its Add panel (`add`) or registry list (`registry`) open. static func openSettings(_ context: AppActionContext, panel: String?) throws { let route = "#/settings/agents" + (panel.map { "?focus=agents.\($0)" } ?? "") @@ -116,6 +98,14 @@ nonisolated enum AgentHarnessStrings { } static var nothingRemoved: String { text("handlers.agent.harness.nothingRemoved", "No removed agent to restore. Name its backup file with --arg backup=.") } + static var nothingToUpdate: String { + text("handlers.agent.harness.nothingToUpdate", "No agent CLI here has an update the app can run. Settings > Agents shows each one's update command.") + } + + static func stillUpdating(_ cli: String) -> String { + String(format: text("handlers.agent.harness.stillUpdating", "%@ is still updating. Wait for that update to finish, then try again."), cli) + } + static func doctorFailed(_ id: String, step: String, detail: String) -> String { String(format: text("handlers.agent.harness.doctorFailed", "%@ failed its check at %@: %@"), id, step, detail) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/PageFactory.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/PageFactory.swift index ed38a7cbba77..786b4d018065 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/PageFactory.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/PageFactory.swift @@ -124,7 +124,13 @@ struct PageFactory { openTerminal: { [weak services] line in guard let pane = services?.windows.active?.focusedPane else { return } pane.newTerminalTab(typing: line + "\r") - } + }, + runUpdate: { [weak services] params, context in + guard let services, let updates = services.agentHarnesses.updates else { throw PageError.closed } + return try await updates.runPage(params, context: context, registry: services.registry) + }, + updateState: { [weak services] in services?.agentHarnesses.updates?.state ?? .array([]) }, + refreshUpdates: { [weak services] in await services?.agentHarnesses.updates?.refresh() } ) provider.harnessesState = { harnesses.state } provider.harnessesRun = { params, context in try await harnesses.run(params, context: context) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/MiscHandlers.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/MiscHandlers.xcstrings index 35533dd9d0d6..e10d6884b8c7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/MiscHandlers.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/MiscHandlers.xcstrings @@ -7922,6 +7922,270 @@ } } }, + "handlers.agent.harness.nothingToUpdate": { + "comment": "Agent CLI update refusal when no listed CLI can be updated by the app; Settings > Agents is the settings page", + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "لا توجد واجهة سطر أوامر لوكيل هنا لها تحديث يمكن للتطبيق تشغيله. تعرض الإعدادات > الوكلاء أمر التحديث لكل منها." + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Nijedan CLI agenta ovdje nema ažuriranje koje aplikacija može pokrenuti. Postavke > Agenti prikazuju naredbu za ažuriranje svakog." + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Ingen agent-CLI her har en opdatering, appen kan køre. Indstillinger > Agenter viser hver enkelts opdateringskommando." + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Keine Agent-CLI hier hat ein Update, das die App ausführen kann. Einstellungen > Agenten zeigt den Update-Befehl jeder CLI." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No agent CLI here has an update the app can run. Settings > Agents shows each one's update command." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Ninguna CLI de agente aquí tiene una actualización que la app pueda ejecutar. Ajustes > Agentes muestra el comando de actualización de cada una." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Aucune CLI d'agent ici n'a de mise à jour que l'app peut lancer. Réglages > Agents affiche la commande de mise à jour de chacune." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Nessuna CLI di agente qui ha un aggiornamento che l'app può eseguire. Impostazioni > Agenti mostra il comando di aggiornamento di ciascuna." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリで実行できる更新があるエージェント CLI はここにはありません。各 CLI の更新コマンドは「設定 > エージェント」に表示されます。" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "គ្មាន CLI ភ្នាក់ងារនៅទីនេះដែលមានការធ្វើបច្ចុប្បន្នភាពដែលកម្មវិធីអាចដំណើរការបានទេ។ ការកំណត់ > ភ្នាក់ងារ បង្ហាញពាក្យបញ្ជាធ្វើបច្ចុប្បន្នភាពនៃនីមួយៗ។" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "앱이 실행할 수 있는 업데이트가 있는 에이전트 CLI가 여기에 없습니다. 설정 > 에이전트에서 각 CLI의 업데이트 명령을 볼 수 있습니다." + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Ingen agent-CLI her har en oppdatering appen kan kjøre. Innstillinger > Agenter viser oppdateringskommandoen for hver." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Żadne CLI agenta tutaj nie ma aktualizacji, którą aplikacja może uruchomić. Ustawienia > Agenci pokazują polecenie aktualizacji każdego z nich." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Nenhuma CLI de agente aqui tem uma atualização que o app possa executar. Ajustes > Agentes mostra o comando de atualização de cada uma." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Здесь нет CLI агента с обновлением, которое может запустить приложение. В разделе «Настройки > Агенты» показана команда обновления для каждого." + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ไม่มี CLI ของเอเจนต์ที่นี่ที่มีอัปเดตซึ่งแอปเรียกใช้ได้ การตั้งค่า > เอเจนต์ แสดงคำสั่งอัปเดตของแต่ละตัว" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Burada uygulamanın çalıştırabileceği bir güncellemesi olan aracı CLI'si yok. Ayarlar > Aracılar her birinin güncelleme komutunu gösterir." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Тут немає CLI агента з оновленням, яке може запустити застосунок. У «Параметри > Агенти» показано команду оновлення для кожного." + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Không có CLI tác tử nào ở đây có bản cập nhật mà ứng dụng có thể chạy. Cài đặt > Tác tử hiển thị lệnh cập nhật của từng cái." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "这里没有可由应用运行更新的智能体 CLI。“设置 > 智能体”会显示每个 CLI 的更新命令。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "這裡沒有可由 App 執行更新的代理 CLI。「設定 > 代理」會顯示每個 CLI 的更新指令。" + } + } + } + }, + "handlers.agent.harness.stillUpdating": { + "comment": "Agent CLI update refusal while an earlier update of the same CLI runs; %@ is the CLI name (claude, codex)", + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "لا يزال %@ قيد التحديث. انتظر حتى ينتهي ذلك التحديث، ثم حاول مرة أخرى." + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "%@ se još ažurira. Sačekaj da to ažuriranje završi, pa pokušaj ponovo." + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "%@ opdateres stadig. Vent, til opdateringen er færdig, og prøv igen." + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "%@ wird noch aktualisiert. Warte, bis dieses Update fertig ist, und versuche es dann erneut." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is still updating. Wait for that update to finish, then try again." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "%@ todavía se está actualizando. Espera a que termine esa actualización y vuelve a intentarlo." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "%@ est toujours en cours de mise à jour. Attendez la fin de cette mise à jour, puis réessayez." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "%@ si sta ancora aggiornando. Attendi che l'aggiornamento finisca, poi riprova." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ はまだ更新中です。その更新が終わるまで待ってから、もう一度お試しください。" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "%@ នៅតែកំពុងធ្វើបច្ចុប្បន្នភាព។ រង់ចាំការធ្វើបច្ចុប្បន្នភាពនោះបញ្ចប់ រួចព្យាយាមម្ដងទៀត។" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "%@이(가) 아직 업데이트 중입니다. 업데이트가 끝날 때까지 기다린 후 다시 시도하세요." + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "%@ oppdateres fortsatt. Vent til oppdateringen er ferdig, og prøv igjen." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "%@ nadal się aktualizuje. Poczekaj na zakończenie tej aktualizacji i spróbuj ponownie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ainda está sendo atualizado. Aguarde essa atualização terminar e tente novamente." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ещё обновляется. Дождитесь завершения этого обновления и повторите попытку." + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ยังอัปเดตอยู่ รอให้การอัปเดตนั้นเสร็จ แล้วลองอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "%@ hâlâ güncelleniyor. Bu güncellemenin bitmesini bekleyip yeniden deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "%@ ще оновлюється. Дочекайтеся завершення цього оновлення й спробуйте ще раз." + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "%@ vẫn đang cập nhật. Hãy đợi bản cập nhật đó hoàn tất rồi thử lại." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "%@ 仍在更新。请等待该更新完成后再试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "%@ 仍在更新。請等待該更新完成後再試一次。" + } + } + } + }, "handlers.agent.harness.unsupported": { "comment": "Agent harness action refusals; keep commands and --arg text exactly", "extractionState": "manual", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Settings/SettingsHarnesses.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Settings/SettingsHarnesses.swift index c1e3f762662d..2a02a98fb378 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Settings/SettingsHarnesses.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Settings/SettingsHarnesses.swift @@ -1,4 +1,5 @@ import CmuxNextAgentPane +import CmuxNextAgentUpdates import CmuxNextDaemonService import CmuxNextPages import CmuxNextSettings @@ -14,9 +15,9 @@ import Observation /// person can see and answer it. /// /// Health (cx-ny85, `_acpmux/harness/health`) adds each row's installed and newest version, a -/// sign-out the daemon has evidence for, and its update command. Update and Update all type that -/// command into a terminal tab the same way; an update that needs sudo is never typed (the page -/// shows the command). Refresh asks the daemon to check again, so a finished update shows. +/// sign-out the daemon has evidence for, and its update command for display. Update and Update +/// all use the shared owner-bound job action; no health argv is typed into a focused pane. +/// Refresh observes retained job terminals and reads health again. /// Update Automatically asks acpmux to run a harness's update itself after a check finds it /// outdated (`_acpmux/harness/health {autoUpdate}`; never one that needs sudo). @MainActor @@ -32,16 +33,25 @@ final class SettingsHarnesses { @ObservationIgnored private let fetch: @Sendable (AcpmuxEnvironment) async throws -> [AcpmuxHarnessRow] @ObservationIgnored private let fetchHealth: @Sendable (AcpmuxEnvironment, Bool) async throws -> Data @ObservationIgnored private let openTerminal: @MainActor (String) -> Void + @ObservationIgnored private let runUpdate: (@MainActor (JSONValue, PageCallContext) async throws -> JSONValue)? + @ObservationIgnored private let updateState: @MainActor () -> JSONValue + @ObservationIgnored private let refreshUpdates: (@MainActor () async -> Void)? @ObservationIgnored private var generation = 0 init(environment: @escaping @MainActor () -> AcpmuxEnvironment?, fetch: @escaping @Sendable (AcpmuxEnvironment) async throws -> [AcpmuxHarnessRow] = { try await $0.harnessRows() }, fetchHealth: @escaping @Sendable (AcpmuxEnvironment, Bool) async throws -> Data = AcpmuxEnvironment.harnessHealth, - openTerminal: @escaping @MainActor (String) -> Void) { + openTerminal: @escaping @MainActor (String) -> Void, + runUpdate: (@MainActor (JSONValue, PageCallContext) async throws -> JSONValue)? = nil, + updateState: @escaping @MainActor () -> JSONValue = { .array([]) }, + refreshUpdates: (@MainActor () async -> Void)? = nil) { self.environment = environment self.fetch = fetch self.fetchHealth = fetchHealth self.openTerminal = openTerminal + self.runUpdate = runUpdate + self.updateState = updateState + self.refreshUpdates = refreshUpdates } /// The page's `cmux.settings.harnesses.state` (webviews/src/pages/settings/ops.ts `HarnessesState`). @@ -49,6 +59,7 @@ final class SettingsHarnesses { [ "loading": .bool(loading), "problem": problem.map(JSONValue.string) ?? .null, + "jobs": updateState(), "harnesses": .array(rows.map { row -> JSONValue in let health = health[row.id] let update = health?["update"].flatMap { plan -> JSONValue? in @@ -63,6 +74,8 @@ final class SettingsHarnesses { "outdated": .bool(health?["outdated"]?.boolValue ?? false), "auth": .string(health?["auth"]?.stringValue ?? "unknown"), "update": update ?? .null, + // The update owner runs only npm and native installations; others show no Update. + "ownerUpdate": .bool(["npm", "native"].contains(health?["channel"]?.stringValue ?? "")), "autoUpdate": .bool(health?["autoUpdate"]?.boolValue ?? false), "lastAutoUpdate": health?["lastAutoUpdate"].flatMap { last -> JSONValue? in guard let ok = last["ok"]?.boolValue else { return nil } @@ -76,6 +89,7 @@ final class SettingsHarnesses { /// Reads the daemon's harnesses again, and their health (`recheck`: the daemon checks again /// instead of answering its last check). Only the newest read writes the rows. func refresh(recheck: Bool = false) async { + await refreshUpdates?() guard let environment = environment() else { rows = [] problem = "unavailable" @@ -97,7 +111,7 @@ final class SettingsHarnesses { // The rows show first: a check can take a while. A failed one (an older daemon without // the method, a timeout) keeps the health already shown. guard let checked = try? await fetchHealth(environment, recheck), - mine == generation, let read = AgentCLIUpdates.rows(checked) else { return } + mine == generation, let read = AgentCLIUpdateActions.healthRows(checked) else { return } health = read } @@ -118,9 +132,10 @@ final class SettingsHarnesses { switch action { case "refresh": await refresh(recheck: params["recheck"]?.boolValue ?? false) - case "update": - guard let id = params["id"]?.stringValue, rows.contains(where: { $0.id == id }) else { throw PageError.invalidParams("id must be a listed harness") } - try type(health[id].flatMap(AgentCLIUpdates.line)) + case "update", "updateAll": + guard action != "update" || rows.contains(where: { $0.id == params["id"]?.stringValue }) else { throw PageError.invalidParams("id must be a listed harness") } + guard let runUpdate, let context else { throw PageError.unavailable(AgentHarnessStrings.unsupported) } + return try await runUpdate(params, context) case "autoUpdate": // acpmux keeps the choice and runs the update after a check finds it outdated. guard let id = params["id"]?.stringValue, rows.contains(where: { $0.id == id }), let on = params["on"]?.boolValue else { @@ -128,10 +143,7 @@ final class SettingsHarnesses { } guard let environment = environment() else { throw PageError(code: "cmux.page.unavailable", message: "no acpmux") } let answer = try await environment.harness(.health, params: ["autoUpdate": ["id": id, "on": on] as [String: any Sendable]]) - if let read = AgentCLIUpdates.rows(answer) { health = read } - case "updateAll": - guard let line = AgentCLIUpdates.allLine(rows.compactMap { health[$0.id] }) else { throw PageError.invalidParams("no harness can be updated here") } - openTerminal(line) + if let read = AgentCLIUpdateActions.healthRows(answer) { health = read } case "signIn", "check": guard let id = params["id"]?.stringValue, rows.contains(where: { $0.id == id && $0.kind != "terminal" }) else { throw PageError.invalidParams("id must be a listed harness") } guard let environment = environment() else { throw PageError(code: "cmux.page.unavailable", message: "no acpmux") } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/AgentCLIJobCarrier.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/AgentCLIJobCarrier.swift index addbd19cf132..bb6da24948e3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/AgentCLIJobCarrier.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/AgentCLIJobCarrier.swift @@ -8,6 +8,14 @@ public nonisolated struct AgentCLIJobCarrier: Sendable { /// The canonical session of that transport, preserved across this carrier's lifetime. public let session: ResourceID let transport: LineTransport + let identity: DaemonIdentity + + /// Reads family evidence on this captured transport before an explicit update. + public func inventory() async throws -> AcpmuxHarnessInventory { + try MachineAgentInventoryRequest.requireServed(by: identity) + return try await DaemonRequestExecutor(transport: transport).perform( + MachineAgentInventoryRequest(expectedMachineID: machine), timeout: .seconds(15)) + } /// Starts or replays one explicit intent using its original key. /// A lost reply does not establish refusal; retain the same intent for recovery. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection+AgentCLIJobs.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection+AgentCLIJobs.swift index 84760edf050a..b2ac8860079c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection+AgentCLIJobs.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection+AgentCLIJobs.swift @@ -14,6 +14,6 @@ extension DaemonConnection { ready.identity.sessionResourceID == expectedSessionID else { throw DaemonError.malformedResponse("Job carrier owner/session did not match the admitted route") } - return AgentCLIJobCarrier(machine: expectedMachineID, session: expectedSessionID, transport: ready.transport) + return AgentCLIJobCarrier(machine: expectedMachineID, session: expectedSessionID, transport: ready.transport, identity: ready.identity) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettingsWindow/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextSettingsWindow/Localizable.xcstrings index b860264aa766..9aab1f71cd9c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettingsWindow/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettingsWindow/Localizable.xcstrings @@ -40018,6 +40018,661 @@ } } }, + "settingsWindow.harnesses.jobCreated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update terminal created" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم إنشاء طرفية التحديث" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Terminal za ažuriranje je kreiran" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Opdateringsterminal oprettet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Update-Terminal erstellt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Terminal de actualización creado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Terminal de mise à jour créé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Terminale di aggiornamento creato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新用ターミナルを作成しました" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានបង្កើតស្ថានីយសម្រាប់ធ្វើបច្ចុប្បន្នភាព" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 터미널이 생성되었습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Oppdateringsterminal opprettet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Utworzono terminal aktualizacji" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Terminal de atualização criado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Терминал обновления создан" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สร้างเทอร์มินัลสำหรับอัปเดตแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncelleme terminali oluşturuldu" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Термінал оновлення створено" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đã tạo cửa sổ dòng lệnh cập nhật" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已创建更新终端" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已建立更新終端機" + } + } + } + }, + "settingsWindow.harnesses.jobPending": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for update to finish" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بانتظار انتهاء التحديث" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Čekanje završetka ažuriranja" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Venter på, at opdateringen afsluttes" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warten auf den Abschluss des Updates" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando a que termine la actualización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente de la fin de la mise à jour" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "In attesa del completamento dell’aggiornamento" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新の完了を待っています" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងរង់ចាំការធ្វើបច្ចុប្បន្នភាពបញ្ចប់" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트가 완료되기를 기다리는 중" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Venter på at oppdateringen skal fullføres" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Oczekiwanie na zakończenie aktualizacji" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aguardando a conclusão da atualização" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ожидание завершения обновления" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังรอให้การอัปเดตเสร็จสิ้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Güncellemenin bitmesi bekleniyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Очікування завершення оновлення" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đang chờ cập nhật hoàn tất" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在等待更新完成" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在等待更新完成" + } + } + } + }, + "settingsWindow.harnesses.jobExit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Exit code: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "رمز الخروج: %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Izlazni kod: %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Afslutningskode: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Exit-Code: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Código de salida: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Code de sortie : %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Codice di uscita: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "終了コード: %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កូដចាកចេញ៖ %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "종료 코드: %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Avslutningskode: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kod zakończenia: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Código de saída: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Код выхода: %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รหัสออก: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çıkış kodu: %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Код виходу: %@" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Mã thoát: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "退出代码:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "結束代碼:%@" + } + } + } + }, + "settingsWindow.harnesses.jobSignal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signal: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإشارة: %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Broj signala: %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Signalnummer: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Signalnummer: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Señal: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Signal reçu : %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Segnale: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シグナル: %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "សញ្ញា៖ %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시그널: %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Signalnummer: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sygnał: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sinal: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сигнал: %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สัญญาณ: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sinyal: %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сигнал: %@" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Tín hiệu: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "信号:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "訊號:%@" + } + } + } + }, + "settingsWindow.harnesses.jobUnknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Outcome unknown; do not start another update." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "النتيجة غير معروفة؛ لا تبدأ تحديثًا آخر." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ishod je nepoznat; nemojte pokretati drugo ažuriranje." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Resultatet er ukendt; start ikke en ny opdatering." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ergebnis unbekannt; kein weiteres Update starten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Resultado desconocido; no inicies otra actualización." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résultat inconnu ; ne lancez pas une autre mise à jour." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Esito sconosciuto; non avviare un altro aggiornamento." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "結果が不明です。別の更新を開始しないでください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនស្គាល់លទ្ធផលទេ។ កុំចាប់ផ្តើមធ្វើបច្ចុប្បន្នភាពផ្សេងទៀត។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "결과를 알 수 없습니다. 다른 업데이트를 시작하지 마세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Resultatet er ukjent; ikke start en ny oppdatering." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wynik jest nieznany; nie uruchamiaj kolejnej aktualizacji." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Resultado desconhecido; não inicie outra atualização." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Результат неизвестен; не запускайте другое обновление." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่ทราบผลลัพธ์ อย่าเริ่มการอัปเดตอื่น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sonuç bilinmiyor; başka bir güncelleme başlatmayın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Результат невідомий; не запускайте інше оновлення." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chưa rõ kết quả; đừng bắt đầu bản cập nhật khác." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "结果未知;请勿开始另一次更新。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "結果未知;請勿開始另一次更新。" + } + } + } + }, "settingsWindow.backdropPicker.fromLink": { "comment": "Background picker tile that adds an image or video from a web link.", "extractionState": "manual", diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentCLIUpdateActionsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentCLIUpdateActionsTests.swift new file mode 100644 index 000000000000..2018000d6334 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentCLIUpdateActionsTests.swift @@ -0,0 +1,443 @@ +import CmuxNextActions +import CmuxNextControl +import CmuxNextDaemon +import CmuxNextPages +import CmuxNextSettings +import Foundation +import Testing +@testable import CmuxNextAgentUpdates +@testable import CmuxNextApp + +/// Settings and palette use the production registry binding; the owner boundary +/// records requests and returns terminal receipts without running a package manager. +@MainActor struct AgentCLIUpdateActionsTests { + typealias JSONValue = CmuxNextSettings.JSONValue + @MainActor final class Owner { + var starts: [(AgentCLIFamily, String)] = [] + var captures = 0 + var reads = 0 + var observations = 0 + var current = true + var loseReply = false + var outcome = "pending" + var refuse: Set = [] + /// The owner answers busy for this family (its key's original request still runs). + var busy: Set = [] + /// The owner answers this family's start with a coded failure (`failCode`). + var fail: Set = [] + var failCode = "operation.failed" + var claudeChannel = "native" + /// A reconnect advances the epoch; routes captured before it retire. + var epoch = 0 + /// The owner answers with a receipt for another machine. + var mismatch = false + /// The start of this family waits in `held` until the test resumes it. + var hold: AgentCLIFamily? + var held: CheckedContinuation? + let machine: ResourceID = "machine_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + var session: ResourceID = "session_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + func route() -> AgentCLIUpdateActions.Route { + captures += 1 + let epoch = self.epoch + return .init(machine: machine, session: session, isCurrent: { self.current && self.epoch == epoch }, inventory: { + self.reads += 1 + return try JSONDecoder().decode(AcpmuxHarnessInventory.self, from: Data(#"{"view":"inventory","cached":true,"harnesses":[{"id":"work-profile","family":"codex","channel":"npm","version":"1","latest":"2","outdated":true,"auth":"unknown","checkedAt":1},{"id":"another-profile","family":"codex","channel":"npm","version":"1","latest":"2","outdated":true,"auth":"unknown","checkedAt":1},{"id":"claude","family":"claude","channel":"\#(self.claudeChannel)","version":"1","latest":"2","outdated":true,"auth":"unknown","checkedAt":1},{"id":"codex","channel":"npm","version":"1","latest":"2","outdated":true,"auth":"unknown","checkedAt":1}]}"#.utf8)) + }, start: { cli, key in + self.starts.append((cli, key)) + if self.hold == cli { + self.hold = nil + await withCheckedContinuation { self.held = $0 } + } + if self.refuse.contains(cli) { + throw DaemonError.command(cmd: "agent-cli-update-job", message: "The owner could not start the update job", + code: "agent_cli_job.refused") + } + if self.busy.contains(cli) { + throw DaemonError.command(cmd: "agent-cli-update-job", message: "Another agent CLI update is running", + code: "agent_cli_job.busy") + } + if self.loseReply { throw DaemonError.notConnected } + if self.fail.contains(cli) { + throw DaemonError.command(cmd: "agent-cli-update-job", message: "The update terminal could not be created", + code: self.failCode) + } + return try self.receipt(cli) + }, observe: { receipt in + self.observations += 1 + let result = switch self.outcome { + case "pending": #"{"state":"pending","lifecycle":"running","revision":"7"}"# + case "unknown": #"{"state":"exited","lifecycle":"exited","revision":"8","exited_at":"1","outcome":{"kind":"unknown"}}"# + default: #"{"state":"exited","lifecycle":"exited","revision":"8","exited_at":"1","outcome":{"kind":"exit","code":23}}"# + } + var value = try JSONValue.parse(Data(result.utf8)).objectValue ?? [:] + value["terminal_id"] = .string(receipt.path.terminalID.rawValue) + return try JSONDecoder().decode(AgentCLIJobStatus.self, from: Data(JSONValue.object(value).compactText.utf8)) + }) + } + + func receipt(_ cli: AgentCLIFamily) throws -> AgentCLIJobReceipt { + let suffix = String(repeating: cli == .codex ? "a" : "b", count: 32) + let owner = mismatch ? "machine_cccccccccccccccccccccccccccccccc" : machine.rawValue + let value: JSONValue = ["machine": .string(owner), "session": .string(session.rawValue), + "cli": .string(cli.rawValue), "revision": "1", "replayed": false, + "path": ["kind": "terminal", "workspace_id": .string("ws_" + suffix), "screen_id": .string("screen_" + suffix), + "pane_id": .string("pane_" + suffix), "tab_id": .string("tab_" + suffix), "terminal_id": .string("term_" + suffix)]] + return try JSONDecoder().decode(AgentCLIJobReceipt.self, from: Data(value.compactText.utf8)) + } + } + + @MainActor final class Notices { + var shown: [(String, Bool)] = [] + } + + private func make() -> (Owner, AgentCLIUpdateActions, ActionRegistry) { + let owner = Owner() + let updates = AgentCLIUpdateActions(capture: { _ in owner.route() }) + let registry = ActionRegistry.standard() + updates.bind(into: registry) + return (owner, updates, registry) + } + + private func page(_ key: String = "original-operation", user: Bool = true) -> PageCallContext { + .init(page: "cmux.settings", opid: key, userGesture: user, gestureEvent: user ? 1 : nil) + } + + @Test func settingsUsesTheOwnersFamilyAndOriginalKeyInsteadOfTheProfileName() async throws { + let (owner, updates, registry) = make() + _ = try await updates.runSettings(id: "work-profile", context: page(), registry: registry) + #expect(owner.starts.count == 1) + #expect(owner.starts.first?.0 == .codex && owner.starts.first?.1 == "original-operation") + let job = try #require(updates.state.arrayValue?.first) + #expect(job["machine"] == .string(owner.machine.rawValue) && job["session"] == .string(owner.session.rawValue)) + #expect(job["terminal"] == "term_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + #expect(job["state"] == "created") + } + + @Test func settingsAndPaletteRunOneJobPerObservedFamilyThroughTheSameHandler() async throws { + let (owner, updates, registry) = make() + _ = try await updates.runSettings(id: nil, context: page("settings-batch"), registry: registry) + #expect(owner.starts.map { $0.0.rawValue } == ["claude", "codex"]) + #expect(Set(owner.starts.map { $0.1 }).count == 2) + #expect(owner.starts.allSatisfy { $0.1 != "settings-batch" }) + let first = owner.starts.map { $0.1 } + _ = try await updates.runSettings(id: nil, context: page("settings-batch"), registry: registry) + #expect(owner.starts.count == 2) + #expect(owner.starts.map { $0.1 } == first) + #expect(owner.captures == 1) + let (paletteOwner, palette, paletteRegistry) = make() + let work = paletteRegistry.capturingWork { paletteRegistry.perform("agent.harness.updateAll") } + for task in work { #expect(await task.value == nil) } + #expect(paletteOwner.starts.map { $0.0.rawValue } == ["claude", "codex"]) + #expect(palette.state.arrayValue?.count == 2) + } + + @Test func profileNamesCannotSubstituteForMissingFamilyEvidence() async throws { + let (owner, updates, registry) = make() + do { + _ = try await updates.runSettings(id: "codex", context: page(), registry: registry) + Issue.record("profile name invented family evidence") + } catch {} + #expect(owner.starts.isEmpty) + } + + @Test func scriptsAndAppGesturesCannotBorrowFrontendExecution() async throws { + let (owner, updates, registry) = make() + do { + _ = try await updates.runSettings(id: nil, context: page(user: false), registry: registry) + Issue.record("page without a gesture updated") + } catch let error as PageError { #expect(error.code == PageNativeOp.userOnlyCode) } + for origin in [ActionOrigin.script, .cli, .user] { + var invocation = ActionInvocation(origin: origin) + if origin == .user { invocation.callerApp = "example.app" } + let work = registry.capturingWork { registry.perform("agent.harness.updateAll", invocation: invocation) } + for task in work { _ = await task.value } + } + let bridge = RegistryControlBridge(registry: registry) + let result = bridge.performAction(.init(actionID: "agent.harness.updateAll", origin: "user")) + if case .refused = result {} else { Issue.record("a wire user claim reached a person-only action") } + #expect(owner.captures == 0 && owner.starts.isEmpty) + } + + @Test func retiredRoutesNeverStartAndAReplyLossNeverChangesTheIntent() async throws { + let (owner, updates, registry) = make() + owner.loseReply = true + for _ in 0..<2 { + do { _ = try await updates.runSettings(id: "work-profile", context: page(), registry: registry) } catch {} + } + #expect(owner.starts.count == 1) + #expect(updates.state.arrayValue?.first?["state"] == "unknown") + owner.current = false + await updates.refresh() + #expect(owner.starts.count == 1 && owner.observations == 0) + let (retired, actions, actionsRegistry) = make() + retired.current = false + do { _ = try await actions.runSettings(id: nil, context: page(), registry: actionsRegistry) } catch {} + #expect(retired.reads == 0 && retired.starts.isEmpty) + } + + @Test func onlyTerminalObservationCanCompleteAnUpdate() async throws { + let (owner, updates, registry) = make() + _ = try await updates.runSettings(id: "work-profile", context: page(), registry: registry) + await updates.refresh() + #expect(updates.state.arrayValue?.first?["state"] == "pending") + owner.outcome = "exited" + await updates.refresh() + let job = try #require(updates.state.arrayValue?.first) + #expect(job["state"] == "exited" && job["exit_code"] == 23) + #expect(owner.starts.count == 1 && owner.observations == 2) + } + + @Test func familiesTheOwnerRefusesAreNeverOfferedOrSubmitted() async throws { + for channel in ["brewCask", "brew", "standalone", "bun"] { + let (owner, updates, registry) = make() + owner.claudeChannel = channel + _ = try await updates.runSettings(id: nil, context: page("batch-\(channel)"), registry: registry) + #expect(owner.starts.map { $0.0.rawValue } == ["codex"], "\(channel)") + do { + _ = try await updates.runSettings(id: "claude", context: page("one-\(channel)"), registry: registry) + Issue.record("a \(channel) installation reached the owner") + } catch {} + #expect(owner.starts.count == 1) + #expect(updates.state.arrayValue?.map { $0["cli"] } == ["codex"]) + } + } + + @Test func aRefusedStartLeavesNoBlockingJobAndTheBatchContinues() async throws { + let (owner, updates, registry) = make() + owner.refuse = [.claude] + do { + _ = try await updates.runSettings(id: nil, context: page("refused-batch"), registry: registry) + Issue.record("a refused family reported success") + } catch let error as PageError { #expect(error.message.contains("claude")) } + #expect(owner.starts.map { $0.0.rawValue } == ["claude", "codex"]) + let jobs = try #require(updates.state.arrayValue) + #expect(jobs.map { $0["cli"] } == ["codex"] && jobs.first?["state"] == "created") + owner.refuse = [] + _ = try await updates.runSettings(id: "claude", context: page("after-refusal"), registry: registry) + #expect(owner.starts.last?.0 == .claude && owner.starts.last?.1 == "after-refusal") + #expect(updates.state.arrayValue?.contains { $0["cli"] == "claude" && $0["state"] == "created" } == true) + } + + @Test func aReconnectToTheSameOwnerKeepsTheJobVisibleAndObservedUntilItEnds() async throws { + let (owner, updates, registry) = make() + _ = try await updates.runSettings(id: "work-profile", context: page("before-reconnect"), registry: registry) + owner.epoch += 1 + #expect(updates.state.arrayValue?.first?["state"] == "created") + await updates.refresh() + #expect(owner.observations == 1) + #expect(updates.state.arrayValue?.first?["state"] == "pending") + do { + _ = try await updates.runSettings(id: "work-profile", context: page("while-pending"), registry: registry) + Issue.record("a second update started while the first still runs") + } catch {} + #expect(owner.starts.count == 1) + owner.outcome = "exited" + await updates.refresh() + _ = try await updates.runSettings(id: "work-profile", context: page("after-reconnect"), registry: registry) + #expect(owner.starts.count == 2 && owner.starts.last?.1 == "after-reconnect") + } + + @Test func aNewDaemonSessionRetiresTheOldJobInsteadOfBlockingTheCLI() async throws { + let (owner, updates, registry) = make() + _ = try await updates.runSettings(id: "work-profile", context: page("first-session"), registry: registry) + owner.session = "session_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + owner.epoch += 1 + _ = try await updates.runSettings(id: "work-profile", context: page("second-session"), registry: registry) + #expect(owner.starts.count == 2 && owner.starts.last?.1 == "second-session") + #expect(updates.state.arrayValue?.map { $0["session"] } == [.string(owner.session.rawValue)]) + } + + @Test func anExitWithAnUnknownOutcomeSettlesTheJobWithoutBlockingTheCLI() async throws { + let (owner, updates, registry) = make() + owner.outcome = "unknown" + _ = try await updates.runSettings(id: "work-profile", context: page("unknown-outcome"), registry: registry) + await updates.refresh() + let job = try #require(updates.state.arrayValue?.first) + #expect(job["state"] == "exited" && job["outcome"] == "unknown") + _ = try await updates.runSettings(id: "work-profile", context: page("after-unknown"), registry: registry) + #expect(owner.starts.count == 2 && owner.starts.last?.1 == "after-unknown") + } + + @Test func aReceiptForAnotherOwnerLeavesNoBlockingJob() async throws { + let (owner, updates, registry) = make() + owner.mismatch = true + do { + _ = try await updates.runSettings(id: "work-profile", context: page("mismatched"), registry: registry) + Issue.record("a receipt for another machine was accepted") + } catch {} + #expect(updates.state.arrayValue?.isEmpty == true) + owner.mismatch = false + _ = try await updates.runSettings(id: "work-profile", context: page("after-mismatch"), registry: registry) + #expect(owner.starts.count == 2 && owner.starts.last?.1 == "after-mismatch") + } + + @Test func thePaletteShowsAFailureNoCallerWaitsFor() async throws { + let (owner, _, registry) = make() + owner.refuse = [.claude, .codex] + let notices = Notices() + registry.refusalObserver = { notices.shown.append(($0, $1)) } + _ = registry.perform("agent.harness.updateAll") + for _ in 0..<200 where notices.shown.isEmpty { await Task.yield() } + #expect(owner.starts.count == 2) + let refusal = try #require(notices.shown.first) + #expect(refusal.0.contains("claude") && refusal.0.contains("codex") && !refusal.1) + } + + @Test func aReconnectDuringABatchStartsNoOtherFamilyAndLeavesItUnblocked() async throws { + let (owner, updates, registry) = make() + owner.hold = .claude + let batch = Task { try await updates.runSettings(id: nil, context: page("reconnect-mid-batch"), registry: registry) } + for _ in 0..<200 where owner.held == nil { await Task.yield() } + let held = try #require(owner.held) + // The connection drops while claude starts, so its reply is lost, and the app + // reconnects to the same machine and session; showing the jobs re-captures the owner. + owner.epoch += 1 + owner.loseReply = true + _ = updates.state + held.resume() + _ = try? await batch.value + #expect(owner.starts.map { $0.0 } == [.claude]) + #expect(updates.state.arrayValue?.contains { $0["cli"] == "codex" } == false) + owner.loseReply = false + _ = try await updates.runSettings(id: "work-profile", context: page("after-reconnect-mid-batch"), registry: registry) + #expect(owner.starts.last?.0 == .codex && owner.starts.last?.1 == "after-reconnect-mid-batch") + } + + @Test func refreshReplaysALostStartReplyByItsKeyAfterAReconnect() async throws { + let (owner, updates, registry) = make() + owner.loseReply = true + do { _ = try await updates.runSettings(id: "work-profile", context: page("lost-reply"), registry: registry) } catch {} + #expect(updates.state.arrayValue?.first?["state"] == "unknown") + owner.loseReply = false + owner.epoch += 1 + await updates.refresh() + #expect(owner.starts.map { $0.1 } == ["lost-reply", "lost-reply"]) + let job = try #require(updates.state.arrayValue?.first) + #expect(job["terminal"] == "term_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" && job["state"] == "pending") + owner.outcome = "exited" + await updates.refresh() + #expect(owner.starts.count == 2) + _ = try await updates.runSettings(id: "work-profile", context: page("after-replay"), registry: registry) + #expect(owner.starts.count == 3 && owner.starts.last?.1 == "after-replay") + } + + @Test func aReplayedStartTheOwnerRefusesClearsTheLostJob() async throws { + let (owner, updates, registry) = make() + owner.loseReply = true + do { _ = try await updates.runSettings(id: "work-profile", context: page("lost-then-refused"), registry: registry) } catch {} + owner.loseReply = false + owner.refuse = [.codex] + await updates.refresh() + #expect(updates.state.arrayValue?.isEmpty == true) + owner.refuse = [] + _ = try await updates.runSettings(id: "work-profile", context: page("after-refused-replay"), registry: registry) + #expect(owner.starts.last?.1 == "after-refused-replay") + } + + @Test func aReplayedStartTheOwnerFindsBusyKeepsTheLostJob() async throws { + let (owner, updates, registry) = make() + owner.loseReply = true + do { _ = try await updates.runSettings(id: "work-profile", context: page("lost-then-busy"), registry: registry) } catch {} + owner.loseReply = false + // Busy on a replay means the key's original request may still be running: the job + // stays, the CLI stays blocked, and the next refresh replays the same key. + owner.busy = [.codex] + await updates.refresh() + #expect(updates.state.arrayValue?.count == 1) + await #expect(throws: (any Error).self) { + _ = try await updates.runSettings(id: "work-profile", context: page("while-busy"), registry: registry) + } + owner.busy = [] + let replays = owner.starts.count + await updates.refresh() + #expect(owner.starts.count == replays + 1) + #expect(owner.starts.last?.1 == owner.starts.first?.1) + #expect(updates.state.arrayValue?.first?["terminal"]?.stringValue != nil) + } + + /// A first start sends a fresh key, so an owner answer of any code means nothing runs for + /// it (a failed creation, or a request the owner turned away, such as during a handoff): + /// the job settles instead of staying unknown, blocking the CLI, and being replayed. + @Test(arguments: ["operation.failed", "daemon_shutdown_pending"]) + func aFirstStartTheOwnerAnswersWithAFailureLeavesNoBlockingJob(code: String) async throws { + let (owner, updates, registry) = make() + owner.fail = [.codex] + owner.failCode = code + do { + _ = try await updates.runSettings(id: "work-profile", context: page("failed-start"), registry: registry) + Issue.record("a failed start reported success") + } catch let error as PageError { #expect(error.message.contains("codex")) } + #expect(updates.state.arrayValue?.isEmpty == true) + await updates.refresh() + #expect(owner.starts.count == 1) + owner.fail = [] + _ = try await updates.runSettings(id: "work-profile", context: page("after-failed-start"), registry: registry) + #expect(owner.starts.count == 2 && owner.starts.last?.1 == "after-failed-start") + } + + /// `mutation.indeterminate` on a first start says the owner's effect may have run (its + /// reserved terminal may be live), so the job keeps blocking its CLI rather than let a + /// second update of it start; a new owner session retires it. + @Test func aFirstStartTheOwnerAnswersIndeterminateKeepsBlockingTheCLI() async throws { + let (owner, updates, registry) = make() + owner.fail = [.codex] + owner.failCode = "mutation.indeterminate" + do { + _ = try await updates.runSettings(id: "work-profile", context: page("indeterminate-start"), registry: registry) + Issue.record("an indeterminate start reported success") + } catch {} + #expect(updates.state.arrayValue?.first?["state"] == "unknown") + owner.fail = [] + await #expect(throws: (any Error).self) { + _ = try await updates.runSettings(id: "work-profile", context: page("after-indeterminate"), registry: registry) + } + #expect(!owner.starts.contains { $0.1 == "after-indeterminate" }) + } + + /// A lost first reply may have created the terminal. A replay the owner answers with a code + /// other than an `agent_cli_job.*` refusal can come before the owner reads its journal + /// (a handoff, a registry error), so it proves nothing ran: the job keeps blocking its CLI + /// and the next refresh replays the same key, which then finds the running update. + @Test(arguments: ["operation.failed", "daemon_shutdown_pending"]) + func aReplayedStartTheOwnerAnswersWithAnotherCodeKeepsTheLostJob(code: String) async throws { + let (owner, updates, registry) = make() + owner.loseReply = true + do { _ = try await updates.runSettings(id: "work-profile", context: page("lost-then-coded"), registry: registry) } catch {} + #expect(updates.state.arrayValue?.first?["state"] == "unknown") + owner.loseReply = false + owner.fail = [.codex] + owner.failCode = code + await updates.refresh() + #expect(updates.state.arrayValue?.first?["state"] == "unknown") + await #expect(throws: (any Error).self) { + _ = try await updates.runSettings(id: "work-profile", context: page("while-lost"), registry: registry) + } + owner.fail = [] + await updates.refresh() + #expect(owner.starts.last?.1 == "lost-then-coded") + let job = try #require(updates.state.arrayValue?.first) + #expect(job["terminal"] == "term_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + } + + /// A job that already ended never blocks the next update of its CLI, even when + /// nothing (such as Settings > Agents) refreshed in between. + @Test func aNewStartObservesAnEndedJobInsteadOfWaitingForARefresh() async throws { + let (owner, updates, registry) = make() + _ = try await updates.runSettings(id: "work-profile", context: page("first-palette-run"), registry: registry) + #expect(updates.state.arrayValue?.first?["state"] == "created") + owner.outcome = "exited" + _ = try await updates.runSettings(id: "work-profile", context: page("second-palette-run"), registry: registry) + #expect(owner.starts.count == 2 && owner.starts.last?.1 == "second-palette-run") + #expect(updates.state.arrayValue?.contains { $0["state"] == "exited" && $0["exit_code"] == 23 } == true) + } + + @Test func aNewStartStillRefusesWhileTheEarlierJobRuns() async throws { + let (owner, updates, registry) = make() + _ = try await updates.runSettings(id: "work-profile", context: page("running"), registry: registry) + await #expect(throws: (any Error).self) { + _ = try await updates.runSettings(id: "work-profile", context: page("while-running"), registry: registry) + } + #expect(owner.starts.count == 1 && owner.observations == 1) + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentCLIUpdatesTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentCLIUpdatesTests.swift deleted file mode 100644 index af91bcb085fa..000000000000 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentCLIUpdatesTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -@testable import CmuxNextApp -import CmuxNextSettings -import Foundation -import Testing - -/// One-click agent CLI updates (cx-ny85): from the daemon's `_acpmux/harness/health` answer, -/// Settings > Agents (Update, Update All) and the palette's Update Agent CLIs type the same lines. -/// An outdated harness types its own update, quoted; one that needs sudo, a sudo program, a word no -/// shell quotes alike, or an up-to-date one types nothing; two harnesses from one package update once. -@MainActor struct AgentCLIUpdatesTests { - static let answer = #""" - {"harnesses": [ - {"id": "codex", "outdated": true, "update": {"argv": ["npm", "install", "-g", "@openai/codex@latest"], "command": "npm install -g @openai/codex@latest", "elevated": false}}, - {"id": "codex-acp", "outdated": true, "update": {"argv": ["npm", "install", "-g", "@openai/codex@latest"], "command": "npm install -g @openai/codex@latest", "elevated": false}}, - {"id": "claude", "outdated": true, "update": {"argv": ["/Users/me/My Tools/claude", "update"], "command": "'/Users/me/My Tools/claude' update", "elevated": false}}, - {"id": "opencode", "outdated": true, "update": {"argv": ["npm", "install", "-g", "opencode-ai@latest"], "command": "sudo npm install -g opencode-ai@latest", "elevated": true}}, - {"id": "rooted", "outdated": true, "update": {"argv": ["/usr/bin/sudo", "npm", "install", "-g", "x@latest"], "command": "sudo npm install -g x@latest", "elevated": false}}, - {"id": "quoted", "outdated": true, "update": {"argv": ["npm", "install", "-g", "it's@latest"], "command": "npm install -g it's@latest", "elevated": false}}, - {"id": "unsaid", "outdated": true, "update": {"argv": ["npm", "install", "-g", "y@latest"], "command": "npm install -g y@latest"}}, - {"id": "gemini", "outdated": false, "update": {"argv": ["brew", "upgrade", "gemini-cli"], "command": "brew upgrade gemini-cli", "elevated": false}}, - {"id": "grok", "outdated": true, "update": null} - ]} - """# - - @Test func oneClickTypesEachOutdatedHarnessesOwnUpdateOnceAndNeverSudo() throws { - let rows = try #require(AgentCLIUpdates.rows(Data(Self.answer.utf8))) - #expect(rows.count == 9) - #expect(AgentCLIUpdates.line(try #require(rows["codex"])) == "'npm' 'install' '-g' '@openai/codex@latest'") - for id in ["opencode", "rooted", "quoted", "unsaid", "gemini", "grok"] { - #expect(AgentCLIUpdates.line(try #require(rows[id])) == nil, "\(id) must not be typed") - } - let ordered = ["claude", "codex", "codex-acp", "gemini", "grok", "opencode", "quoted", "rooted", "unsaid"].compactMap { rows[$0] } - #expect(AgentCLIUpdates.allLine(ordered) == "'/Users/me/My Tools/claude' 'update'; 'npm' 'install' '-g' '@openai/codex@latest'") - #expect(AgentCLIUpdates.allLine(ordered.filter { $0["outdated"]?.boolValue == false }) == nil) - } -} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SettingsAgentUpdateContextTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SettingsAgentUpdateContextTests.swift index 2f5b5702cbd7..c5db09edeaaa 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SettingsAgentUpdateContextTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SettingsAgentUpdateContextTests.swift @@ -13,7 +13,7 @@ import Testing var lines: [String] = [] } - private func make() async throws -> (page: SettingsPageProvider, harnesses: SettingsHarnesses, recorder: Recorder, directory: URL) { + private func make(channel: String = "npm") async throws -> (page: SettingsPageProvider, harnesses: SettingsHarnesses, recorder: Recorder, directory: URL) { let directory = FileManager.default.temporaryDirectory.appending(path: "cn-settings-agent-context-\(UUID().uuidString)") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let url = directory.appending(path: "cmux.json") @@ -24,10 +24,13 @@ import Testing let recorder = Recorder() let environment = AcpmuxEnvironment(executable: directory.appending(path: "acpmux"), home: directory, socketPath: directory.appending(path: "a.sock").path, daemonArguments: [], childEnvironment: [:]) - let health = Data(#"{"harnesses":[{"id":"codex","outdated":true,"update":{"argv":["npm","install","-g","@openai/codex@latest"],"elevated":false}}]}"#.utf8) + let health = Data(#"{"harnesses":[{"id":"codex","channel":"\#(channel)","outdated":true,"update":{"argv":["npm","install","-g","@openai/codex@latest"],"elevated":false}}]}"#.utf8) let harnesses = SettingsHarnesses(environment: { environment }, fetch: { _ in [.init(id: "codex", kind: "acp", source: "path")] }, - fetchHealth: { _, _ in health }, openTerminal: { recorder.lines.append($0) }) + fetchHealth: { _, _ in health }, openTerminal: { recorder.lines.append($0) }, + runUpdate: { params, _ in + recorder.lines.append("update:" + (params["action"] == "update" ? params["id"]?.stringValue ?? "" : "all")); return .array([]) + }) await harnesses.refresh() let page = SettingsPageProvider(settings: settings) page.harnessesRun = { params, context in try await harnesses.run(params, context: context) } @@ -66,7 +69,7 @@ import Testing let h = try await make() defer { try? FileManager.default.removeItem(at: h.directory) } _ = try await h.page.call("cmux.settings.harnesses.run", params: params(action), context: context(mode)) - #expect(h.recorder.lines == ["'npm' 'install' '-g' '@openai/codex@latest'"]) + #expect(h.recorder.lines == [action == "update" ? "update:codex" : "update:all"]) } @Test(arguments: ["script", "gesture", "confirmed"]) @@ -100,4 +103,13 @@ import Testing #expect(h.harnesses.rows.map(\.id) == ["codex"]) #expect(h.recorder.lines.isEmpty) } + + /// Only channels the owner can run offer Update. + @Test(arguments: ["npm", "native", "brew", "brewCask", "bun", "standalone"]) + func onlyChannelsTheOwnerRunsOfferUpdate(channel: String) async throws { + let h = try await make(channel: channel) + defer { try? FileManager.default.removeItem(at: h.directory) } + let row = try #require(h.harnesses.state["harnesses"]?.arrayValue?.first) + #expect(row["ownerUpdate"] == .bool(channel == "npm" || channel == "native")) + } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDaemonTests/AgentCLIJobCarrierTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDaemonTests/AgentCLIJobCarrierTests.swift index 9fe01fdedfc1..1270587fca73 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextDaemonTests/AgentCLIJobCarrierTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDaemonTests/AgentCLIJobCarrierTests.swift @@ -103,6 +103,26 @@ struct AgentCLIJobCarrierTests { } } + @Test(arguments: ["good", "inventory-old"]) + func inventoryUsesTheCapturedOwnerAndRequiresItsReadCapability(mode: String) async throws { + try await withConnection(mode) { connection in + let carrier = try await connection.agentCLIJobs(expectedMachineID: machine, expectedSessionID: session) + do { + let inventory = try await carrier.inventory() + #expect(mode == "good" && inventory.harnesses.isEmpty) + } catch DaemonError.missingCapabilities(let capabilities) { + #expect(mode == "inventory-old" && capabilities == ["agent-inventory-owner-v1"]) + } + let calls = try #require(try await connection.request(Calls()).arrayValue) + #expect(calls.count == (mode == "good" ? 1 : 0)) + if let call = calls.first { + #expect(call["cmd"] == .string("agent-inventory")) + #expect(call["expected_machine_id"] == .string(machine.rawValue)) + #expect(call["cli"] == nil && call["idempotency_key"] == nil) + } + } + } + @Test(arguments: ["empty-key", "control-key", "unknown-cli"]) func invalidIntentIsRefusedBeforeAnyMutation(mode: String) async throws { try await withConnection("good") { connection in @@ -209,11 +229,15 @@ struct AgentCLIJobCarrierTests { cmd = frame.get('cmd') if cmd == 'identify': result = {'app':'cmux-tui','protocol':12,'generation':'job-boot','registry_id':'original-registry', - 'capabilities':['machine-identity-v1'] + ([] if mode == 'old' else ['agent-cli-update-job-v1']), + 'capabilities':['machine-identity-v1'] + ([] if mode == 'old' else ['agent-cli-update-job-v1']) + + ([] if mode == 'inventory-old' else ['agent-inventory-owner-v1']), 'machine_id': 'machine_' + 'b'*32 if mode == 'wrong-machine' else machine, 'session_resource_id': 'session_' + 'b'*32 if mode == 'wrong-session' else session} elif cmd == 'client-hello': result = {'connection_id':'1','user_origin_allowed':mode != 'unverified'} + elif cmd == 'agent-inventory': + calls.append(frame) + result = {'view':'inventory','cached':True,'harnesses':[]} elif cmd == 'agent-cli-update-job': calls.append(frame) result = {'machine':machine,'session':session,'cli':'claude','revision':'18446744073709551615', diff --git a/Packages/macOS/CmuxNext/ci-target-graph.json b/Packages/macOS/CmuxNext/ci-target-graph.json index 5600bc6d710e..4e9a7306bb47 100644 --- a/Packages/macOS/CmuxNext/ci-target-graph.json +++ b/Packages/macOS/CmuxNext/ci-target-graph.json @@ -167,6 +167,18 @@ "CmuxAgentQuestion" ] }, + "CmuxNextAgentUpdates": { + "kind": "regular", + "path": "Packages/macOS/CmuxNext/Sources/CmuxNextAgentUpdates", + "targets": [ + "CmuxNextActions", + "CmuxNextDaemon", + "CmuxNextDaemonService", + "CmuxNextPages", + "CmuxNextSettings" + ], + "packages": [] + }, "CmuxNextApp": { "kind": "regular", "path": "Packages/macOS/CmuxNext/Sources/CmuxNextApp", @@ -181,6 +193,7 @@ "CmuxNextAgentPane", "CmuxNextAgentPermissions", "CmuxNextAgentQuestion", + "CmuxNextAgentUpdates", "CmuxNextApps", "CmuxNextBookmarks", "CmuxNextBridge", @@ -279,6 +292,7 @@ "CmuxNextActions", "CmuxNextAgentCursor", "CmuxNextAgentLanes", + "CmuxNextAgentUpdates", "CmuxNextApp", "CmuxNextApps", "CmuxNextBrowserHistory", diff --git a/webviews/src/pages/settings/components/HarnessesCard.tsx b/webviews/src/pages/settings/components/HarnessesCard.tsx index fc9fe099e9db..95d6d3253271 100644 --- a/webviews/src/pages/settings/components/HarnessesCard.tsx +++ b/webviews/src/pages/settings/components/HarnessesCard.tsx @@ -4,14 +4,14 @@ // running `acpmux harness login ` (`--status` for Check), so the agent's own sign-in runs // where the person sees it; Browse ACP Registry runs `acpmux harness registry` the same way. // Health (cx-ny85, `_acpmux/harness/health`): the installed and newest versions, a sign-out the -// daemon has evidence for, and Update / Update all, which type the harness's own update command -// into a terminal tab. An update that needs sudo shows its exact command instead of a button. +// daemon has evidence for, and Update / Update all, which start owner-bound terminal jobs. +// An update that needs sudo shows its exact command instead of a button. import { useCallback, useId } from "react"; import { agentDisplayName } from "../../../agent-session/acpmux/agents"; import { AgentMark } from "../../../agent-session/shared/AgentMark"; import { useSettingsState, useStore } from "../context"; -import { updatable } from "../harnesses"; -import type { HarnessRow } from "../ops"; +import { offersUpdate, updatable, updateUnsettled } from "../harnesses"; +import type { HarnessRow, HarnessesState } from "../ops"; import { t } from "../strings"; const SOURCES = new Set(["managed", "user-file", "cmux-json", "registry", "path", "config"]); @@ -20,8 +20,18 @@ function sourceText(row: HarnessRow): string { return t(`settingsWindow.harnesses.source.${SOURCES.has(row.source) ? row.source : "config"}`); } +function jobText(job: NonNullable[number]): string { + if (job.state === "created") return t("settingsWindow.harnesses.jobCreated"); + if (job.state === "pending") return t("settingsWindow.harnesses.jobPending"); + if (job.state === "exited" && job.exit_code !== undefined) return t("settingsWindow.harnesses.jobExit", job.exit_code); + if (job.state === "exited" && job.signal !== undefined) return t("settingsWindow.harnesses.jobSignal", job.signal); + // A lost creation reply (`unknown`) and an end without a process result (`outcome: "unknown"`). + return t("settingsWindow.harnesses.jobUnknown"); +} + function Row({ row }: { row: HarnessRow }) { const store = useStore(); + const { harnesses } = useSettingsState(); const name = row.name ?? agentDisplayName(row.id); const terminal = row.kind === "terminal"; // Every row has a Check and a Sign In: each names its harness for assistive tech. @@ -85,11 +95,12 @@ function Row({ row }: { row: HarnessRow }) { {row.problem &&
{row.problem}
}
- {row.outdated && row.update && !row.update.elevated && ( + {offersUpdate(row) && ( )}
+ {(harnesses?.jobs ?? []).map((job, index) => ( +
+ {agentDisplayName(job.cli)}{" · "} + {jobText(job)} +
+ ))} {harnesses?.problem && (
{t(`settingsWindow.harnesses.problem.${harnesses.problem}`)} diff --git a/webviews/src/pages/settings/harnesses.ts b/webviews/src/pages/settings/harnesses.ts index 45282f63172b..cc5635ec3884 100644 --- a/webviews/src/pages/settings/harnesses.ts +++ b/webviews/src/pages/settings/harnesses.ts @@ -2,9 +2,20 @@ // and the section list's badge, so the list does not load the card. import type { HarnessRow, HarnessesState } from "./ops"; -/** Rows the page can update with one click (outdated, and no sudo needed). */ +/** Rows the page can update with one click (outdated, no sudo needed, a channel the owner runs). */ export function updatable(state: HarnessesState | null | undefined): HarnessRow[] { - return (state?.harnesses ?? []).filter((row) => row.outdated && row.update && !row.update.elevated); + return (state?.harnesses ?? []).filter((row) => offersUpdate(row) && !updateUnsettled(state, row.id)); +} + +/** Whether a row shows Update: the owner refuses elevated plans and channels it cannot run. */ +export function offersUpdate(row: HarnessRow): boolean { + return !!row.outdated && !!row.update && !row.update.elevated && row.ownerUpdate === true; +} + +/** A job still being created, running, or whose creation reply was lost blocks a second update; + * any end observation (`exited`, even with an unknown outcome) settles it. */ +export function updateUnsettled(state: HarnessesState | null | undefined, id: string): boolean { + return (state?.jobs ?? []).some((job) => job.profiles.includes(id) && job.state !== "exited"); } /** Whether Settings > Agents wants a look: a harness is outdated or signed out. */ diff --git a/webviews/src/pages/settings/harnessesCard.test.tsx b/webviews/src/pages/settings/harnessesCard.test.tsx index be49bce8d960..6bc484297151 100644 --- a/webviews/src/pages/settings/harnessesCard.test.tsx +++ b/webviews/src/pages/settings/harnessesCard.test.tsx @@ -13,6 +13,67 @@ const card = (container: HTMLElement) => container.querySelector('[ const ids = (element: HTMLElement) => [...element.querySelectorAll("[data-harness]")].map((row) => row.dataset.harness); +test("owner job receipts stay pending until a terminal exit; a lost reply blocks another update, an unknown exit does not", async () => { + const page = await renderPage({ path: "/settings/agents" }); + await settle(); + const base = page.provider.harnesses; + const show = async (state: string, extra = {}) => { + await act(async () => page.provider.setHarnesses({ ...base, jobs: [{ + machine: "machine_original", session: "session_original", cli: "codex", profiles: ["codex"], + terminal: "term_original", state, ...extra, + }] } as typeof base)); + await settle(); + }; + await show("created"); + let job = card(page.container)!.querySelector("[data-agent-update-job]"); + expect(job?.dataset.agentUpdateJob).toBe("created"); + expect(job?.textContent).toContain("Update terminal created"); + expect(card(page.container)!.querySelector('[data-harness="codex"] [data-harness-action="update"]')?.disabled).toBe(true); + await show("pending"); + job = card(page.container)!.querySelector("[data-agent-update-job]"); + expect(job?.textContent).toContain("Waiting for update to finish"); + await show("exited", { exit_code: 23 }); + job = card(page.container)!.querySelector("[data-agent-update-job]"); + expect(job?.textContent).toContain("Exit code: 23"); + expect(card(page.container)!.querySelector('[data-harness="codex"] [data-harness-action="update"]')?.disabled).toBe(false); + await show("unknown"); + expect(card(page.container)!.querySelector("[data-agent-update-job]")?.textContent).toContain("Outcome unknown"); + expect(card(page.container)!.querySelector('[data-harness="codex"] [data-harness-action="update"]')?.disabled).toBe(true); + // An end observation without a process result is settled: it says so and allows another update. + await show("exited", { outcome: "unknown" }); + job = card(page.container)!.querySelector("[data-agent-update-job]"); + expect(job?.dataset.agentUpdateJob).toBe("exited"); + expect(job?.textContent).toContain("Outcome unknown"); + expect(card(page.container)!.querySelector('[data-harness="codex"] [data-harness-action="update"]')?.disabled).toBe(false); + page.unmount(); +}); + +test("a channel the update owner cannot run (a Homebrew cask) offers no Update or Update All", async () => { + const page = await renderPage({ path: "/settings/agents" }); + await settle(); + const cask = { + id: "claude", + name: null, + kind: "claude-stdio", + source: "path", + problem: null, + version: "2.0.5", + latest: "2.0.9", + outdated: true, + auth: "signedIn" as const, + update: { command: "brew upgrade --cask claude-code", elevated: false }, + ownerUpdate: false, + }; + await act(async () => page.provider.setHarnesses({ loading: false, problem: null, harnesses: [cask] })); + await settle(); + const element = card(page.container)!; + expect(element.querySelector('[data-harness="claude"] [data-harness-action="update"]')).toBeNull(); + expect(element.querySelector('[data-harness-action="updateAll"]')).toBeNull(); + // Its auto-update choice stays: acpmux runs that itself. + expect(element.querySelector('[data-harness="claude"] input[data-harness-auto]')).not.toBeNull(); + page.unmount(); +}); + test("the card lists the host's harnesses with their names and reads them again when it opens", async () => { const page = await renderPage({ path: "/settings/agents" }); await settle(); diff --git a/webviews/src/pages/settings/mockProvider.ts b/webviews/src/pages/settings/mockProvider.ts index d7eebb7aa8cd..72c1aa39d478 100644 --- a/webviews/src/pages/settings/mockProvider.ts +++ b/webviews/src/pages/settings/mockProvider.ts @@ -438,6 +438,7 @@ export class MockSettingsProvider { outdated: true, auth: "signedOut", update: { command: "npm install -g @openai/codex@latest", elevated: false }, + ownerUpdate: true, }, { id: "github-copilot-cli", name: "GitHub Copilot", kind: "acp", source: "user-file", problem: null }, { id: "aider", name: "Aider", kind: "terminal", source: "user-file", problem: null }, diff --git a/webviews/src/pages/settings/ops.ts b/webviews/src/pages/settings/ops.ts index 44bfda4133b1..209296944220 100644 --- a/webviews/src/pages/settings/ops.ts +++ b/webviews/src/pages/settings/ops.ts @@ -315,6 +315,8 @@ export type HarnessRow = { auth?: "signedIn" | "signedOut" | "unknown"; /** How to update it; `elevated`: needs sudo, so the page shows `command` and never runs it. */ update?: { command: string; elevated: boolean } | null; + /** The update owner runs this channel (npm or native); without it Update is not offered. */ + ownerUpdate?: boolean; /** acpmux runs its update on its own when a check finds it outdated (off by default). */ autoUpdate?: boolean; /** What auto-update last did for it. */ @@ -327,10 +329,23 @@ export type HarnessesState = { /** `unavailable`: no acpmux in this app; `unreachable`: its daemon did not answer. */ problem: "unavailable" | "unreachable" | null; harnesses: HarnessRow[]; + /** Receipts and terminal observations from the captured update owner. */ + jobs?: Array<{ + machine: string; + session: string; + cli: string; + profiles: string[]; + terminal?: string; + state: "created" | "pending" | "exited" | "unknown"; + exit_code?: number; + signal?: number; + /** With `exited`: the owner saw the end but no process result; settled, never a success. */ + outcome?: "unknown"; + }>; }; /** One Harnesses gesture: Sign in and Check open a terminal tab with `harness login`; Update - * (one `id`) and Update all type the harness's own update command into a terminal tab. */ + * (one `id`) and Update all start owner-bound native terminal jobs. */ export type HarnessesRun = { action: "refresh" | "signIn" | "check" | "registry" | "update" | "updateAll" | "autoUpdate"; id?: string;