diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeChiefControl.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeChiefControl.swift index 95469d61f774..3c948308abf3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeChiefControl.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeChiefControl.swift @@ -2,6 +2,7 @@ import CmuxHomeCore import CmuxNextActions import CmuxNextDaemon import CmuxNextHomeEngine +import CmuxNextHostedChief import CmuxNextRemote import Foundation import os @@ -9,8 +10,9 @@ import os /// Which brain answers a Home Chief conversation, and the owner's control /// of it: the engine source of the settings panel and `chief.stop`. This /// Mac's Chief goes through this Mac's Chief owner daemon (which forwards to -/// its brain's tools socket); a cloud Chief goes through the daemon of the -/// paired server its brain is placed on (the owner session the app holds). +/// its brain's tools socket); a cloud Chief placed on a paired server goes +/// through that server's daemon (the owner session the app holds); a hosted +/// cloud Chief (no `brain_place`) through the Cloud service's brain route. @MainActor struct HomeChiefControl { let services: AppServices @@ -31,6 +33,15 @@ struct HomeChiefControl { var isCloud: Bool { services.home.isCloudConversation(conversation) } + /// A cloud Chief no paired server runs: the Cloud service's brain answers. A cloud Chief + /// not listed yet counts as hosted (a new Chief); the list's answer picks again. + var isHosted: Bool { isCloud && services.home.cloudChiefPlacement(of: conversation.rawValue)?.placed != true } + + /// The hosted Chief's agent id; nil while it is not listed (or a server runs it). + var hostedChiefID: String? { + isCloud ? services.home.cloudChiefPlacement(of: conversation.rawValue).flatMap { $0.placed ? nil : $0.id } : nil + } + /// The paired server that runs this cloud Chief's brain (`brain_place`). var server: ServerMachineSession? { guard isCloud, let host = services.home.cloudChiefs.chief(for: conversation.rawValue)?.brainPlace?.host else { return nil } @@ -64,6 +75,11 @@ struct HomeChiefControl { } let conversation = conversation let muxHome = muxHome + if isHosted { + return HomeChiefHostedEngine( + chief: { HomeChiefControl(services: services, conversation: conversation, muxHome: muxHome).hostedChiefID }, + send: services.home.hostedChief.brainRoute, files: files) + } return HomeChiefRemoteEngine( connection: { HomeChiefControl(services: services, conversation: conversation, muxHome: muxHome).connection }, place: server?.name ?? services.home.cloudChiefs.chief(for: conversation.rawValue)?.brainPlace?.host, @@ -73,6 +89,8 @@ struct HomeChiefControl { /// `chief.stop` on this Chief's brain; nil when it stopped (or had no /// turn), else why not. func stop() async -> HomeChiefEngineError? { + // The Cloud service has no stop for a hosted Chief's turn yet; no server to ask. + if isHosted { return .cloudStopUnsupported } guard let connection else { return .unreachable } do { let stopped = try await ChiefControlClient(connection).stopChief(chief: named) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeDirectory.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeDirectory.swift index 7695ae433f4e..3a6ab2be1da1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeDirectory.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeDirectory.swift @@ -1,18 +1,10 @@ import CmuxHomeCore import CmuxNextHome import CmuxNextHomeEngine +import CmuxNextHostedChief import Foundation import Observation -/// One of the user's Chiefs, as UserDO's `chief.list` reports it. -struct HomeChiefRecord: Hashable, Sendable { - var id: String - var name: String - var isDefault: Bool - var rev: Int - var mainConversation: String? -} - /// The people and Chiefs behind the Home page's New Message and New Chief /// (home-messaging.md 4.2 and 16): the members of the user's team /// (TeamDO `team.members.list`) and the user's Chiefs (UserDO `chief.*`). @@ -58,8 +50,8 @@ final class HomeDirectory { func refreshChiefs() async { if let reply = try? await call("v1/read", ["op": "chief.list", "params": ["include_archived": true]]), let value = try? Self.value(reply) { - chiefs = Self.chiefs(from: value) - archivedChiefs = Self.archivedChiefIDs(from: value) + chiefs = HomeChiefRecord.list(from: value) + archivedChiefs = HomeChiefRecord.archivedIDs(from: value) } } @@ -73,7 +65,7 @@ final class HomeDirectory { "idempotency_key": "chief-create-" + UUID().uuidString.lowercased(), "origin": "user"] return try Self.value(try await self.call("v1/ops", body)) } - guard let record = Self.chief(value) else { throw FeedServiceError.badReply } + guard let record = HomeChiefRecord.parse(value) else { throw FeedServiceError.badReply } chiefs.removeAll { $0.id == record.id } chiefs.append(record) return record @@ -129,20 +121,4 @@ final class HomeDirectory { } .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } } - - static func chiefs(from value: Any?) -> [HomeChiefRecord] { - ((value as? [String: Any])?["chiefs"] as? [[String: Any]] ?? []).compactMap(chief) - } - - static func archivedChiefIDs(from value: Any?) -> Set { - let list = (value as? [String: Any])?["chiefs"] as? [[String: Any]] ?? [] - return Set(list.compactMap { $0["archived_at"] is String ? $0["id"] as? String : nil }) - } - - static func chief(_ value: Any?) -> HomeChiefRecord? { - guard let object = value as? [String: Any], let id = object["id"] as? String, - (object["archived_at"] as? String) == nil else { return nil } - return HomeChiefRecord(id: id, name: object["display_name"] as? String ?? "", isDefault: object["is_default"] as? Bool ?? false, - rev: (object["rev"] as? NSNumber)?.intValue ?? 0, mainConversation: object["main_conversation"] as? String) - } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView.swift index 44766b715c6c..2d9de0d7fa6d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView.swift @@ -42,8 +42,10 @@ final class HomeHostView: NSView { /// Which brain answers this conversation (this Mac's or a paired server's). private var control: HomeChiefControl private var stopObserver: (any NSObjectProtocol)? - /// The settings panel shows a paired server's brain (a cloud Chief). + /// The settings panel shows a cloud Chief's brain, and whether the Cloud service hosts it + /// (else a paired server runs it). private var showsCloud = false + private var showsHosted = false private var isWorking = false /// Where this Chief runs and its other host, for the settings card (no floating switch over the chat). private var hostWatch: Task? @@ -150,14 +152,15 @@ final class HomeHostView: NSView { // whether this is the Chief conversation, and a refresh of the // settings' last turn on each new message. engineWatch = Task { [weak self] in - for await (isChief, _, title, cloud, typing, named) in ObservationStream({ () -> (Bool, Int, String, Bool, Bool, String?) in + for await (isChief, _, title, cloud, typing, named, hosted) in ObservationStream({ () -> (Bool, Int, String, Bool, Bool, String?, Bool) in let row = store.rows.first { $0.summary.id == id } let chief = row?.summary.participants.contains { $0.agentClass == .chief } ?? false // A cloud Chief's brain runs on its paired server, never on // this Mac's mux home (2026-10-08: the sidebar wrote codex // here while cmux-lawrence ran claude-sr). return (chief, store.transcriptVersion[id] ?? 0, row?.summary.title ?? "", service.isCloudConversation(id), - row?.isTyping ?? false, row.flatMap { ChiefHome.namedChief(in: $0.summary.participants) }) + row?.isTyping ?? false, row.flatMap { ChiefHome.namedChief(in: $0.summary.participants) }, + service.cloudChiefPlacement(of: conversation)?.placed != true) }) { guard let self else { return } // A named Chief's conversation: its panel, Stop and avatar name it (cx-327wl). @@ -170,8 +173,9 @@ final class HomeHostView: NSView { // The Chief types while its turn runs: the compose bar offers Stop. isWorking = isChief && typing transcript.isWorking = isWorking - if cloud != showsCloud { + if cloud != showsCloud || (cloud && hosted != showsHosted) { showsCloud = cloud + showsHosted = hosted settingsView.use(control.engineSource()) } settingsView.setName(title) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeService+ChiefRoster.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeService+ChiefRoster.swift index c3dbfa96653b..165206dd9301 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeService+ChiefRoster.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeService+ChiefRoster.swift @@ -36,6 +36,11 @@ extension HomeService { return line } + /// The cloud Chief whose main conversation is `conversation` (``HomeCloudChiefs/placement(of:records:)``). + func cloudChiefPlacement(of conversation: String) -> (id: String, placed: Bool)? { + cloudChiefs.placement(of: conversation, records: directory.chiefs) + } + /// The paired server's name for a placed Chief (its host id while the server is not paired here). func serverName(of chief: CloudChief?) -> String? { guard let host = chief?.brainPlace?.host else { return nil } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeService+Conversations.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeService+Conversations.swift index d5c977908f46..030f5decbf96 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeService+Conversations.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeService+Conversations.swift @@ -1,6 +1,7 @@ import CmuxHomeCore import CmuxNextHome import CmuxNextHomeEngine +import CmuxNextHostedChief import Foundation /// The Home page's conversation actions (New Message, Invite, New Chief, @@ -56,6 +57,8 @@ extension HomeService { private func createCloudChief(named name: String) async -> HomeComposeOutcome { do { let record = try await directory.createChief(named: name) + // A new Chief's brain starts off: turn it on, or it never answers. + if !record.placed { await hostedChief.enableBrain(of: record.id) } let id = record.mainConversation.map { ConversationID($0) } pendingSelection = id return id.map { HomeComposeOutcome.opened($0) } ?? .invited(nil) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Chief/ChiefControlClient.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Chief/ChiefControlClient.swift index 3f8604861f34..6721846fc66b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Chief/ChiefControlClient.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Chief/ChiefControlClient.swift @@ -11,6 +11,12 @@ public struct ChiefEngineReport: Decodable, Sendable, Equatable { public var harness: String? public var model: String? public var effort: String? + + public init(harness: String? = nil, model: String? = nil, effort: String? = nil) { + self.harness = harness + self.model = model + self.effort = effort + } } /// One recent turn's end, as the brain summarizes its trace. @@ -48,6 +54,11 @@ public struct ChiefEngineReport: Decodable, Sendable, Equatable { public struct Model: Decodable, Sendable, Equatable { public var id: String public var name: String + + public init(id: String, name: String) { + self.id = id + self.name = name + } } /// What a set may pick: acpmux's models for the engine's harness and the diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefEngineError.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefEngineError.swift index 4b8a5339cfc8..bb071ce842f1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefEngineError.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefEngineError.swift @@ -15,6 +15,14 @@ public nonisolated enum HomeChiefEngineError: Error, Equatable, Sendable { /// Agents are off in this build session (`agent_launch_disabled`). case agentsDisabled case refused(reason: String, message: String) + /// A hosted cloud Chief: the Cloud service could not be reached (offline, or the Chief list is not read yet). + case cloudUnreachable + /// A hosted cloud Chief: no account is signed in, or its sign-in expired. + case signedOut + /// A hosted cloud Chief: this Cloud service runs no Chief brains. + case cloudUnavailable + /// A hosted cloud Chief's turn: the Cloud service has no stop for it yet. + case cloudStopUnsupported case other(String) public init(_ error: ChiefControlError) { @@ -39,6 +47,10 @@ public nonisolated enum HomeChiefEngineError: Error, Equatable, Sendable { case .notConfigured: HomeEngineStrings.errorNotConfigured case .unsupported: HomeEngineStrings.errorUnsupported case .agentsDisabled: HomeStrings.chiefAgentsDisabled + case .cloudUnreachable: HomeEngineStrings.errorCloudUnreachable + case .signedOut: HomeEngineStrings.errorSignedOut + case .cloudUnavailable: HomeEngineStrings.errorCloudUnavailable + case .cloudStopUnsupported: HomeEngineStrings.errorCloudStopUnsupported case .refused(_, let message), .other(let message): String(format: HomeEngineStrings.errorRefusedFormat, message) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefEngineSource.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefEngineSource.swift index b1e9a6d2f237..1eff69ff1baa 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefEngineSource.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefEngineSource.swift @@ -7,13 +7,20 @@ import Foundation public nonisolated protocol HomeChiefEngineSource: Sendable { /// This Mac's brain: its traces and memory are here too. var isLocal: Bool { get } - /// The paired server that runs the brain (nil: this Mac). + /// The paired server that runs the brain (nil: this Mac, or the Cloud service). var place: String? { get } + /// The brain runs on a harness the owner picks (false: a hosted cloud Chief's brain runs + /// one engine of its own, and the card hides the Harness row). + var choosesHarness: Bool { get } func read() async throws(HomeChiefEngineError) -> HomeChiefSnapshot /// Sets (nil: clears) any of harness, model and effort in one brain write; the snapshot after. func set(_ fields: [String: String?]) async throws(HomeChiefEngineError) -> HomeChiefSnapshot } +extension HomeChiefEngineSource { + public nonisolated var choosesHarness: Bool { true } +} + /// This Mac's brain. It reads its mux home's files (engine.json, profile, /// traces) as a read-only mirror; a set goes to the brain through this Mac's /// Chief owner daemon (`chief.engine.set`), because the brain is the one diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefFiles.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefFiles.swift index 5b9da847d983..0c62483293f7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefFiles.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefFiles.swift @@ -20,6 +20,8 @@ public nonisolated struct HomeChiefSnapshot: Sendable { /// What the turns run on (engine.json over the brain's defaults), which /// names each picker's "Default"; nil until the brain answers. var effective: ChiefEngineReport.Engine? + /// A hosted cloud Chief's brain is turned off: it answers no message (``HomeChiefHostedEngine``). + var brainOff = false /// This snapshot with `report`'s offered models and efforts and its effective engine. func offering(_ report: ChiefEngineReport) -> HomeChiefSnapshot { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefHostedEngine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefHostedEngine.swift new file mode 100644 index 000000000000..16a2b9d8c0d3 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefHostedEngine.swift @@ -0,0 +1,114 @@ +public import CmuxNextDaemon +public import Foundation + +/// A hosted cloud Chief's brain: a cloud Chief no paired server runs (no `brain_place`), whose +/// brain runs in the Cloud service. Its settings go through the brain's own route as the signed-in +/// owner: `GET v1/chiefs//brain` (`{config, turns_today}`) and `PUT v1/chiefs//brain` +/// with `{model?, effort?}` (the new config). 2026-10-20: the card read a new Chief as a paired +/// server's and said "The server with this Chief is not connected." with empty pickers. +/// +/// The brain runs one engine of its own, so there is no harness to pick (``choosesHarness``); it +/// offers the models and efforts the route takes. The route never clears a field, so "Default" +/// sets the brain's own default (``defaultModel``, ``defaultEffort``). +public nonisolated struct HomeChiefHostedEngine: HomeChiefEngineSource { + /// One request to the Cloud service as the signed-in user: the status and the body. Throws + /// ``Failure/signedOut`` with no signed-in account, any other error when it was not reached. + public typealias Send = @MainActor @Sendable (_ method: String, _ path: String, _ body: Data?) async throws -> (status: Int, body: Data) + + public enum Failure: Error, Sendable { + case signedOut + } + + /// The Chief's agent id (`agent_…`); nil while the Chief list has not been read. + let chief: @MainActor @Sendable () -> String? + let send: Send + /// The avatar stays this Mac's (`profile.json`, cosmetic). + let files: HomeChiefFiles + public var isLocal: Bool { false } + public var place: String? { nil } + public var choosesHarness: Bool { false } + + /// The models the brain route takes for a Chief's turns (chief-brain/routes.ts). + static let models: [ChiefEngineReport.Model] = [ + .init(id: "claude-opus-5-5", name: "Opus 5.5"), + .init(id: "claude-sonnet-5-5", name: "Sonnet 5.5"), + .init(id: "claude-haiku-5-5", name: "Haiku 5.5"), + ] + /// The efforts the brain route takes (contract.ts `Effort`). + static let efforts = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] + /// The brain's own defaults (contract.ts `DEFAULT_MODEL`, `DEFAULT_EFFORT`). + static let defaultModel = "claude-opus-5-5" + static let defaultEffort = "low" + + public init(chief: @escaping @MainActor @Sendable () -> String?, send: @escaping Send, files: HomeChiefFiles) { + self.chief = chief + self.send = send + self.files = files + } + + public func read() async throws(HomeChiefEngineError) -> HomeChiefSnapshot { + let config = try await call("GET", body: nil) + return Self.snapshot(config, avatar: files.avatar()) + } + + /// Sets model and effort (nil: the brain's default); a harness is not the brain's to change. + public func set(_ fields: [String: String?]) async throws(HomeChiefEngineError) -> HomeChiefSnapshot { + let patch = Self.patch(fields) + guard !patch.isEmpty else { return try await read() } + let body = try? JSONSerialization.data(withJSONObject: patch, options: [.sortedKeys]) + return Self.snapshot(try await call("PUT", body: body), avatar: files.avatar()) + } + + private func call(_ method: String, body: Data?) async throws(HomeChiefEngineError) -> [String: Any] { + guard let agent = await chief() else { throw .cloudUnreachable } + let reply: (status: Int, body: Data) + do { + reply = try await send(method, "v1/chiefs/\(agent)/brain", body) + } catch let failure as Failure { + switch failure { + case .signedOut: throw .signedOut + } + } catch { + throw .cloudUnreachable + } + return try Self.config(status: reply.status, body: reply.body) + } + + /// The PUT body for a set: model and effort only, nil as the brain's default. + static func patch(_ fields: [String: String?]) -> [String: String] { + var patch: [String: String] = [:] + if let model = fields["model"] { patch["model"] = model ?? defaultModel } + if let effort = fields["effort"] { patch["effort"] = effort ?? defaultEffort } + return patch + } + + /// The brain's config from one reply (GET's `value.config`, PUT's `value`), or why not. + static func config(status: Int, body: Data) throws(HomeChiefEngineError) -> [String: Any] { + let json = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] ?? [:] + if status == 200, let value = json["value"] as? [String: Any] { + return value["config"] as? [String: Any] ?? value + } + let error = json["error"] as? [String: Any] + let code = error?["code"] as? String + switch status { + case 401: throw .signedOut + // The deployment runs no Chief brains (`CHIEF_BRAIN_ENABLED` off). + case 404: throw .cloudUnavailable + case 200, 0, 500...: throw .cloudUnreachable + default: throw .refused(reason: code ?? "http_\(status)", message: error?["message"] as? String ?? code ?? "HTTP \(status)") + } + } + + /// What the card shows for one config: its model and effort as chosen (the route always has + /// both), the route's offer, and whether the brain is off (it then answers no message). + static func snapshot(_ config: [String: Any], avatar: String?) -> HomeChiefSnapshot { + let model = config["model"] as? String + let effort = config["effort"] as? String + var snapshot = HomeChiefSnapshot(harness: nil, model: model, effort: effort, avatar: avatar, turns: []) + snapshot.models = models + snapshot.efforts = efforts + snapshot.effective = ChiefEngineReport.Engine(model: model, effort: effort) + snapshot.brainOff = config["enabled"] as? Bool == false + return snapshot + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefSettingsView+Engine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefSettingsView+Engine.swift index 3d2d12249136..26896dd1c2bd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefSettingsView+Engine.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefSettingsView+Engine.swift @@ -107,7 +107,12 @@ extension HomeChiefSettingsView { replies.stringValue = snapshot.turns.isEmpty ? "" : HomeEngineStrings.answeredBy + "\n" + snapshot.turns.map(HomeEngineStrings.reply).joined(separator: "\n") replies.isHidden = snapshot.turns.isEmpty - onResize() + // A hosted cloud Chief whose brain is off answers no message: say so, never a silent Chief. + if snapshot.brainOff, !changeFailed { + note(HomeEngineStrings.hostedBrainOff, kept: false) + } else { + onResize() + } } /// `values` (value, title) after a "Default" item that names what it runs, @@ -187,6 +192,7 @@ extension HomeChiefSettingsView { /// The hint under the Harness row: only UniiChat's (sign in with `unii login` first), since /// the other engines' sign-in is the brain host's own (a cloud Chief's runs on its server). func showHarnessHint() { + guard source.choosesHarness else { return } let unii = harness.selectedItem?.representedObject as? String == ChiefBackend.unii.id let hint = unii ? ChiefBackend.unii.hint : nil harnessHint.stringValue = hint ?? "" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefSettingsView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefSettingsView.swift index cdd9cf0d0ba8..dee6bbc6544c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefSettingsView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefSettingsView.swift @@ -173,6 +173,7 @@ public final class HomeChiefSettingsView: NSView { addFullWidth(diagnosticsRow()) buildDiagnostics() addFullWidth(diagnostics) + showHarnessRow() refresh() } @@ -192,12 +193,15 @@ public final class HomeChiefSettingsView: NSView { changeFailed = false checkingHarness = nil // A named Chief's panel keeps its own files (cx-327wl), never the default's. - if let files = (source as? HomeChiefLocalEngine)?.files ?? (source as? HomeChiefRemoteEngine)?.files, files.muxHome != muxHome { + let files = (source as? HomeChiefLocalEngine)?.files ?? (source as? HomeChiefRemoteEngine)?.files ?? (source as? HomeChiefHostedEngine)?.files + if let files, files.muxHome != muxHome { muxHome = files.muxHome brain.stringValue = String(format: HomeEngineStrings.brainFormat, muxHome.path) } for view in localViews { view.isHidden = !source.isLocal } - place.stringValue = hostPlace ?? source.place.map { String(format: HomeEngineStrings.runsOnServerFormat, $0) } ?? HomeEngineStrings.runsHere + showHarnessRow() + let own = source is HomeChiefHostedEngine ? HomeEngineStrings.runsInCloud : HomeEngineStrings.runsHere + place.stringValue = hostPlace ?? source.place.map { String(format: HomeEngineStrings.runsOnServerFormat, $0) } ?? own return refresh() } @@ -217,6 +221,15 @@ public final class HomeChiefSettingsView: NSView { onResize() } + /// The Harness row only for a brain whose harness the owner picks (not a hosted cloud Chief's). + func showHarnessRow() { + let hidden = !source.choosesHarness + guard let row = harness.superview, row.isHidden != hidden else { return } + row.isHidden = hidden + if hidden { harnessHint.isHidden = true } + onResize() + } + @objc func switchHost() { onSwitchHost() } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeEngineStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeEngineStrings.swift index b9957bd231fc..91d1f6473ee6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeEngineStrings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeEngineStrings.swift @@ -49,6 +49,26 @@ public nonisolated enum HomeEngineStrings { static var errorUnreachable: String { String(localized: "home.engine.error.unreachable", defaultValue: "The server with this Chief is not connected.", table: "Home", bundle: .module) } + /// A hosted cloud Chief: the Cloud service did not answer. + static var errorCloudUnreachable: String { + String(localized: "home.engine.error.cloudUnreachable", defaultValue: "cmux could not reach the cloud for this Chief. Check your connection and try again.", table: "Home", bundle: .module) + } + /// A hosted cloud Chief: no account is signed in. + static var errorSignedOut: String { + String(localized: "home.engine.error.signedOut", defaultValue: "Sign in to cmux to change this Chief's settings.", table: "Home", bundle: .module) + } + /// A hosted cloud Chief on a Cloud service that runs no Chief brains. + static var errorCloudUnavailable: String { + String(localized: "home.engine.error.cloudUnavailable", defaultValue: "This cloud does not run Chiefs, so this Chief does not answer.", table: "Home", bundle: .module) + } + /// A hosted cloud Chief's turn cannot be stopped from this Mac yet. + static var errorCloudStopUnsupported: String { + String(localized: "home.engine.error.cloudStopUnsupported", defaultValue: "A cloud Chief's reply cannot be stopped from here yet.", table: "Home", bundle: .module) + } + /// A hosted cloud Chief whose brain is turned off. + static var hostedBrainOff: String { + String(localized: "home.engine.hostedBrainOff", defaultValue: "This Chief is turned off, so it does not answer messages.", table: "Home", bundle: .module) + } /// This Mac's Chief: its owner daemon or brain host is not running. static var errorLocalUnreachable: String { String(localized: "home.engine.error.localUnreachable", defaultValue: "This Mac's Chief is not running.", table: "Home", bundle: .module) @@ -78,6 +98,7 @@ public nonisolated enum HomeEngineStrings { /// know shows as the brain names it. static func effortTitle(_ id: String) -> String { switch id { + case "off": String(localized: "home.engine.effort.off", defaultValue: "Off", table: "Home", bundle: .module) case "minimal": String(localized: "home.engine.effort.minimal", defaultValue: "Minimal", table: "Home", bundle: .module) case "low": String(localized: "home.engine.effort.low", defaultValue: "Low", table: "Home", bundle: .module) case "medium": String(localized: "home.engine.effort.medium", defaultValue: "Medium", table: "Home", bundle: .module) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/Resources/Home.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/Resources/Home.xcstrings index 1bc7f2b6352d..f0fe7b5b1364 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/Resources/Home.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/Resources/Home.xcstrings @@ -7896,6 +7896,792 @@ } } } + }, + "home.engine.error.cloudUnreachable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux could not reach the cloud for this Chief. Check your connection and try again." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux konnte die Cloud für diesen Chief nicht erreichen. Prüfe deine Verbindung und versuche es erneut." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux n’a pas pu joindre le cloud pour ce Chief. Vérifiez votre connexion et réessayez." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر على cmux الوصول إلى السحابة لهذا الـ Chief. تحقّق من اتصالك وحاول مرة أخرى." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux no pudo conectar con la nube para este Chief. Comprueba tu conexión e inténtalo de nuevo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 無法連線到此 Chief 的雲端。請檢查網路連線後再試一次。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 无法连接到此 Chief 的云端。请检查网络连接后重试。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux가 이 Chief의 클라우드에 연결할 수 없습니다. 연결을 확인한 후 다시 시도하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux はこの Chief のクラウドに接続できませんでした。接続を確認して、もう一度お試しください。" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux nije mogao dosegnuti oblak za ovog Chiefa. Provjerite vezu i pokušajte ponovo." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke nå skyen for denne Chief. Tjek din forbindelse, og prøv igen." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux non è riuscito a raggiungere il cloud per questo Chief. Controlla la connessione e riprova." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "cmux មិនអាចភ្ជាប់ទៅពពកសម្រាប់ Chief នេះបានទេ។ សូមពិនិត្យការតភ្ជាប់របស់អ្នក ហើយព្យាយាមម្តងទៀត។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke nå skyen for denne Chief. Sjekk tilkoblingen og prøv igjen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux nie mógł połączyć się z chmurą dla tego Chiefa. Sprawdź połączenie i spróbuj ponownie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O cmux não conseguiu acessar a nuvem deste Chief. Verifique sua conexão e tente novamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux не удалось связаться с облаком для этого Chief. Проверьте подключение и повторите попытку." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux เชื่อมต่อคลาวด์ของ Chief นี้ไม่ได้ ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux bu Chief için buluta ulaşamadı. Bağlantınızı kontrol edip tekrar deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "cmux не вдалося зв’язатися з хмарою для цього Chief. Перевірте з’єднання й спробуйте ще раз." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "cmux không thể kết nối đến đám mây của Chief này. Hãy kiểm tra kết nối rồi thử lại." + } + } + } + }, + "home.engine.error.signedOut": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in to cmux to change this Chief's settings." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Melde dich bei cmux an, um die Einstellungen dieses Chiefs zu ändern." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connectez-vous à cmux pour modifier les réglages de ce Chief." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سجّل الدخول إلى cmux لتغيير إعدادات هذا الـ Chief." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Inicia sesión en cmux para cambiar los ajustes de este Chief." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "登入 cmux 以變更此 Chief 的設定。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "登录 cmux 以更改此 Chief 的设置。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 Chief의 설정을 변경하려면 cmux에 로그인하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Chief の設定を変更するには cmux にサインインしてください。" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prijavite se u cmux da promijenite postavke ovog Chiefa." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Log ind på cmux for at ændre denne Chiefs indstillinger." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Accedi a cmux per modificare le impostazioni di questo Chief." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ចូលគណនី cmux ដើម្បីប្តូរការកំណត់របស់ Chief នេះ។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Logg på cmux for å endre innstillingene til denne Chief." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zaloguj się do cmux, aby zmienić ustawienia tego Chiefa." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Entre no cmux para alterar as configurações deste Chief." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Войдите в cmux, чтобы изменить настройки этого Chief." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลงชื่อเข้าใช้ cmux เพื่อเปลี่ยนการตั้งค่าของ Chief นี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu Chief’in ayarlarını değiştirmek için cmux’a giriş yapın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Увійдіть у cmux, щоб змінити налаштування цього Chief." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đăng nhập cmux để thay đổi cài đặt của Chief này." + } + } + } + }, + "home.engine.error.cloudUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This cloud does not run Chiefs, so this Chief does not answer." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Cloud führt keine Chiefs aus, daher antwortet dieser Chief nicht." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ce cloud n’exécute pas de Chiefs, ce Chief ne répond donc pas." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "هذه السحابة لا تشغّل الـ Chiefs، لذلك لا يردّ هذا الـ Chief." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esta nube no ejecuta Chiefs, así que este Chief no responde." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此雲端不執行 Chief,因此這個 Chief 不會回覆。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此云端不运行 Chief,因此这个 Chief 不会回复。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 클라우드는 Chief를 실행하지 않으므로 이 Chief는 응답하지 않습니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このクラウドでは Chief が動作しないため、この Chief は返信しません。" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ovaj oblak ne pokreće Chiefove, pa ovaj Chief ne odgovara." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Denne sky kører ikke Chiefs, så denne Chief svarer ikke." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questo cloud non esegue Chief, quindi questo Chief non risponde." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ពពកនេះមិនដំណើរការ Chief ទេ ដូច្នេះ Chief នេះមិនឆ្លើយតបទេ។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Denne skyen kjører ikke Chiefs, så denne Chief svarer ikke." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ta chmura nie uruchamia Chiefów, więc ten Chief nie odpowiada." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Esta nuvem não executa Chiefs, então este Chief não responde." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Это облако не запускает Chief, поэтому этот Chief не отвечает." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คลาวด์นี้ไม่ได้รัน Chief ดังนั้น Chief นี้จะไม่ตอบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu bulut Chief çalıştırmıyor, bu yüzden bu Chief yanıt vermiyor." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ця хмара не запускає Chief, тому цей Chief не відповідає." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đám mây này không chạy Chief, nên Chief này không trả lời." + } + } + } + }, + "home.engine.error.cloudStopUnsupported": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A cloud Chief's reply cannot be stopped from here yet." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Antwort eines Cloud-Chiefs kann hier noch nicht gestoppt werden." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La réponse d’un Chief dans le cloud ne peut pas encore être arrêtée d’ici." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يمكن بعدُ إيقاف ردّ Chief سحابي من هنا." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Todavía no se puede detener desde aquí la respuesta de un Chief en la nube." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目前還無法從這裡停止雲端 Chief 的回覆。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "目前还无法从这里停止云端 Chief 的回复。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클라우드 Chief의 답변은 아직 여기에서 중지할 수 없습니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラウドの Chief の返信は、まだここから停止できません。" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Odgovor Chiefa u oblaku još se ne može zaustaviti odavde." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "En cloud-Chiefs svar kan endnu ikke stoppes herfra." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non è ancora possibile interrompere da qui la risposta di un Chief nel cloud." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនទាន់អាចបញ្ឈប់ការឆ្លើយតបរបស់ Chief លើពពកពីទីនេះបានទេ។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Svaret fra en sky-Chief kan ikke stoppes herfra ennå." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odpowiedzi Chiefa w chmurze nie można jeszcze zatrzymać stąd." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ainda não é possível interromper daqui a resposta de um Chief na nuvem." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ответ облачного Chief пока нельзя остановить отсюда." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยังหยุดคำตอบของ Chief บนคลาวด์จากที่นี่ไม่ได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bulut Chief’inin yanıtı henüz buradan durdurulamıyor." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відповідь хмарного Chief поки не можна зупинити звідси." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chưa thể dừng câu trả lời của Chief trên đám mây từ đây." + } + } + } + }, + "home.engine.hostedBrainOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This Chief is turned off, so it does not answer messages." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieser Chief ist ausgeschaltet und beantwortet daher keine Nachrichten." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ce Chief est désactivé, il ne répond donc pas aux messages." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "هذا الـ Chief متوقّف، لذلك لا يردّ على الرسائل." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Este Chief está desactivado, así que no responde a los mensajes." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此 Chief 已關閉,因此不會回覆訊息。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此 Chief 已关闭,因此不会回复消息。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 Chief는 꺼져 있어 메시지에 응답하지 않습니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Chief はオフになっているため、メッセージに返信しません。" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ovaj Chief je isključen, pa ne odgovara na poruke." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Denne Chief er slået fra, så den svarer ikke på beskeder." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questo Chief è disattivato, quindi non risponde ai messaggi." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Chief នេះត្រូវបានបិទ ដូច្នេះវាមិនឆ្លើយតបសារទេ។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Denne Chief er slått av, så den svarer ikke på meldinger." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ten Chief jest wyłączony, więc nie odpowiada na wiadomości." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Este Chief está desativado, então não responde a mensagens." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Этот Chief выключен, поэтому не отвечает на сообщения." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Chief นี้ปิดอยู่ จึงไม่ตอบข้อความ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu Chief kapalı, bu yüzden mesajlara yanıt vermiyor." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Цей Chief вимкнено, тому він не відповідає на повідомлення." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chief này đang tắt nên không trả lời tin nhắn." + } + } + } + }, + "home.engine.effort.off": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactivé" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivado" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끄기" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフ" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Isključeno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fra" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Disattivato" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បិទ" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Av" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyłączone" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Desativado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выкл." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kapalı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вимк." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Tắt" + } + } + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHostedChief/HomeChiefRecord.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHostedChief/HomeChiefRecord.swift new file mode 100644 index 000000000000..d61ae1a4800e --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHostedChief/HomeChiefRecord.swift @@ -0,0 +1,59 @@ +import Foundation + +/// One of the user's Chiefs, as UserDO's `chief.list` reports it (Home's +/// New Chief and archive read these; ``CloudChief`` is the Chief tab's view). +public nonisolated struct HomeChiefRecord: Hashable, Sendable { + public var id: String + public var name: String + public var isDefault: Bool + public var rev: Int + public var mainConversation: String? + /// A paired server runs it (`brain_place`); false: the Cloud service's brain does. + public var placed: Bool + + /// - Parameters: + /// - id: The Chief's agent id. + /// - name: Its display name. + /// - isDefault: It is the user's default Chief. + /// - rev: The record's revision. + /// - mainConversation: Its main conversation, when it has one. + /// - placed: A paired server runs its brain. + public init(id: String, name: String, isDefault: Bool, rev: Int, mainConversation: String?, placed: Bool = false) { + self.id = id + self.name = name + self.isDefault = isDefault + self.rev = rev + self.mainConversation = mainConversation + self.placed = placed + } + + /// The Chiefs of a `chief.list` value that are not archived. + public static func list(from value: Any?) -> [HomeChiefRecord] { + ((value as? [String: Any])?["chiefs"] as? [[String: Any]] ?? []).compactMap(parse) + } + + /// The agent ids of a `chief.list` value's archived Chiefs. + public static func archivedIDs(from value: Any?) -> Set { + let list = (value as? [String: Any])?["chiefs"] as? [[String: Any]] ?? [] + return Set(list.compactMap { $0["archived_at"] is String ? $0["id"] as? String : nil }) + } + + /// One `HomeChief` value; nil when it has no id or is archived. + public static func parse(_ value: Any?) -> HomeChiefRecord? { + guard let object = value as? [String: Any], let id = object["id"] as? String, + (object["archived_at"] as? String) == nil else { return nil } + return HomeChiefRecord(id: id, name: object["display_name"] as? String ?? "", isDefault: object["is_default"] as? Bool ?? false, + rev: (object["rev"] as? NSNumber)?.intValue ?? 0, mainConversation: object["main_conversation"] as? String, + placed: object["brain_place"] is [String: Any]) + } +} + +extension HomeCloudChiefs { + /// The cloud Chief whose main conversation is `conversation`: its agent id and whether a + /// paired server runs it, from the Chief tab's Chiefs, else from `records` (the directory's + /// `chief.list`); nil while neither knows it. + public func placement(of conversation: String, records: [HomeChiefRecord]) -> (id: String, placed: Bool)? { + if let chief = chief(for: conversation) { return (chief.id, chief.brainPlace != nil) } + return records.first { $0.mainConversation == conversation }.map { ($0.id, $0.placed) } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHostedChief/HostedChiefCopy.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHostedChief/HostedChiefCopy.swift index aa225e9d4f2c..9beb88180a60 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHostedChief/HostedChiefCopy.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHostedChief/HostedChiefCopy.swift @@ -23,6 +23,14 @@ final class HostedChiefCopy { /// The status and the decoded JSON object (empty when the body is not one). func send(_ method: String, _ path: String, query: [URLQueryItem] = [], body: [String: Any]? = nil) async throws -> (status: Int, json: [String: Any]) { + // The memory batches are measured in this form (no escaped slashes). + let data = try body.map { try JSONSerialization.data(withJSONObject: $0, options: [.withoutEscapingSlashes]) } + let reply = try await send(method, path, query: query, data: data) + return (reply.status, await Self.decode(reply.body)) + } + + /// The status and the body of one request whose body is already encoded. + func send(_ method: String, _ path: String, query: [URLQueryItem] = [], data: Data?) async throws -> (status: Int, body: Data) { var components = URLComponents(url: base.appendingPathComponent(path), resolvingAgainstBaseURL: false) if !query.isEmpty { components?.queryItems = query } guard let url = components?.url else { throw HostedChiefError.badReply } @@ -31,11 +39,9 @@ final class HostedChiefCopy { request.timeoutInterval = 60 request.setValue("application/json", forHTTPHeaderField: "content-type") request.setValue("Bearer \(try await token())", forHTTPHeaderField: "authorization") - // The memory batches are measured in this form (no escaped slashes). - if let body { request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [.withoutEscapingSlashes]) } - let (data, response) = try await URLSession.shared.data(for: request) - let status = (response as? HTTPURLResponse)?.statusCode ?? 0 - return (status, await Self.decode(data)) + request.httpBody = data + let (reply, response) = try await URLSession.shared.data(for: request) + return ((response as? HTTPURLResponse)?.statusCode ?? 0, reply) } /// The reply's JSON object off the main actor (a history page can be large). diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHostedChief/HostedChiefMigration.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHostedChief/HostedChiefMigration.swift index de527540d986..d469dc753162 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHostedChief/HostedChiefMigration.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHostedChief/HostedChiefMigration.swift @@ -86,6 +86,32 @@ public final class HostedChiefMigration { return HostedChiefCopy.HTTP(base: wiring.apiBase(), token: wiring.token) } + /// The Cloud Chief brain route as the signed-in user, for a hosted Chief's settings card + /// (``HomeChiefHostedEngine``): signed out (or no token) throws its `signedOut`. + public var brainRoute: HomeChiefHostedEngine.Send { + { [weak self] method, path, body in + guard let self, let wiring, wiring.user() != nil, let http else { throw HomeChiefHostedEngine.Failure.signedOut } + let token: String + do { token = try await wiring.token() } catch { throw HomeChiefHostedEngine.Failure.signedOut } + return try await HostedChiefCopy.HTTP(base: http.base, token: { token }).send(method, path, data: body) + } + } + + /// Turns on the brain of a hosted Chief the user just made (New Chief): a new Chief's brain + /// starts off, and one left off never answers. A service with no Chief brains is left alone. + /// Whether it is on; a failure is logged. + @discardableResult + public func enableBrain(of chief: String) async -> Bool { + guard let wiring, let http, await brainsAvailable() == true else { return false } + do { + try await HostedChiefCopy(http: http, ops: wiring.ops, chief: chief, conversation: "").setBrain(enabled: true) + return true + } catch { + logger.error("hosted chief: a new Chief's brain could not be turned on: \(String(describing: error), privacy: .public)") + return false + } + } + /// Whether this service runs Cloud Chief brains, asked once per launch (nil: unknown, asked again on the next connect). public func brainsAvailable() async -> Bool? { if let brains { return brains } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeChiefHostedSettingsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeChiefHostedSettingsTests.swift new file mode 100644 index 000000000000..6472ac575de3 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeChiefHostedSettingsTests.swift @@ -0,0 +1,198 @@ +import AppKit +import CmuxHomeCore +@testable import CmuxNextApp +@testable import CmuxNextHomeEngine +import CmuxNextHostedChief +import Foundation +import Testing + +/// A hosted cloud Chief (no `brain_place`; the Cloud service's brain answers) has settings: the +/// card reads and writes its model and effort through the brain route, never through a paired +/// server. 2026-10-20: a new Chief's card said "The server with this Chief is not connected." +/// and its pickers were empty, since the card looked for the server its brain is placed on. +@MainActor @Suite(.serialized) +struct HomeChiefHostedSettingsTests { + static let agent = "agent_01JZ0000000000000000000000" + static let main = "conv_dm_01JZ0000000000000000000000" + + // MARK: Which brain the card talks to + + @Test func aHostedCloudChiefUsesTheCloudBrainRoute() async { + let services = Self.services() + services.home.setCloudChiefs(HomeCloudChiefs(hosted: CloudChief(id: Self.agent, displayName: "Chief", isDefault: true, + mainConversation: Self.main, rev: 1))) + let control = HomeChiefControl(services: services, conversation: ConversationID(Self.main), muxHome: Self.home()) + #expect(control.isHosted) + #expect(control.hostedChiefID == Self.agent) + #expect(control.engineSource() is HomeChiefHostedEngine) + // No server to ask: Stop says the cloud has no stop yet, not that a server is missing. + #expect(await control.stop() == .cloudStopUnsupported) + } + + @Test func aChiefPlacedOnAServerStaysOnThatServersDaemon() { + let services = Self.services() + let placed = CloudChief(id: Self.agent, displayName: "Chief (Mac mini)", isDefault: false, mainConversation: Self.main, rev: 1, + brainPlace: .init(host: "host_mini", install: "install_1")) + services.home.setCloudChiefs(HomeCloudChiefs(placed: placed)) + let control = HomeChiefControl(services: services, conversation: ConversationID(Self.main), muxHome: Self.home()) + #expect(!control.isHosted) + #expect(control.hostedChiefID == nil) + #expect(control.engineSource() is HomeChiefRemoteEngine) + } + + /// A new Chief is in the directory's `chief.list` before the Chief tab lists it: its + /// `brain_place` there still decides hosted or placed. + @Test func theDirectoryListDecidesWhileTheChiefTabDoesNotKnowTheChief() { + let list: [String: Any] = ["chiefs": [ + ["id": Self.agent, "display_name": "New", "rev": 1, "main_conversation": Self.main], + ["id": "agent_placed", "display_name": "Mini", "rev": 2, "main_conversation": "conv_placed", + "brain_place": ["host": "host_mini", "install": "install_1"]], + ["id": "agent_old", "rev": 3, "main_conversation": "conv_old", "archived_at": "2026-10-01T00:00:00Z"], + ]] + let records = HomeChiefRecord.list(from: list) + #expect(records.map(\.id) == [Self.agent, "agent_placed"]) + #expect(HomeChiefRecord.archivedIDs(from: list) == ["agent_old"]) + let none = HomeCloudChiefs() + #expect(none.placement(of: Self.main, records: records).map { [$0.id, "\($0.placed)"] } == [Self.agent, "false"]) + #expect(none.placement(of: "conv_placed", records: records)?.placed == true) + #expect(none.placement(of: "conv_unknown", records: records) == nil) + } + + @Test func thisMacsChiefStaysLocal() { + let services = Self.services() + let control = HomeChiefControl(services: services, conversation: ConversationID("conv_local"), muxHome: Self.home()) + #expect(!control.isHosted) + #expect(control.engineSource() is HomeChiefLocalEngine) + } + + // MARK: The card over the brain route + + @Test func theCardShowsTheCloudBrainsModelAndEffort() async throws { + let cloud = FakeBrainRoute(config: ["enabled": true, "model": "claude-sonnet-5-5", "effort": "medium"]) + let view = Self.card(cloud) + await view.use(Self.engine(cloud)).value + #expect(view.failure.isHidden) + #expect(view.place.stringValue == HomeEngineStrings.runsInCloud) + // One engine of its own: no harness to pick. + #expect(view.harness.superview?.isHidden == true) + let models = view.model.itemArray.compactMap { $0.representedObject as? String } + #expect(models == ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5"]) + #expect(view.model.selectedItem?.representedObject as? String == "claude-sonnet-5-5") + let efforts = view.effort.itemArray.compactMap { $0.representedObject as? String } + #expect(efforts == ["off", "minimal", "low", "medium", "high", "xhigh", "max"]) + #expect(view.effort.selectedItem?.representedObject as? String == "medium") + #expect(view.model.isEnabled && view.effort.isEnabled) + #expect(cloud.requests.first?.method == "GET") + #expect(cloud.requests.first?.path == "v1/chiefs/\(Self.agent)/brain") + } + + @Test func aPickPutsTheModelOnTheBrainRoute() async throws { + let cloud = FakeBrainRoute(config: ["enabled": true, "model": "claude-opus-5-5", "effort": "low"]) + let view = Self.card(cloud) + await view.use(Self.engine(cloud)).value + await view.pick("model", "claude-haiku-5-5").value + let put = try #require(cloud.requests.last { $0.method == "PUT" }) + #expect(put.method == "PUT") + #expect(put.path == "v1/chiefs/\(Self.agent)/brain") + #expect(put.body == ["model": "claude-haiku-5-5"]) + #expect(view.model.selectedItem?.representedObject as? String == "claude-haiku-5-5") + #expect(view.failure.isHidden) + // Default sets the brain's own default: the route never clears a field. + await view.pick("effort", nil).value + #expect(cloud.requests.last { $0.method == "PUT" }?.body == ["effort": "low"]) + } + + @Test func aRefusedModelSaysWhyAndKeepsWhatTheBrainRuns() async { + let cloud = FakeBrainRoute(config: ["enabled": true, "model": "claude-opus-5-5", "effort": "low"]) + cloud.refusal = (409, "chief_claude_unavailable", "claude-sonnet-5-5 is not available for this account here") + let view = Self.card(cloud) + await view.use(Self.engine(cloud)).value + await view.pick("model", "claude-sonnet-5-5").value + #expect(!view.failure.isHidden) + #expect(view.failure.stringValue.contains("not available for this account")) + #expect(view.model.selectedItem?.representedObject as? String == "claude-opus-5-5") + } + + @Test func signedOutAndOfflineSayTheCloudNotAServer() async { + let signedOut = FakeBrainRoute(config: [:]) + signedOut.failure = HomeChiefHostedEngine.Failure.signedOut + let view = Self.card(signedOut) + await view.use(Self.engine(signedOut)).value + #expect(view.failure.stringValue == HomeEngineStrings.errorSignedOut) + #expect(!view.model.isEnabled) + + let offline = FakeBrainRoute(config: [:]) + offline.failure = URLError(.notConnectedToInternet) + let offlineView = Self.card(offline) + await offlineView.use(Self.engine(offline)).value + #expect(offlineView.failure.stringValue == HomeEngineStrings.errorCloudUnreachable) + #expect(offlineView.failure.stringValue != HomeEngineStrings.errorUnreachable) + } + + @Test func aBrainThatIsOffSaysItDoesNotAnswer() async { + let cloud = FakeBrainRoute(config: ["enabled": false, "model": "claude-opus-5-5", "effort": "low"]) + let view = Self.card(cloud) + await view.use(Self.engine(cloud)).value + #expect(!view.failure.isHidden) + #expect(view.failure.stringValue == HomeEngineStrings.hostedBrainOff) + // Its settings still show and can be changed. + #expect(view.model.isEnabled) + } + + // MARK: Helpers + + static func services() -> AppServices { + _ = NSApplication.shared + return AppServices(environment: AppEnvironment.current([:])) + } + + static func home() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("hosted-chief-\(UUID().uuidString)", isDirectory: true) + } + + static func engine(_ cloud: FakeBrainRoute) -> HomeChiefHostedEngine { + HomeChiefHostedEngine(chief: { agent }, send: { method, path, body in try await cloud.answer(method, path, body) }, + files: HomeChiefFiles(muxHome: home())) + } + + /// A card that starts on a source that never answers, as the app's does before `use`. + static func card(_ cloud: FakeBrainRoute) -> HomeChiefSettingsView { + HomeChiefSettingsView(muxHome: home(), source: engine(cloud)) + } +} + +/// The Cloud service's brain route as chief-brain/routes.ts answers it: GET is +/// `{ok, value: {config, turns_today}}`, PUT applies the patch and answers `{ok, value: config}`. +@MainActor +final class FakeBrainRoute { + struct Request { + var method: String + var path: String + var body: [String: String]? + } + + var config: [String: Any] + var requests: [Request] = [] + /// Every PUT is refused with this status, code and message. + var refusal: (status: Int, code: String, message: String)? + /// Every request throws this before it is sent. + var failure: (any Error)? + + init(config: [String: Any]) { + self.config = config + } + + func answer(_ method: String, _ path: String, _ body: Data?) async throws -> (status: Int, body: Data) { + if let failure { throw failure } + let patch = body.flatMap { (try? JSONSerialization.jsonObject(with: $0)) as? [String: String] } + requests.append(Request(method: method, path: path, body: patch)) + if method == "PUT" { + if let refusal { + return (refusal.status, try JSONSerialization.data(withJSONObject: ["ok": false, "error": ["code": refusal.code, "message": refusal.message]])) + } + for (key, value) in patch ?? [:] { config[key] = value } + return (200, try JSONSerialization.data(withJSONObject: ["ok": true, "value": config])) + } + return (200, try JSONSerialization.data(withJSONObject: ["ok": true, "value": ["config": config, "turns_today": 0]])) + } +}