Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3c006d3
cnx-ios D2 test: hostd's launch names the app's own Chief owner (red:…
azooz2003-bit Oct 20, 2026
82f3f0f
cmux-next Mac: hand cnx-hostd the app's own Chief owner (session and …
azooz2003-bit Oct 20, 2026
7824a69
cnx-ios D2/D3 test: an unavailable Chief shows why with Try Again and…
azooz2003-bit Oct 20, 2026
f56f8aa
cnx-ios D1 test: the Worker creates teams for the caller (red: no POS…
azooz2003-bit Oct 20, 2026
c296e4c
cnx-ios D1: POST /v1/teams creates the team with the project's server…
azooz2003-bit Oct 20, 2026
4f0c4de
cnx-ios D1: the phone creates teams through the cmux service, then re…
azooz2003-bit Oct 20, 2026
603faab
cnx-ios D2/D3: an unavailable Chief says why with Try Again; a messag…
azooz2003-bit Oct 20, 2026
76c4841
cnx-ios D1: team creation request and answer in CnxModel (CnxAuth is …
azooz2003-bit Oct 20, 2026
3bab0e0
cnx-ios D9 test: hostd's file service shares the Mac's workspace fold…
azooz2003-bit Oct 20, 2026
892b74e
cnx-hostd D9: file previews may read the Mac's workspace folders (age…
azooz2003-bit Oct 20, 2026
5bce946
cnx-ios D9: preview hint names workspace folders; SECURITY.md file roots
azooz2003-bit Oct 20, 2026
c3e0cf8
acpmux: a catalog start model the harness does not offer is not used …
azooz2003-bit Oct 20, 2026
3d1e5bb
cnx-ios D4 tests: Default is the Mac's own pick, unlisted defaults un…
azooz2003-bit Oct 20, 2026
e48fd1f
cnx-ios D4: Default is the Mac's own pick; a model the account lacks …
azooz2003-bit Oct 20, 2026
c859cf2
cnx-ios D5, D8: no autocorrect in name fields and the agent composer;…
azooz2003-bit Oct 20, 2026
71bdf45
cnx-ios D7, D11, D6, D12: placeholders never cover the Mac lines (Con…
azooz2003-bit Oct 20, 2026
8c4c737
cnx-ios D12, D13, D14: Codex's bare "finished" says where to look; Ho…
azooz2003-bit Oct 20, 2026
4a31def
cnx-ios D10: short inline Feed rows draw as native text, not a web vi…
azooz2003-bit Oct 20, 2026
903b7ac
acpmux review fix: skip only a catalog start model, and only when the…
azooz2003-bit Oct 20, 2026
e96061c
cnx-hostd review fix: no workspace folder without a resolvable HOME; …
azooz2003-bit Oct 20, 2026
186b62f
cnx-ios review fixes: Try Again always ends and never opens Chief twi…
azooz2003-bit Oct 20, 2026
5beee45
cnx-ios verify: fixes-r1 live results over the real link (4a31def5f5a)
azooz2003-bit Oct 20, 2026
ffbcd54
cnx-ios live recheck fixes: Chief rebuilds on the new store after Try…
azooz2003-bit Oct 20, 2026
d42a116
CmuxTabsKit: sync d785980a15c (Close All Tabs asks first)
azooz2003-bit Oct 20, 2026
49b9508
cnx-ios test: a returned prompt keeps its id on resend; a draft the M…
azooz2003-bit Oct 20, 2026
1b4e275
cnx-ios: a prompt keeps one acpmux id across a resend; a returned dra…
azooz2003-bit Oct 20, 2026
1f521e8
cnx-ios D4 test: a chat's models, modes and options come from _acpmux…
azooz2003-bit Oct 20, 2026
a553ef2
cnx-ios D4: a chat's models, modes and options come from _acpmux/atta…
azooz2003-bit Oct 20, 2026
5180efa
cnx-ios review fixes: no returned draft for a prompt seen running; at…
azooz2003-bit Oct 20, 2026
d79ea22
CmuxTabsKit: sync 087c69c04b0 (Close All closes the tabs it asked about)
azooz2003-bit Oct 20, 2026
e9e9bf0
cnx-ios verify: fixes-r1 second live pass (Mac a553ef2b, phone d79ea22)
azooz2003-bit Oct 20, 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
@@ -0,0 +1,11 @@
/// What Close All Tabs asks before it closes every tab (as Safari's grid does).
struct CloseAllConfirmation: Equatable {
/// The tabs shown when the person asked: exactly these close on confirm.
var ids: [String]

var count: Int { ids.count }
/// The destructive button.
var confirmTitle: String { count == 1 ? "Close 1 Tab" : "Close All \(count) Tabs" }
/// The line under the question.
var message: String { "This can’t be undone." }
}
17 changes: 14 additions & 3 deletions Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/UI/TabsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ struct TabsScreen<Source: TabsDataSource>: View {
let keyboard: CGFloat

@State private var chrome = ChromeState()
/// Close All Tabs is waiting for the person's answer.
@State private var confirmingCloseAll: CloseAllConfirmation?
/// The page <-> grid transition (one interruptible progress value).
@State private var zoomDriver = TabZoomDriver()
/// Swipe-to-close offsets of the grid's cards.
Expand Down Expand Up @@ -154,14 +156,24 @@ struct TabsScreen<Source: TabsDataSource>: View {
onClose: { id in await closeFromGrid(id) },
onNewTab: newTabFromOverview,
onDone: { closeOverview(selecting: activeID) },
onCloseAll: closeAll,
onCloseAll: { confirmingCloseAll = CloseAllConfirmation(ids: tabs.map(\.id)) },
pending: newTabSlot?.showsPlaceholder(in: tabs.map(\.id)) == true ? pendingNewTab : nil,
onScrolling: { source.overviewScrollingChanged($0) },
onQueueSelect: { queuedOpen = $0 },
onPressChanged: { pressedCard = $0 })
.opacity(overviewVisible ? 1 : 0)
.allowsHitTesting(overviewVisible)
.accessibilityHidden(!zoomState.gridInteractive)
// Close All Tabs asks first, as Safari does: every tab closes with no way back.
.confirmationDialog(confirmingCloseAll?.confirmTitle ?? "", isPresented: Binding(
get: { confirmingCloseAll != nil }, set: { if !$0 { confirmingCloseAll = nil } }
), titleVisibility: .hidden, presenting: confirmingCloseAll) { confirmation in
Button(confirmation.confirmTitle, role: .destructive) { closeAll(confirmation.ids) }
.accessibilityIdentifier("tabs.closeAll.confirm")
Button("Cancel", role: .cancel) {}
} message: { confirmation in
Text(confirmation.message)
}

if chrome.swiping {
swipeNeighbor
Expand Down Expand Up @@ -615,8 +627,7 @@ struct TabsScreen<Source: TabsDataSource>: View {
zoomDriver.openNewTab(spring: newTabZoom.spring)
}

private func closeAll() {
let ids = tabs.map(\.id)
private func closeAll(_ ids: [String]) {
Task { for id in ids { _ = await source.close(id) } }
if let option = source.newTabOptions.first { newTabFromOverview(option) }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@testable import CmuxTabsKit
import Testing

/// Close All Tabs asks first, as Safari does: one tap on the grid's More menu
/// closed every tab of a workspace with no way back (cnx-ios live recheck, 2026-10-20).
struct CloseAllConfirmationTests {
@Test func namesHowManyTabsClose() {
#expect(CloseAllConfirmation(ids: ["a", "b", "c", "d", "e"]).confirmTitle == "Close All 5 Tabs")
#expect(CloseAllConfirmation(ids: ["a"]).confirmTitle == "Close 1 Tab")
#expect(CloseAllConfirmation(ids: ["a", "b", "c", "d", "e"]).message == "This can’t be undone.")
}

/// The tabs asked about are the ones closed, even if the host lists another meanwhile.
@Test func closesTheTabsItAskedAbout() {
#expect(CloseAllConfirmation(ids: ["a", "b"]).ids == ["a", "b"])
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import CmuxNextActions
import CmuxNextCompat
import CmuxNextDaemon
import CmuxNextHomeEngine
import CmuxNextMobileHost
import Observation

Expand Down Expand Up @@ -42,6 +43,11 @@ extension AppServices {
newChatPicks: { [weak self] in
NewChatPicks(lastAgent: self?.newTabChoices.agent, picks: self?.newTabChoices.picks)
},
// hostd serves phones this build's own Chief (a DEV or tagged build's is not the default home).
chief: { [weak self] in
guard let owner = self?.home.chief else { return nil }
return IPhoneHostService.ChiefPlacement(session: owner.home.session, socket: await owner.socketPath())
},
settings: { [weak self] in self?.settings },
bundleID: environment.launch.bundleID, computerName: { await MacName.computerName() })
iphone.pairWindow = { [weak self] in self?.windows.active?.window }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,17 @@ public struct CnxHostLaunch: Sendable, Equatable {
/// acpmux and shows here as this Mac's own. Nil: phones cannot create agent tabs.
public var agentTabHost: String?
/// The Chief's conversation owner session (`ChiefHome.session`), served
/// to phones by hostd's scoped `chief` service; nil lets hostd derive it
/// from `~/.cmux/chief/default`, the home of every build that is not an
/// isolated launch. The app's wiring sets it with
/// `ChiefHome.resolve(tag:).session` (cnx-ios/verify/relay-security.md).
/// to phones by hostd's scoped `chief` service. The app always sets it from
/// its own Chief home (`IPhoneHostService.Dependencies.chief`): a DEV or
/// tagged build's Chief lives at `~/.cmux/chief/<bundle id>`, not the
/// `~/.cmux/chief/default` hostd derives when it is nil (only a hostd
/// started by hand relies on that).
public var chiefSession: String?
/// The Chief owner's socket once the app is connected to it
/// (`ChiefConversationOwner.socketPath()`): hostd dials it as is
/// (`CNX_CHIEF_SOCKET`), so a socket outside the app daemon's folder
/// (cmux-tui's short-path fallback) is still found.
public var chiefSocket: String?
/// This build streams the phone's browser tabs (a bundled remote browser host):
/// hostd serves `browser` and says `browser-stream-v1` (`CNX_BROWSER_HOSTS=1`).
/// Off, phones hide New Browser Tab for this Mac.
Expand Down Expand Up @@ -87,7 +93,8 @@ public struct CnxHostLaunch: Sendable, Equatable {
"NO_COLOR": "1",
]) { $1 }
if let agentTabHost, !agentTabHost.isEmpty { environment["CNX_AGENT_TAB_HOST"] = agentTabHost }
if let chiefSession { environment["CNX_CHIEF_SESSION"] = chiefSession }
if let chiefSession, !chiefSession.isEmpty { environment["CNX_CHIEF_SESSION"] = chiefSession }
if let chiefSocket, !chiefSocket.isEmpty { environment["CNX_CHIEF_SOCKET"] = chiefSocket }
if browserHosts { environment["CNX_BROWSER_HOSTS"] = "1" }
return environment
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ extension IPhoneHostService {
/// - computerName: How phones list this Mac.
/// - processEnvironment: Read for the Debug-only overrides.
/// - acpmuxEnvironment: The app's acpmux, for the phones' approval keys (nil: none).
/// - agentTabHost: This Mac's agent tab host (`install:<id>`), for agent tabs a phone creates.
/// - chief: This app's Chief owner (session, and socket once connected), which hostd serves to phones.
public func wire(auth: CloudAuth, daemon: DaemonService,
acpmuxHost: @escaping @MainActor @Sendable () -> (any AgentPaneHostProviding)?,
acpmuxEnvironment: AcpmuxEnvironment? = nil,
agentTabHost: @escaping @MainActor () -> String? = { nil },
newChatPicks: @escaping @MainActor @Sendable () -> NewChatPicks = { NewChatPicks() },
chief: @escaping @MainActor () async -> ChiefPlacement? = { nil },
settings: @escaping @MainActor @Sendable () -> SettingsController?,
bundleID: String?, computerName: @escaping @Sendable () async -> String,
processEnvironment: [String: String] = ProcessInfo.processInfo.environment) {
Expand Down Expand Up @@ -106,7 +107,8 @@ extension IPhoneHostService {
computerName: computerName,
acpmuxEnvironment: acpmuxEnvironment,
browserHosts: browserHosts,
pairingSeed: IPhonePairingSeed.fromEnvironment(processEnvironment)
pairingSeed: IPhonePairingSeed.fromEnvironment(processEnvironment),
chief: chief
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,37 @@ public final class IPhoneHostService {
var browserHosts: (any CnxBrowserHosting)? = nil
/// Test-only (DEBUG): phones this tagged test instance trusts without the prompt.
var pairingSeed: IPhonePairingSeed? = nil
/// This app's own Chief owner: its session (from the app's `ChiefHome`, always known)
/// and its socket once connected. hostd's `chief` service dials it; without it hostd
/// guesses `~/.cmux/chief/default`, which is not a DEV or tagged build's Chief.
var chief: @MainActor () async -> ChiefPlacement? = { nil }
}

/// Where this app's Chief owner listens (``Dependencies/chief``).
public struct ChiefPlacement: Equatable, Sendable {
/// `ChiefHome.session` of the app's Chief home.
public var session: String
/// The owner's socket, once the app is connected to it.
public var socket: String?

public init(session: String, socket: String?) {
self.session = session
self.socket = socket
}
}

/// hostd's launch for `account`: the app's daemon, chat roots, agent tab host and its own Chief.
static func launch(_ dependencies: Dependencies, executable: URL, account: Account, deviceID: String,
deviceName: String, daemonSocket: String, chief: ChiefPlacement?) -> CnxHostLaunch {
var launch = CnxHostLaunch(
executable: executable, baseURL: dependencies.baseURL, teamID: account.teamID, userID: account.userID,
deviceID: deviceID, deviceName: deviceName, daemonSocket: daemonSocket,
stateDirectory: dependencies.stateDirectory, fileRoots: dependencies.fileRoots(),
stackProjectID: dependencies.stackProjectID, agentTabHost: dependencies.agentTabHost())
launch.browserHosts = dependencies.browserHosts != nil
launch.chiefSession = chief?.session
launch.chiefSocket = chief?.socket
return launch
}

/// `mobile.allowIPhoneConnections`, as the settings file has it.
Expand Down Expand Up @@ -351,13 +382,10 @@ public final class IPhoneHostService {
// task-owner: one Keychain read per hostd start; awaited here
let identity = try await Task.detached { try store.loadOrCreate() }.value
let name = await dependencies.computerName()
let chief = await dependencies.chief()
guard !Task.isCancelled, self?.running == key else { return }
var launch = CnxHostLaunch(
executable: executable, baseURL: dependencies.baseURL, teamID: want.teamID, userID: want.userID,
deviceID: deviceID, deviceName: name, daemonSocket: socket,
stateDirectory: dependencies.stateDirectory, fileRoots: dependencies.fileRoots(),
stackProjectID: dependencies.stackProjectID, agentTabHost: dependencies.agentTabHost())
launch.browserHosts = dependencies.browserHosts != nil
let launch = Self.launch(dependencies, executable: executable, account: want, deviceID: deviceID,
deviceName: name, daemonSocket: socket, chief: chief)
await supervisor.start(.init(
launch: launch, accessToken: dependencies.accessToken, acpmux: dependencies.acpmux,
log: dependencies.stateDirectory.appendingPathComponent("cnx-hostd.log", isDirectory: false),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Foundation
@testable import CmuxNextMobileHost
import Testing

/// hostd serves phones the app's own Chief (cnx-ios full verify D2): a DEV or tagged build keeps its
/// Chief at `~/.cmux/chief/<bundle id>`, so the launch must name that owner instead of letting hostd
/// guess `~/.cmux/chief/default`.
@MainActor
struct IPhoneHostChiefTests {
static func dependencies(chief: IPhoneHostService.ChiefPlacement?) -> IPhoneHostService.Dependencies {
IPhoneHostService.Dependencies(
executable: URL(fileURLWithPath: "/bin/cnx-hostd"), baseURL: URL(fileURLWithPath: "/dev/null"),
stackProjectID: "p", stateDirectory: URL(fileURLWithPath: "/tmp/cnx-state"),
accessToken: { _ in "t" }, daemonSocket: { "/tmp/cmux-tui-501/cmux-app-tag.sock" }, acpmux: { nil },
fileRoots: { ["/a"] }, computerName: { "Mac" }, chief: { chief })
}

static let account = IPhoneHostService.Account(userID: "u", teamID: "personal-u", teamName: nil, email: nil)

@Test func theLaunchNamesTheAppsOwnChief() async throws {
let placement = IPhoneHostService.ChiefPlacement(session: "cmux-chief-1a2b3c4d",
socket: "/tmp/cmux-tui-501/cmux-chief-1a2b3c4d.sock")
let dependencies = Self.dependencies(chief: placement)
let launch = IPhoneHostService.launch(dependencies, executable: URL(fileURLWithPath: "/bin/cnx-hostd"),
account: Self.account, deviceID: "d", deviceName: "Mac",
daemonSocket: "/tmp/cmux-tui-501/cmux-app-tag.sock",
chief: await dependencies.chief())
let environment = launch.environment(inherited: [:])
#expect(environment["CNX_CHIEF_SESSION"] == "cmux-chief-1a2b3c4d")
#expect(environment["CNX_CHIEF_SOCKET"] == "/tmp/cmux-tui-501/cmux-chief-1a2b3c4d.sock")
#expect(environment["CNX_TEAM_ID"] == "personal-u")
}

@Test func aChiefNotConnectedYetStillNamesItsSession() async throws {
let dependencies = Self.dependencies(chief: .init(session: "cmux-chief-1a2b3c4d", socket: nil))
let launch = IPhoneHostService.launch(dependencies, executable: URL(fileURLWithPath: "/bin/cnx-hostd"),
account: Self.account, deviceID: "d", deviceName: "Mac",
daemonSocket: "/tmp/cmux-tui-501/cmux-app-tag.sock",
chief: await dependencies.chief())
let environment = launch.environment(inherited: [:])
#expect(environment["CNX_CHIEF_SESSION"] == "cmux-chief-1a2b3c4d")
#expect(environment["CNX_CHIEF_SOCKET"] == nil)
}
}
10 changes: 9 additions & 1 deletion cmux-tui/crates/acpmux/src/hub/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ impl Hub {
} else {
(None, None)
};
// A catalog default (not a saved pick) is skipped when the harness doesn't offer it.
let model_from_catalog = !model_pinned
&& start_model.as_ref().is_some_and(|s| s.source == super::start_defaults::StartSource::Catalog);
let (model, model_soft) = match (model_pinned, start_model) {
(true, _) | (false, None) => (pinned_model, false),
(false, Some(start)) => (Some(start.value), true),
Expand Down Expand Up @@ -214,7 +217,12 @@ impl Hub {
let catalog = self.catalog_ids(agent).await;
let full = format!("{head}/{m}");
let m = if !catalog.is_empty() && !catalog.iter().any(|id| id == m) && catalog.contains(&full) { full } else { m.clone() };
if let Err(e) = self.set_model(&session, &m).await {
// A start value this session's harness does not list (its account
// lacks it): the harness accepts set_model, then fails every prompt.
if model_from_catalog && !self.harness_offers(agent, &profile, &m) {
tracing::warn!(harness = agent, model = %m, "start model skipped: not offered by the harness");
self.append(&session, "mux", "start_default_skipped", json!({"modelId": m, "error": "the harness does not offer this model"}));
} else if let Err(e) = self.set_model(&session, &m).await {
// A start value the harness refuses (start_defaults.rs): the
// chat runs the harness's own default instead.
if model_soft {
Expand Down
51 changes: 51 additions & 0 deletions cmux-tui/crates/acpmux/src/hub/start_defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@ impl Hub {
model: Option<&str>,
) -> (Option<StartValue>, Option<StartValue>) {
let curated = super::models_view::curated_for(&self.catalog, agent, profile);
// The catalog default only when the harness offers it: an account that
// lacks it (Codex refusing GPT-6.1 Sol at the first prompt) starts on the
// harness's own default, its config's model, instead (cnx-ios full verify D4).
let reported = self.reported_model_ids(agent);
let start_model = match model {
Some(_) => None,
None => saved
Expand All @@ -233,6 +237,10 @@ impl Hub {
curated
.as_ref()
.and_then(|c| c.default_model.clone())
.filter(|m| {
let ids: Vec<String> = c_ids(curated.as_ref());
offers(&reported, &ids, m)
})
.map(|value| StartValue { value, source: StartSource::Catalog })
}),
};
Expand All @@ -257,6 +265,23 @@ impl Hub {
(start_model, start_effort)
}

/// The model ids `agent` itself reported (its last session or probe);
/// empty when it reported none (Claude Code, a harness not probed yet).
pub(super) fn reported_model_ids(&self, agent: &str) -> Vec<String> {
self.known_models
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(agent)
.map(|list| list.iter().map(|(id, _)| id.clone()).collect())
.unwrap_or_default()
}

/// Whether the catalog start model `model` is one `agent`'s harness offers ([`offers`]).
pub(super) fn harness_offers(&self, agent: &str, profile: &HarnessProfile, model: &str) -> bool {
let curated = super::models_view::curated_for(&self.catalog, agent, profile);
offers(&self.reported_model_ids(agent), &c_ids(curated.as_ref()), model)
}

/// `start` for one `_acpmux/harnesses` row: `{model?, effort?,
/// modelSource?, effortSource?}`, what a new chat there starts on.
pub(super) fn start_view(
Expand Down Expand Up @@ -294,3 +319,29 @@ impl Hub {
Some(v)
}
}

/// The curated model ids of a catalog entry.
fn c_ids(curated: Option<&CatalogHarness>) -> Vec<String> {
curated.map(|c| c.models.iter().map(|m| m.id.clone()).collect()).unwrap_or_default()
}

/// Whether reported id `id` names `model`: the same id, or a DSH route
/// (`["provider","model"]`) for `model` or `provider/model`, as
/// `model_catalog::resolve` accepts them.
fn names(id: &str, model: &str) -> bool {
id == model
|| serde_json::from_str::<Vec<String>>(id)
.is_ok_and(|p| p.len() == 2 && (p[1] == model || format!("{}/{}", p[0], p[1]) == model))
}

/// Whether a harness that reported `reported` offers the catalog's `model`.
/// Any model when it reported none, or when its ids are not the catalog's
/// (Claude Code's aliases `default`/`opus`/`sonnet`): then the harness decides,
/// as before. Otherwise only a model it lists, so an account that lacks the
/// catalog default (Codex without GPT-6.1 Sol) starts on its own default.
pub(super) fn offers(reported: &[String], curated: &[String], model: &str) -> bool {
if reported.is_empty() || !reported.iter().any(|r| curated.iter().any(|c| names(r, c))) {
return true;
}
reported.iter().any(|r| names(r, model))
}
Loading