Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
eab8a97
cmux NEXT: a hosted cloud Chief's card reads and sets its model and e…
austinywang Oct 20, 2026
58d0c31
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 20, 2026
d68e9cc
HomeChiefEngineSource: the default choosesHarness is nonisolated, as …
austinywang Oct 20, 2026
72ed216
Merge remote-tracking branch 'origin/feat-cmux-next-chief-hosted-sett…
austinywang Oct 20, 2026
c8bc47e
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 20, 2026
10efd15
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
austinywang Oct 20, 2026
2e05d42
Merge remote-tracking branch 'origin/feat-cmux-next-chief-hosted-sett…
austinywang Oct 20, 2026
271884e
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 20, 2026
bd09054
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 20, 2026
d90543a
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 20, 2026
a26ec45
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 20, 2026
a4fcdd2
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 20, 2026
767bcb8
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 20, 2026
cb773ce
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 21, 2026
ca94858
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 21, 2026
f73430a
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 21, 2026
778187c
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 21, 2026
876fde1
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 21, 2026
aaeb281
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 21, 2026
590fd30
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 21, 2026
f88853d
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 21, 2026
bc53fe8
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 21, 2026
bfb7d2e
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 21, 2026
507b129
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 21, 2026
9748926
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 21, 2026
5a3bef6
Merge branch 'feat-cmux-next' into feat-cmux-next-chief-hosted-settings
teamleaderleo Oct 21, 2026
6f0f3f5
refactor(home): Chief records and their placement live in CmuxNextHos…
teamleaderleo Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ import CmuxHomeCore
import CmuxNextActions
import CmuxNextDaemon
import CmuxNextHomeEngine
import CmuxNextHostedChief
import CmuxNextRemote
import Foundation
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
Expand All @@ -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 }
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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.*`).
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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<String> {
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
Expand Down Expand Up @@ -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).
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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/<agent>/brain` (`{config, turns_today}`) and `PUT v1/chiefs/<agent>/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
}
}
Loading