From 3c006d3e4ed2c41594edcef63da846db7c6de0ab Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:22:42 -0700 Subject: [PATCH 01/31] cnx-ios D2 test: hostd's launch names the app's own Chief owner (red: no Chief placement in the launch) Co-Authored-By: Claude Opus 5.5 --- .../IPhoneHostChiefTests.swift | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextMobileHostTests/IPhoneHostChiefTests.swift diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextMobileHostTests/IPhoneHostChiefTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextMobileHostTests/IPhoneHostChiefTests.swift new file mode 100644 index 000000000000..afa26b676cb4 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextMobileHostTests/IPhoneHostChiefTests.swift @@ -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/`, 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) + } +} From 82f3f0f524b13627c5f718386d52bcf6e38aba37 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:22:43 -0700 Subject: [PATCH 02/31] cmux-next Mac: hand cnx-hostd the app's own Chief owner (session and socket) A DEV or tagged build keeps its Chief at ~/.cmux/chief/, but the app never set CnxHostLaunch.chiefSession, so hostd dialed the default home's owner and refused every phone chief channel. The wiring now passes the app's ChiefConversationOwner session (always) and its socket once connected (CNX_CHIEF_SOCKET), for tagged and Release builds alike. Co-Authored-By: Claude Opus 5.5 --- .../CmuxNextApp/Cloud/AppServices+Cloud.swift | 6 +++ .../CnxHost/CnxHostLaunch.swift | 17 +++++--- .../IPhoneHostService+Wiring.swift | 6 ++- .../IPhoneHostService.swift | 40 ++++++++++++++++--- 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/AppServices+Cloud.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/AppServices+Cloud.swift index e2e1b8df7c2e..2683044f94ee 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/AppServices+Cloud.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/AppServices+Cloud.swift @@ -1,6 +1,7 @@ import CmuxNextActions import CmuxNextCompat import CmuxNextDaemon +import CmuxNextHomeEngine import CmuxNextMobileHost import Observation @@ -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 } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextMobile/CnxHost/CnxHostLaunch.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextMobile/CnxHost/CnxHostLaunch.swift index 39c231d166af..395abc17b779 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextMobile/CnxHost/CnxHostLaunch.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextMobile/CnxHost/CnxHostLaunch.swift @@ -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/`, 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. @@ -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 } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextMobileHost/IPhoneHostService+Wiring.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextMobileHost/IPhoneHostService+Wiring.swift index fbeb801edcca..ad95b59798ff 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextMobileHost/IPhoneHostService+Wiring.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextMobileHost/IPhoneHostService+Wiring.swift @@ -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:`), 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) { @@ -106,7 +107,8 @@ extension IPhoneHostService { computerName: computerName, acpmuxEnvironment: acpmuxEnvironment, browserHosts: browserHosts, - pairingSeed: IPhonePairingSeed.fromEnvironment(processEnvironment) + pairingSeed: IPhonePairingSeed.fromEnvironment(processEnvironment), + chief: chief ) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextMobileHost/IPhoneHostService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextMobileHost/IPhoneHostService.swift index f51e1f78f511..22ae07d4b210 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextMobileHost/IPhoneHostService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextMobileHost/IPhoneHostService.swift @@ -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. @@ -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), From 7824a690366e10fc28c7f1a34f58e85feea95bee Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:23:48 -0700 Subject: [PATCH 03/31] cnx-ios D2/D3 test: an unavailable Chief shows why with Try Again and keeps the message (red) Co-Authored-By: Claude Opus 5.5 --- .../CnxChiefTests/ChiefUnavailableTests.swift | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 cnx-ios/CnxKit/Tests/CnxChiefTests/ChiefUnavailableTests.swift diff --git a/cnx-ios/CnxKit/Tests/CnxChiefTests/ChiefUnavailableTests.swift b/cnx-ios/CnxKit/Tests/CnxChiefTests/ChiefUnavailableTests.swift new file mode 100644 index 000000000000..f9b70062156a --- /dev/null +++ b/cnx-ios/CnxKit/Tests/CnxChiefTests/ChiefUnavailableTests.swift @@ -0,0 +1,54 @@ +import CmuxConversationCore +import Foundation +import Testing +@testable import CnxChief + +/// Full verify D2/D3 (2026-10-19): with the Mac's Chief refused, the +/// conversation spun forever and a sent message vanished. Chief says why it +/// is unavailable, offers Try Again, and keeps what the user wrote. +@MainActor +@Suite struct ChiefUnavailableTests { + @Test func aRefusedChiefShowsWhyAndTryAgain() { + let session = ChiefSession(backend: ChiefOfflineBackend(), outbox: ChiefOutbox(file: nil), clock: ImmediateClock()) + // Before the link says anything, nothing is shown (the link may still come up). + #expect(session.barItem == nil) + var retries = 0 + session.reconnect = { retries += 1 } + session.status = "Chief isn't running on Studio." + guard case .unavailable(let reason, let retrying)? = session.barItem else { + Issue.record("expected the unavailable bar, got \(String(describing: session.barItem))") + return + } + #expect(reason == "Chief isn't running on Studio.") + #expect(!retrying) + session.retryConnecting() + #expect(retries == 1) + #expect(session.barItem == .unavailable(reason: "Chief isn't running on Studio.", retrying: true)) + // A second tap while it tries does nothing. + session.retryConnecting() + #expect(retries == 1) + // The try ended with the same refusal: Try Again is offered again. + session.status = "Chief isn't running on Studio." + #expect(session.barItem == .unavailable(reason: "Chief isn't running on Studio.", retrying: false)) + } + + @Test func aMessageWrittenWhileChiefIsUnavailableIsKept() { + let session = ChiefSession(backend: ChiefOfflineBackend(), outbox: ChiefOutbox(file: nil), clock: ImmediateClock()) + session.status = "Chief isn't running on Studio." + // The conversation's own send drops a message before Chief ever connected. + #expect(!session.acceptsSendsNow) + session.send(ConversationComposeMessage(text: "Hello Chief")) + #expect(session.outbox.map(\.text) == ["Hello Chief"]) + #expect(session.preview.contains("Hello Chief")) + } + + @Test func withoutRefusalOrReconnectHookNoTryAgain() { + let session = ChiefSession(backend: ChiefOfflineBackend(), outbox: ChiefOutbox(file: nil), clock: ImmediateClock()) + session.status = "No Mac" + #expect(session.barItem == .unavailable(reason: "No Mac", retrying: false)) + // No hook: Try Again does nothing and never shows as trying. + session.retryConnecting() + #expect(session.barItem == .unavailable(reason: "No Mac", retrying: false)) + #expect(!session.canRetryConnecting) + } +} From f56f8aa6366ba14fb285d72a3a3daa1a785d72d3 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:26:30 -0700 Subject: [PATCH 04/31] cnx-ios D1 test: the Worker creates teams for the caller (red: no POST /v1/teams) Co-Authored-By: Claude Opus 5.5 --- cnx-ios/backend/test/teams.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 cnx-ios/backend/test/teams.test.ts diff --git a/cnx-ios/backend/test/teams.test.ts b/cnx-ios/backend/test/teams.test.ts new file mode 100644 index 000000000000..6d16e1d621cc --- /dev/null +++ b/cnx-ios/backend/test/teams.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest" +import { call, PROD_PROJECT, token } from "./helpers.ts" + +// Full verify D1 (2026-10-19): the phone created teams with Stack's client API, which both Stack +// projects refuse (client_team_creation_enabled is false), so no team ever appeared. The Worker +// creates the team with the project's server key and makes the caller its creator and member. +describe("POST /v1/teams", () => { + it("creates a team for the caller and returns its id and name", async () => { + const r = await call("POST", "/v1/teams", await token("alice"), { name: " fv team " }) + expect(r.status).toBe(200) + expect(r.json.team).toEqual({ id: expect.stringMatching(/^team-/), name: "fv team" }) + }) + + it("needs a name of at most 64 characters", async () => { + const tok = await token("alice") + expect((await call("POST", "/v1/teams", tok, {})).status).toBe(400) + expect((await call("POST", "/v1/teams", tok, { name: " " })).status).toBe(400) + expect((await call("POST", "/v1/teams", tok, { name: "x".repeat(65) })).status).toBe(400) + }) + + it("says so when this Worker cannot create teams for the caller's project", async () => { + const r = await call("POST", "/v1/teams", await token("pat", { project: PROD_PROJECT }), { name: "Prod team" }) + expect(r.status).toBe(503) + expect(r.json.error.code).toBe("team_creation_unavailable") + }) + + it("needs a signed-in caller", async () => { + expect((await call("POST", "/v1/teams", undefined, { name: "x" })).status).toBe(401) + }) +}) From c296e4c3d40c9430188ba9e5f8343fb950842258 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:27:25 -0700 Subject: [PATCH 05/31] cnx-ios D1: POST /v1/teams creates the team with the project's server key; phone test (red) Stack refuses client-side team creation in both projects (client_team_creation_enabled is false), so a team made from the phone never existed. The Worker now creates it as the caller with the server key and says team_creation_unavailable when it has none. Co-Authored-By: Claude Opus 5.5 --- .../CreateTeamRequestTests.swift | 30 +++++++++++++++++++ cnx-ios/PROTOCOL.md | 1 + cnx-ios/backend/src/index.ts | 19 +++++++++++- cnx-ios/backend/src/stack.ts | 28 +++++++++++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 cnx-ios/CnxKit/Tests/CnxShellTests/CreateTeamRequestTests.swift diff --git a/cnx-ios/CnxKit/Tests/CnxShellTests/CreateTeamRequestTests.swift b/cnx-ios/CnxKit/Tests/CnxShellTests/CreateTeamRequestTests.swift new file mode 100644 index 000000000000..98e532f5ac5e --- /dev/null +++ b/cnx-ios/CnxKit/Tests/CnxShellTests/CreateTeamRequestTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import CnxAuth + +/// Full verify D1 (2026-10-19): Stack refuses team creation from the +/// client, so the phone creates teams through the cmux service. +@Suite struct CreateTeamRequestTests { + @Test func theRequestGoesToTheServiceWithTheName() throws { + let request = CnxAuthModel.createTeamRequest(apiBase: URL(string: "https://api.example")!, accessToken: "tok", name: "fv team") + #expect(request.url?.absoluteString == "https://api.example/v1/teams") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer tok") + let body = try #require(request.httpBody.flatMap { try JSONSerialization.jsonObject(with: $0) as? [String: String] }) + #expect(body == ["name": "fv team"]) + } + + @Test func theAnswerIsTheNewTeam() throws { + let data = Data(#"{"team":{"id":"t-1","name":"fv team"}}"#.utf8) + let team = try CnxAuthModel.createdTeam(data: data, status: 200) + #expect(team.id == "t-1") + #expect(team.name == "fv team") + } + + @Test func aRefusalIsAnErrorNeverASilentSuccess() { + let unavailable = Data(#"{"error":{"code":"team_creation_unavailable","message":"x"}}"#.utf8) + #expect(throws: CnxAuthModel.TeamCreationError.unavailable) { try CnxAuthModel.createdTeam(data: unavailable, status: 503) } + #expect(throws: CnxAuthModel.TeamCreationError.failed) { try CnxAuthModel.createdTeam(data: Data("{}".utf8), status: 200) } + #expect(throws: CnxAuthModel.TeamCreationError.failed) { try CnxAuthModel.createdTeam(data: Data(), status: 502) } + } +} diff --git a/cnx-ios/PROTOCOL.md b/cnx-ios/PROTOCOL.md index 694b05141051..2684ef9bb859 100644 --- a/cnx-ios/PROTOCOL.md +++ b/cnx-ios/PROTOCOL.md @@ -71,6 +71,7 @@ echoes the token), | `GET /v1/me/macs` | → `{macs:[{deviceId,name,teamId,online,lastSeenAt,ownerUserId}]}`: the caller's own Macs (registered by the caller, the only Macs v1 lets it dial) in every team the caller still belongs to and in its personal space, with presence from each team's hub. Added in 0.5.0 so a phone finds its Mac whichever team either side has selected; the app polls it (every 4 s while its active team has no online Mac, else every 30 s) and moves to the team of an online Mac. `502 membership_unavailable` when Stack cannot list the caller's teams | | `GET /v1/teams/:teamId/prefs` | → `{color, emoji}` for the caller (per user per team) | | `PUT /v1/teams/:teamId/prefs` | `{color?: "#RRGGBB", emoji?: string\|null}`. `emoji` is the avatar glyph: `null` = automatic (team initial), `""` = none (color only), otherwise exactly one grapheme (letter, digit, symbol or emoji; not whitespace) | +| `POST /v1/teams` | `{name}` (1 to 64 characters, trimmed) → `{team:{id,name}}`: creates a Stack team with the caller as creator and first member, using the project's server key (Stack refuses client-side team creation). `503 team_creation_unavailable` when the Worker has no server key for the caller's project; `403`/`502 team_create_failed` when Stack refuses. | | `POST /v1/teams/:teamId/invites` | `{email}` → `{url, sent:true}`: Stack emails a team invitation whose link opens `url` (the configured callback) with the code. Changed from a bare link: without a Stack server key the API can only send the invitation email, not mint a shareable link. Needs the caller's `$invite_members` team permission. | | `GET /v1/ice` | → `{iceServers:[{urls:[...],username?,credential?}], ttl, turn}` from the Cloudflare TURN key (generate-ice-servers), STUN `stun:stun.cloudflare.com:3478` always included (STUN only if TURN is unavailable). `turn` (0.7.0) is `false` when no TURN server could be included: clients must say so (off-LAN WebRTC may then fail) instead of failing silently. `ttl` is 1 h (was 4 h) | | `GET /v1/me/devices` | → `{devices:[{deviceId,kind,name,teamId,createdAt,updatedAt,lastSeenAt,online,push}]}` (0.7.0): every Mac and phone the caller registered, in any team or personal space. `online` is live presence for Macs and `null` for phones; `push` is whether the phone has an APNs token | diff --git a/cnx-ios/backend/src/index.ts b/cnx-ios/backend/src/index.ts index f23f684b3101..5ec0f23934a7 100644 --- a/cnx-ios/backend/src/index.ts +++ b/cnx-ios/backend/src/index.ts @@ -1,7 +1,7 @@ import { authenticate, bearerOf, primaryProject, stackProjects, type Caller } from "./auth.ts" import type { Env } from "./env.ts" import { iceServers } from "./ice.ts" -import { sendTeamInvite, userTeams } from "./stack.ts" +import { createTeam, sendTeamInvite, userTeams } from "./stack.ts" import { apnsConfig, apnsTopics, clean, sendApns } from "./push.ts" import { openStore, PUSH_KINDS, type Device, type PushKind, type Store } from "./store.ts" import type { Attachment } from "./team-hub.ts" @@ -411,6 +411,23 @@ const routes = async (req: Request, env: Env, ctx: ExecutionContext): Promise 64) throw new HttpError(400, "bad_request", "name must be 1 to 64 characters") + await limit(env.INVITE_LIMIT, me, "team creations") + const r = await createTeam(env, caller, name) + if (!r.ok) { + if (r.code === "team_creation_unavailable") return fail(503, r.code, "this service can't create teams for your account yet") + return fail(r.status === 401 || r.status === 403 ? 403 : 502, "team_create_failed", `Stack refused the team (${r.code})`) + } + // The caller's cached memberships predate the new team. + teamsCache.delete(`${caller.projectId}\u0000${caller.userId}`) + return json({ team: { id: r.id, name: r.name } }) + } + const team = path.match(/^\/v1\/teams\/([^/]+)\/(macs|prefs|invites|signal|hub)$/) if (!team) return fail(404, "not_found", "no such route") const teamId = decodeURIComponent(team[1]!) diff --git a/cnx-ios/backend/src/stack.ts b/cnx-ios/backend/src/stack.ts index 10b0dace6e2a..4f8e30d86163 100644 --- a/cnx-ios/backend/src/stack.ts +++ b/cnx-ios/backend/src/stack.ts @@ -62,3 +62,31 @@ export const sendTeamInvite = async (env: Env, caller: Caller, teamId: string, e if (res.ok) return { ok: true, url: callback } return { ok: false, status: res.status, code: res.headers.get("x-stack-known-error") ?? "stack_error" } } + +export type CreateTeamResult = { ok: true; id: string; name: string } | { ok: false; status: number; code: string } + +/** + * Creates a Stack team with the caller as its creator (and so its first member). Stack refuses team + * creation from the client API in both projects (`client_team_creation_enabled` is false), so this + * needs the project's server key; without one it says so instead of pretending. + */ +export const createTeam = async (env: Env, caller: Caller, name: string): Promise => { + if (env.ENVIRONMENT === "test") { + // Tests have no server key: the primary project stands for one that has it. + return caller.projectId === primaryProject(env).id + ? { ok: true, id: `team-${crypto.randomUUID()}`, name } + : { ok: false, status: 503, code: "team_creation_unavailable" } + } + if (!usesServerKey(env, caller)) return { ok: false, status: 503, code: "team_creation_unavailable" } + const res = await fetch(`${API}/teams`, { + method: "POST", + headers: headers(env, caller), + body: JSON.stringify({ display_name: name, creator_user_id: caller.userId }), + signal: AbortSignal.timeout(10_000) + }) + // Never echo Stack's body: it can carry user data. + if (!res.ok) return { ok: false, status: res.status, code: res.headers.get("x-stack-known-error") ?? "stack_error" } + const body = (await res.json()) as { id?: unknown; display_name?: unknown } + if (typeof body.id !== "string") return { ok: false, status: 502, code: "stack_error" } + return { ok: true, id: body.id, name: typeof body.display_name === "string" ? body.display_name : name } +} From 4f0c4dec44f92b4a3b4356fc8760023af7eb0a5e Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:28:49 -0700 Subject: [PATCH 06/31] cnx-ios D1: the phone creates teams through the cmux service, then rereads Stack's list Co-Authored-By: Claude Opus 5.5 --- .../CnxKit/Sources/CnxAuth/CnxAuthModel.swift | 46 +++++++++++++++++-- .../CnxKit/Sources/CnxShell/ShellModel.swift | 3 ++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/cnx-ios/CnxKit/Sources/CnxAuth/CnxAuthModel.swift b/cnx-ios/CnxKit/Sources/CnxAuth/CnxAuthModel.swift index 867a8ab9e209..e91c8b2586a9 100644 --- a/cnx-ios/CnxKit/Sources/CnxAuth/CnxAuthModel.swift +++ b/cnx-ios/CnxKit/Sources/CnxAuth/CnxAuthModel.swift @@ -322,12 +322,52 @@ public final class CnxAuthModel { try? await coordinator?.selectTeam(id: id) } - /// Creates a team and selects it. + /// Why a team could not be created. + public enum TeamCreationError: Error, Equatable, Sendable { + /// The cmux service can't create teams for this account (no server key for its project). + case unavailable + case failed + } + + /// Creates a team and selects it. Stack refuses team creation from the + /// client (`client_team_creation_enabled` is off in both projects), so it + /// goes through the cmux service (`POST /v1/teams`), which creates it with + /// the project's server key; then the team list is read again from Stack. public func createTeam(named name: String) async throws -> AppStore.AccountTeam { if isMock { return AppStore.AccountTeam(id: "team-\(UUID().uuidString.prefix(8))", name: name) } guard let coordinator else { throw AuthError.unauthorized } - let team = try await coordinator.createTeam(displayName: name) - return AppStore.AccountTeam(id: team.id, name: team.displayName) + guard let apiBase else { throw TeamCreationError.unavailable } + let tokens = try await coordinator.currentTokens() + let (data, response) = try await URLSession.shared.data( + for: Self.createTeamRequest(apiBase: apiBase, accessToken: tokens.accessToken, name: name)) + let team = try Self.createdTeam(data: data, status: (response as? HTTPURLResponse)?.statusCode ?? 0) + // The new membership, then the selection (best effort, as in Stack's own flow). + await coordinator.refreshTeams() + try? await coordinator.selectTeam(id: team.id) + return team + } + + /// `POST /v1/teams {name}` on the cmux service. + static func createTeamRequest(apiBase: URL, accessToken: String, name: String) -> URLRequest { + var request = URLRequest(url: apiBase.appending(path: "v1/teams")) + request.httpMethod = "POST" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONSerialization.data(withJSONObject: ["name": name]) + return request + } + + /// The created team from the service's answer; any other answer is an error, never a silent success. + static func createdTeam(data: Data, status: Int) throws -> AppStore.AccountTeam { + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + guard status == 200 else { + let code = (object?["error"] as? [String: Any])?["code"] as? String + throw code == "team_creation_unavailable" ? TeamCreationError.unavailable : TeamCreationError.failed + } + guard let team = object?["team"] as? [String: Any], let id = team["id"] as? String, !id.isEmpty else { + throw TeamCreationError.failed + } + return AppStore.AccountTeam(id: id, name: team["name"] as? String ?? "") } /// Joins a team from a Stack invitation link (`...?code=...`) and refreshes diff --git a/cnx-ios/CnxKit/Sources/CnxShell/ShellModel.swift b/cnx-ios/CnxKit/Sources/CnxShell/ShellModel.swift index c36e55933b2c..c8682caf1dd6 100644 --- a/cnx-ios/CnxKit/Sources/CnxShell/ShellModel.swift +++ b/cnx-ios/CnxKit/Sources/CnxShell/ShellModel.swift @@ -427,6 +427,9 @@ final class ShellModel { } theme.haptics.success() switchTeam(to: team.id) + } catch CnxAuthModel.TeamCreationError.unavailable { + toast = CnxToast(symbol: "exclamationmark.circle", text: "Creating teams isn't available for this account yet") + theme.haptics.error() } catch { toast = CnxToast(symbol: "exclamationmark.circle", text: "Couldn't create the team") theme.haptics.error() From 603faabd9577cec0faa7a37cba81f705e0feb571 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:28:50 -0700 Subject: [PATCH 07/31] cnx-ios D2/D3: an unavailable Chief says why with Try Again; a message written then stays The conversation's first-load spinner never ended without the Mac's Chief, and its composer cleared and dropped a message sent before Chief connected. Chief's bar now shows the reason (in plain words, hostd's text goes to the log) with Try Again, which opens the chief channel again. The send button goes through a gate: until Chief takes sends the text stays in the composer and an alert offers Try Again or Send When Chief Connects (the outbox). Co-Authored-By: Claude Opus 5.5 --- .../CnxChief/ChiefConversationScreen.swift | 103 +++++++++++++++++- .../Sources/CnxChief/ChiefFailure.swift | 2 + .../Sources/CnxChief/ChiefQuestionViews.swift | 40 +++++++ .../Sources/CnxChief/ChiefSession.swift | 32 +++++- .../Sources/CnxLive/LiveCoordinator.swift | 30 ++++- 5 files changed, 202 insertions(+), 5 deletions(-) diff --git a/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift b/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift index 6a5eeacbff90..909a57f3bb7c 100644 --- a/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift +++ b/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift @@ -130,10 +130,111 @@ public final class ChiefConversationContainer: UIViewController, UIGestureRecogn // the Mac (chief-profile-changed) redraws it in place. headerAvatar = chiefInitials conversation.store.addObserver { [weak self] _ in - guard let self, self.isViewLoaded, self.chiefInitials != self.headerAvatar else { return } + guard let self, self.isViewLoaded else { return } + self.updateAvailability() + guard self.chiefInitials != self.headerAvatar else { return } self.headerAvatar = self.chiefInitials if self.view.window != nil { self.conversation.setBackUnreadCount(self.backUnreadCount) } } + installSendGate() + observeAvailability() + } + + // MARK: Chief unavailable (full verify D2, D3) + + /// The package's send action, kept while the gate holds the button. + private var packageSendActions: [UIAction] = [] + private weak var sendButton: UIButton? + + /// Before Chief ever connected, the package's send clears the composer + /// and then drops the message (its store has no sender yet). The send + /// button goes through this gate: once Chief takes sends it runs the + /// package's own action; until then the text stays in the composer and + /// the screen says why, with Try Again and Send When Chief Connects. + private func installSendGate() { + guard let button = conversation.view.firstSubview(where: { $0.accessibilityIdentifier == "conversation.composer.send" }) as? UIButton + else { return } + var actions: [UIAction] = [] + button.enumerateEventHandlers { action, _, event, _ in + if event == .touchUpInside, let action { actions.append(action) } + } + guard !actions.isEmpty else { return } + sendButton = button + packageSendActions = actions + for action in actions { button.removeAction(action, for: .touchUpInside) } + button.addAction(UIAction { [weak self] _ in self?.sendTapped() }, for: .touchUpInside) + updateAvailability() + } + + private func sendTapped() { + guard let button = sendButton else { return } + if session.acceptsSendsNow { + for action in packageSendActions { button.sendAction(action) } + return + } + holdUnsentMessage() + } + + private var composerTextView: UITextView? { + conversation.view.firstSubview { $0.accessibilityIdentifier == "conversation.composer.text" } as? UITextView + } + + /// The message stays in the composer; the alert says why and offers to + /// try Chief again or to send it once Chief connects (the outbox). + private func holdUnsentMessage() { + guard presentedViewController == nil else { return } + let text = composerTextView?.text.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let reason = session.unavailableReason ?? String(localized: "Chief isn't connected yet.") + let alert = UIAlertController( + title: String(localized: "Message Not Sent"), + message: String(localized: "\(reason) Your message is still in the box."), + preferredStyle: .alert) + if session.canRetryConnecting { + alert.addAction(UIAlertAction(title: String(localized: "Try Again"), style: .default) { [weak self] _ in + self?.session.retryConnecting() + }) + } + if !text.isEmpty { + alert.addAction(UIAlertAction(title: String(localized: "Send When Chief Connects"), style: .default) { [weak self] _ in + self?.moveComposerToOutbox(text) + }) + } + alert.addAction(UIAlertAction(title: String(localized: "OK"), style: .cancel)) + present(alert, animated: true) + } + + private func moveComposerToOutbox(_ text: String) { + session.send(ConversationComposeMessage(text: text)) + guard let field = composerTextView else { return } + field.text = "" + // The composer follows its field through the delegate (send button, height). + field.delegate?.textViewDidChange?(field) + } + + /// Follows the session for the gate and the spinner. + private func observeAvailability() { + withObservationTracking { + _ = session.barItem + _ = session.isConnected + } onChange: { [weak self] in + Task { @MainActor [weak self] in + self?.updateAvailability() + self?.observeAvailability() + } + } + } + + /// The package's first-load spinner never ends while Chief is unavailable: + /// hidden while the bar says why. The effect long-press on Send would + /// also clear and drop: off until Chief takes sends. + private func updateAvailability() { + let unavailable: Bool + if case .unavailable? = session.barItem { unavailable = true } else { unavailable = false } + conversation.view.firstSubview { $0.accessibilityIdentifier == "conversation.initialLoading" }?.alpha = unavailable ? 0 : 1 + let sends = session.acceptsSendsNow + for recognizer in sendButton?.gestureRecognizers ?? [] where recognizer is UILongPressGestureRecognizer { + recognizer.isEnabled = sends + } } // MARK: Chief's questions diff --git a/cnx-ios/CnxKit/Sources/CnxChief/ChiefFailure.swift b/cnx-ios/CnxKit/Sources/CnxChief/ChiefFailure.swift index 4f99eaff51ef..ff2fd1c0faaf 100644 --- a/cnx-ios/CnxKit/Sources/CnxChief/ChiefFailure.swift +++ b/cnx-ios/CnxKit/Sources/CnxChief/ChiefFailure.swift @@ -257,4 +257,6 @@ public enum ChiefBarItem: Equatable, Sendable { case question(AgentQuestion) /// `id` is the failed message's id. case failure(id: String, ChiefTurnFailure) + /// The Mac's Chief can't be reached: why, and whether Try Again is running. + case unavailable(reason: String, retrying: Bool) } diff --git a/cnx-ios/CnxKit/Sources/CnxChief/ChiefQuestionViews.swift b/cnx-ios/CnxKit/Sources/CnxChief/ChiefQuestionViews.swift index 9269b0ac4ac3..ca5ee8f863ad 100644 --- a/cnx-ios/CnxKit/Sources/CnxChief/ChiefQuestionViews.swift +++ b/cnx-ios/CnxKit/Sources/CnxChief/ChiefQuestionViews.swift @@ -60,6 +60,7 @@ struct ChiefQuestionBar: View { switch shown { case .question(let question)?: question.id + (session.answers[question.id]?.statusText ?? "") case .failure(let id, _)?: id + case .unavailable(let reason, let retrying)?: reason + (retrying ? "\u{0}" : "") case nil: "" } } @@ -67,6 +68,8 @@ struct ChiefQuestionBar: View { @ViewBuilder private var content: some View { if case .failure(_, let failure)? = shown { failureBar(failure) + } else if case .unavailable(let reason, let retrying)? = shown { + unavailableBar(reason: reason, retrying: retrying) } else if case .question(let question)? = shown { let flow = session.answers[question.id] ?? ChiefAnswerFlow() Button { open(question) } label: { @@ -122,6 +125,43 @@ struct ChiefQuestionBar: View { .allowsHitTesting(visible) } + /// The Mac's Chief can't be reached (refused, not running, no Mac): why, + /// in its own words, and Try Again when the link can try. Replaces the + /// conversation's first-load spinner, which never ends without Chief. + private func unavailableBar(reason: String, retrying: Bool) -> some View { + Button { session.retryConnecting() } label: { + HStack(spacing: 10) { + if retrying { + ProgressView().controlSize(.small) + } else { + Image(systemName: "exclamationmark.triangle.fill").font(.body).foregroundStyle(.orange) + } + VStack(alignment: .leading, spacing: 1) { + Text("Chief isn't available").font(.footnote.weight(.semibold)) + Text(reason).font(.footnote).foregroundStyle(.secondary).lineLimit(3) + } + Spacer(minLength: 4) + if session.canRetryConnecting { + Text(retrying ? "Trying…" : "Try Again").font(.footnote.weight(.semibold)) + } + } + .foregroundStyle(.primary) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + .background(Color(uiColor: .secondarySystemBackground), in: .rect(cornerRadius: 20, style: .continuous)) + } + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .buttonStyle(.plain) + .disabled(retrying || !session.canRetryConnecting) + .padding(.horizontal, 16) + .accessibilityIdentifier("chief.unavailable.bar") + .accessibilityHint(session.canRetryConnecting ? "Tries to reach Chief on your Mac again" : "") + .opacity(visible ? 1 : 0) + .allowsHitTesting(visible) + } + @ViewBuilder private func label(_ question: AgentQuestion, flow: ChiefAnswerFlow) -> some View { let icon = Group { if flow.isBusy { diff --git a/cnx-ios/CnxKit/Sources/CnxChief/ChiefSession.swift b/cnx-ios/CnxKit/Sources/CnxChief/ChiefSession.swift index b11ebfda88ad..41a55bab0732 100644 --- a/cnx-ios/CnxKit/Sources/CnxChief/ChiefSession.swift +++ b/cnx-ios/CnxKit/Sources/CnxChief/ChiefSession.swift @@ -55,7 +55,16 @@ public final class ChiefSession { public private(set) var isConnected = false /// Why Chief is not reachable (no Mac, Chief not started there), set by /// whoever swaps the backend; nil when there is nothing to explain. - public var status: String? + /// Setting it ends a Try Again in flight (the try came back). + public var status: String? { + didSet { isReconnecting = false } + } + /// Tries the Mac's Chief again (set by the link's coordinator); nil + /// where there is nothing to try (no link). + @ObservationIgnored public var reconnect: (@MainActor () -> Void)? + /// A Try Again is in flight: the bar shows progress until ``status`` is + /// set again or Chief connects. + public private(set) var isReconnecting = false /// The backend the store runs on. public private(set) var backend: any ConversationBackend /// Who is in the conversation; the Cloud Chief's ids come with its backend. @@ -166,10 +175,30 @@ public final class ChiefSession { /// else Try Again for a failed newest turn not tried again yet. public var barItem: ChiefBarItem? { if let question = openQuestion { return .question(question) } + // The Mac's Chief refused or is missing (D2): say why, instead of + // the conversation's endless first-load spinner. + if !isConnected, let status { return .unavailable(reason: status, retrying: isReconnecting) } guard let id = lastTurnFailureID, let failure = lastTurnFailure, id != retriedFailureID, isConnected else { return nil } return .failure(id: id, failure) } + /// Try Again on the unavailable bar has something to try. + public var canRetryConnecting: Bool { reconnect != nil } + + /// Try Again on the unavailable bar: asks the coordinator to open the + /// Mac's Chief again. A second tap while one runs does nothing. + public func retryConnecting() { + guard let reconnect, !isReconnecting else { return } + isReconnecting = true + reconnect() + } + + /// The conversation's own send takes a message now. Before Chief ever + /// connected it has no sender and drops the message after clearing the + /// composer (D3), so the screen holds the text and sends it through + /// ``send(_:)`` (the outbox) instead. + public var acceptsSendsNow: Bool { store.meID != nil } + /// What Chief is doing right now (Live frames v1), when its backend /// carries live frames (``ChiefLiveSource``); nil when nothing runs. public private(set) var live: ChiefLive? @@ -454,6 +483,7 @@ public final class ChiefSession { if isTyping != typing { isTyping = typing } let connected = store.connection == .connected if isConnected != connected { isConnected = connected } + if connected, isReconnecting { isReconnecting = false } if title != store.info?.title { title = store.info?.title } // Chief's avatar as the conversation draws it (the Mac's text, live). if let drawn = store.info?.participants.first(where: { $0.id == identity.chiefID })?.initials, diff --git a/cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift b/cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift index 73f72552dcbe..a30a8c394c5f 100644 --- a/cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift +++ b/cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift @@ -1113,6 +1113,8 @@ public final class LiveCoordinator { /// reason; there is no stand-in Chief. private func startChief(_ link: any MacLink) async { let session = ChiefSession.shared + // Try Again on Chief's unavailable bar opens the channel again on this link. + session.reconnect = { [weak self] in self?.retryChief() } session.macName = self.link.target?.name // Chief's settings show this Mac's last known model until it answers. session.macID = self.link.target?.macID @@ -1162,12 +1164,34 @@ public final class LiveCoordinator { } catch { // An older hostd without the `chief` service closes it as unknown. let reason = LinkController.reason(error) - offline(reason.contains("unknown_service") || reason.contains("no service chief") - ? String(localized: "This Mac's cmux is too old for Chief on iPhone. Update cmux on the Mac.") - : String(localized: "Chief on this Mac: \(reason)")) + log.info("chief channel refused: \(reason, privacy: .public)") + offline(Self.chiefRefusal(reason, macName: self.link.target?.name)) } } + /// Try Again on Chief's unavailable bar: the Chief channel again, on the + /// link that is up now. Without one, the bar says so. + func retryChief() { + guard let link = servedLink else { + ChiefSession.shared.status = String(localized: "Your Mac isn't connected. Chief comes back when it is.") + return + } + Task { [weak self] in await self?.startChief(link) } + } + + /// The bar's sentence for a refused `chief` channel. hostd's own text + /// ("cmux-tui session chief: No such file or directory") is for the log. + static func chiefRefusal(_ reason: String, macName: String?) -> String { + let mac = macName ?? String(localized: "your Mac") + if reason.contains("unknown_service") || reason.contains("no service chief") { + return String(localized: "This Mac's cmux is too old for Chief on iPhone. Update cmux on the Mac.") + } + if reason.contains("No such file") || reason.contains("not set up") || reason.contains("Connection refused") { + return String(localized: "Chief isn't running on \(mac). Open cmux on the Mac to start it, then try again.") + } + return String(localized: "Chief on \(mac) didn't answer (\(reason)).") + } + private func apply(_ tree: DaemonTree) { guard let macID = link.target?.macID else { return } self.tree = tree From 76c4841ad542e53786806daeeb0b70b438da6650 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:30:19 -0700 Subject: [PATCH 08/31] cnx-ios D1: team creation request and answer in CnxModel (CnxAuth is iOS-only), tests there Co-Authored-By: Claude Opus 5.5 --- .../CnxKit/Sources/CnxAuth/CnxAuthModel.swift | 38 ++-------------- .../CnxModel/ServiceTeamCreation.swift | 43 +++++++++++++++++++ .../CnxKit/Sources/CnxShell/ShellModel.swift | 2 +- .../ServiceTeamCreationTests.swift} | 17 ++++---- 4 files changed, 57 insertions(+), 43 deletions(-) create mode 100644 cnx-ios/CnxKit/Sources/CnxModel/ServiceTeamCreation.swift rename cnx-ios/CnxKit/Tests/{CnxShellTests/CreateTeamRequestTests.swift => CnxModelTests/ServiceTeamCreationTests.swift} (55%) diff --git a/cnx-ios/CnxKit/Sources/CnxAuth/CnxAuthModel.swift b/cnx-ios/CnxKit/Sources/CnxAuth/CnxAuthModel.swift index e91c8b2586a9..d9424da333ac 100644 --- a/cnx-ios/CnxKit/Sources/CnxAuth/CnxAuthModel.swift +++ b/cnx-ios/CnxKit/Sources/CnxAuth/CnxAuthModel.swift @@ -322,13 +322,6 @@ public final class CnxAuthModel { try? await coordinator?.selectTeam(id: id) } - /// Why a team could not be created. - public enum TeamCreationError: Error, Equatable, Sendable { - /// The cmux service can't create teams for this account (no server key for its project). - case unavailable - case failed - } - /// Creates a team and selects it. Stack refuses team creation from the /// client (`client_team_creation_enabled` is off in both projects), so it /// goes through the cmux service (`POST /v1/teams`), which creates it with @@ -336,40 +329,17 @@ public final class CnxAuthModel { public func createTeam(named name: String) async throws -> AppStore.AccountTeam { if isMock { return AppStore.AccountTeam(id: "team-\(UUID().uuidString.prefix(8))", name: name) } guard let coordinator else { throw AuthError.unauthorized } - guard let apiBase else { throw TeamCreationError.unavailable } + guard let apiBase else { throw ServiceTeamCreation.Failure.unavailable } + let creation = ServiceTeamCreation(apiBase: apiBase) let tokens = try await coordinator.currentTokens() - let (data, response) = try await URLSession.shared.data( - for: Self.createTeamRequest(apiBase: apiBase, accessToken: tokens.accessToken, name: name)) - let team = try Self.createdTeam(data: data, status: (response as? HTTPURLResponse)?.statusCode ?? 0) + let (data, response) = try await URLSession.shared.data(for: creation.request(accessToken: tokens.accessToken, name: name)) + let team = try creation.team(from: data, status: (response as? HTTPURLResponse)?.statusCode ?? 0) // The new membership, then the selection (best effort, as in Stack's own flow). await coordinator.refreshTeams() try? await coordinator.selectTeam(id: team.id) return team } - /// `POST /v1/teams {name}` on the cmux service. - static func createTeamRequest(apiBase: URL, accessToken: String, name: String) -> URLRequest { - var request = URLRequest(url: apiBase.appending(path: "v1/teams")) - request.httpMethod = "POST" - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try? JSONSerialization.data(withJSONObject: ["name": name]) - return request - } - - /// The created team from the service's answer; any other answer is an error, never a silent success. - static func createdTeam(data: Data, status: Int) throws -> AppStore.AccountTeam { - let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] - guard status == 200 else { - let code = (object?["error"] as? [String: Any])?["code"] as? String - throw code == "team_creation_unavailable" ? TeamCreationError.unavailable : TeamCreationError.failed - } - guard let team = object?["team"] as? [String: Any], let id = team["id"] as? String, !id.isEmpty else { - throw TeamCreationError.failed - } - return AppStore.AccountTeam(id: id, name: team["name"] as? String ?? "") - } - /// Joins a team from a Stack invitation link (`...?code=...`) and refreshes /// the team list. public func joinTeam(inviteLink: String) async throws { diff --git a/cnx-ios/CnxKit/Sources/CnxModel/ServiceTeamCreation.swift b/cnx-ios/CnxKit/Sources/CnxModel/ServiceTeamCreation.swift new file mode 100644 index 000000000000..2a6921f4e544 --- /dev/null +++ b/cnx-ios/CnxKit/Sources/CnxModel/ServiceTeamCreation.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Create Team through the cmux service (`POST /v1/teams`, PROTOCOL.md). +/// Stack refuses team creation from the client (`client_team_creation_enabled` +/// is off in both projects), so the service creates it with the project's +/// server key (full verify D1, 2026-10-19). +public struct ServiceTeamCreation: Sendable { + /// Why a team could not be created. + public enum Failure: Error, Equatable, Sendable { + /// The service can't create teams for this account (no server key for its project). + case unavailable + case failed + } + + public let apiBase: URL + + public init(apiBase: URL) { + self.apiBase = apiBase + } + + /// `POST /v1/teams {name}`. + public func request(accessToken: String, name: String) -> URLRequest { + var request = URLRequest(url: apiBase.appending(path: "v1/teams")) + request.httpMethod = "POST" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONSerialization.data(withJSONObject: ["name": name]) + return request + } + + /// The created team from the service's answer; any other answer is an error, never a silent success. + public func team(from data: Data, status: Int) throws -> AppStore.AccountTeam { + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + guard status == 200 else { + let code = (object?["error"] as? [String: Any])?["code"] as? String + throw code == "team_creation_unavailable" ? Failure.unavailable : Failure.failed + } + guard let team = object?["team"] as? [String: Any], let id = team["id"] as? String, !id.isEmpty else { + throw Failure.failed + } + return AppStore.AccountTeam(id: id, name: team["name"] as? String ?? "") + } +} diff --git a/cnx-ios/CnxKit/Sources/CnxShell/ShellModel.swift b/cnx-ios/CnxKit/Sources/CnxShell/ShellModel.swift index c8682caf1dd6..3d91f9076381 100644 --- a/cnx-ios/CnxKit/Sources/CnxShell/ShellModel.swift +++ b/cnx-ios/CnxKit/Sources/CnxShell/ShellModel.swift @@ -427,7 +427,7 @@ final class ShellModel { } theme.haptics.success() switchTeam(to: team.id) - } catch CnxAuthModel.TeamCreationError.unavailable { + } catch ServiceTeamCreation.Failure.unavailable { toast = CnxToast(symbol: "exclamationmark.circle", text: "Creating teams isn't available for this account yet") theme.haptics.error() } catch { diff --git a/cnx-ios/CnxKit/Tests/CnxShellTests/CreateTeamRequestTests.swift b/cnx-ios/CnxKit/Tests/CnxModelTests/ServiceTeamCreationTests.swift similarity index 55% rename from cnx-ios/CnxKit/Tests/CnxShellTests/CreateTeamRequestTests.swift rename to cnx-ios/CnxKit/Tests/CnxModelTests/ServiceTeamCreationTests.swift index 98e532f5ac5e..e8b1804d656c 100644 --- a/cnx-ios/CnxKit/Tests/CnxShellTests/CreateTeamRequestTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxModelTests/ServiceTeamCreationTests.swift @@ -1,12 +1,14 @@ import Foundation import Testing -@testable import CnxAuth +@testable import CnxModel /// Full verify D1 (2026-10-19): Stack refuses team creation from the /// client, so the phone creates teams through the cmux service. -@Suite struct CreateTeamRequestTests { +@Suite struct ServiceTeamCreationTests { + let creation = ServiceTeamCreation(apiBase: URL(string: "https://api.example")!) + @Test func theRequestGoesToTheServiceWithTheName() throws { - let request = CnxAuthModel.createTeamRequest(apiBase: URL(string: "https://api.example")!, accessToken: "tok", name: "fv team") + let request = creation.request(accessToken: "tok", name: "fv team") #expect(request.url?.absoluteString == "https://api.example/v1/teams") #expect(request.httpMethod == "POST") #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer tok") @@ -15,16 +17,15 @@ import Testing } @Test func theAnswerIsTheNewTeam() throws { - let data = Data(#"{"team":{"id":"t-1","name":"fv team"}}"#.utf8) - let team = try CnxAuthModel.createdTeam(data: data, status: 200) + let team = try creation.team(from: Data(#"{"team":{"id":"t-1","name":"fv team"}}"#.utf8), status: 200) #expect(team.id == "t-1") #expect(team.name == "fv team") } @Test func aRefusalIsAnErrorNeverASilentSuccess() { let unavailable = Data(#"{"error":{"code":"team_creation_unavailable","message":"x"}}"#.utf8) - #expect(throws: CnxAuthModel.TeamCreationError.unavailable) { try CnxAuthModel.createdTeam(data: unavailable, status: 503) } - #expect(throws: CnxAuthModel.TeamCreationError.failed) { try CnxAuthModel.createdTeam(data: Data("{}".utf8), status: 200) } - #expect(throws: CnxAuthModel.TeamCreationError.failed) { try CnxAuthModel.createdTeam(data: Data(), status: 502) } + #expect(throws: ServiceTeamCreation.Failure.unavailable) { try creation.team(from: unavailable, status: 503) } + #expect(throws: ServiceTeamCreation.Failure.failed) { try creation.team(from: Data("{}".utf8), status: 200) } + #expect(throws: ServiceTeamCreation.Failure.failed) { try creation.team(from: Data(), status: 502) } } } From 3bab0e03da73ca6d7c685396c2bcdba827799d64 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:31:28 -0700 Subject: [PATCH 09/31] cnx-ios D9 test: hostd's file service shares the Mac's workspace folders, never home (red) Co-Authored-By: Claude Opus 5.5 --- cnx-ios/rust/crates/cnx-hostd/src/services.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/cnx-ios/rust/crates/cnx-hostd/src/services.rs b/cnx-ios/rust/crates/cnx-hostd/src/services.rs index c19915393735..05730545aea5 100644 --- a/cnx-ios/rust/crates/cnx-hostd/src/services.rs +++ b/cnx-ios/rust/crates/cnx-hostd/src/services.rs @@ -1175,6 +1175,35 @@ mod tests { assert!(!file.metadata().await.unwrap().is_file()); } + #[test] + fn workspace_folders_share_projects_never_home_or_above() { + // Full verify D9: the phone's chat edits files in its workspace folder, + // which the Mac shares with phones (agentcwd); home and its ancestors never. + let home = Path::new("/Users/me"); + let folders = vec![ + PathBuf::from("/Users/me/code/app"), + PathBuf::from("/Users/me"), + PathBuf::from("/Users"), + PathBuf::from("/"), + PathBuf::from("/tmp/scratch"), + PathBuf::from("/Users/me/code/app"), + ]; + assert_eq!( + shareable_workspace_folders(folders, home), + vec![PathBuf::from("/Users/me/code/app"), PathBuf::from("/tmp/scratch")] + ); + } + + #[test] + fn a_file_in_a_workspace_folder_is_inside_the_shared_roots() { + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("README.md"), "r").unwrap(); + let home = tempfile::tempdir().unwrap(); + let roots = shared_file_roots(&[], vec![project.path().to_path_buf(), home.path().to_path_buf()], home.path()); + assert!(confine(&roots, &project.path().join("README.md")).is_ok()); + assert_eq!(roots.len(), 1, "home is never a root: {roots:?}"); + } + #[test] fn confine_keeps_reads_inside_roots() { let root = tempfile::tempdir().unwrap(); From 892b74e73cbd3fd887bab9df2c2d926ab1e41dda Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:32:05 -0700 Subject: [PATCH 10/31] cnx-hostd D9: file previews may read the Mac's workspace folders (agentcwd roots), never home or above Files and Artifacts lists files the phone's chat edited in its workspace folder, but hostd's file service only knew the Chat Folders, so every preview was refused. A file outside the Chat Folders is now checked against the folders of the tabs the Mac's daemon lists (the same folders the app shares with phones for agent chats), dropping /, home and its ancestors as acpmux does. Co-Authored-By: Claude Opus 5.5 --- cnx-ios/rust/crates/cnx-hostd/src/services.rs | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/cnx-ios/rust/crates/cnx-hostd/src/services.rs b/cnx-ios/rust/crates/cnx-hostd/src/services.rs index 05730545aea5..b9678cb45fb2 100644 --- a/cnx-ios/rust/crates/cnx-hostd/src/services.rs +++ b/cnx-ios/rust/crates/cnx-hostd/src/services.rs @@ -656,14 +656,52 @@ fn confine_open(_roots: &[PathBuf], _file: &tokio::fs::File) -> Result<(), Refus Ok(()) } +/// The workspace folders a phone may read from: the folders of the tabs the +/// Mac's daemon lists, the same folders the app shares with phones for agent +/// chats (`_acpmux/workspace_roots_set`, agentcwd). A folder that is `/`, the +/// home folder or any ancestor of it is dropped, as acpmux drops it: a tab in +/// `~` must not open `~/.ssh` to a phone. +pub fn shareable_workspace_folders(folders: Vec, home: &Path) -> Vec { + let mut out: Vec = Vec::new(); + for folder in folders { + if !home.starts_with(&folder) && !out.contains(&folder) { + out.push(folder); + } + } + out +} + +/// `CNX_FILE_ROOTS` (the Chat Folders) and the shareable workspace folders. +pub fn shared_file_roots(file_roots: &[PathBuf], folders: Vec, home: &Path) -> Vec { + let home = std::fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf()); + let mut roots = file_roots.to_vec(); + for folder in shareable_workspace_folders(folders, &home) { + if !roots.contains(&folder) { + roots.push(folder); + } + } + roots +} + /// `file`: stream one regular file inside a workspace root, then close -/// with `eof`. +/// with `eof`. The roots are the Chat Folders, then (only when the file is +/// outside them) the Mac's workspace folders as they are now. async fn file(config: &ServiceConfig, args: &Value, sender: &ChannelSender) -> Result<(), Refusal> { let requested = args .get("path") .and_then(Value::as_str) .ok_or_else(|| Refusal::new("bad_request", "missing path"))?; - let resolved = confine(&config.file_roots, Path::new(requested))?; + let (resolved, roots) = match confine(&config.file_roots, Path::new(requested)) { + Ok(resolved) => (resolved, config.file_roots.clone()), + Err(refusal) if refusal.code == "forbidden" => { + let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")); + // No daemon answer: only the Chat Folders (the refusal stands). + let folders = daemon_folders(config).await.unwrap_or_default(); + let roots = shared_file_roots(&config.file_roots, folders, &home); + (confine(&roots, Path::new(requested))?, roots) + } + Err(refusal) => return Err(refusal), + }; // Non-blocking, so a FIFO inside a root cannot hold the open; the checks below // refuse anything that is not a regular file. let mut file = tokio::fs::OpenOptions::new() @@ -674,7 +712,7 @@ async fn file(config: &ServiceConfig, args: &Value, sender: &ChannelSender) -> R .map_err(|_| Refusal::new("not_found", "cannot open"))?; // A path component swapped for a symlink between the check and the open would // escape the roots: check again where the open descriptor really points. - confine_open(&config.file_roots, &file)?; + confine_open(&roots, &file)?; let metadata = file.metadata().await.map_err(|_| Refusal::new("not_found", "cannot stat"))?; if !metadata.is_file() { return Err(Refusal::new("bad_request", "not a regular file")); From 5bce946a12379fe2cfd0834ec19fbc13024b7658 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:33:17 -0700 Subject: [PATCH 11/31] cnx-ios D9: preview hint names workspace folders; SECURITY.md file roots Co-Authored-By: Claude Opus 5.5 --- .../CnxArtifacts/LinkFileArtifactSource.swift | 5 +++-- cnx-ios/SECURITY.md | 17 ++++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/cnx-ios/CnxKit/Sources/CnxArtifacts/LinkFileArtifactSource.swift b/cnx-ios/CnxKit/Sources/CnxArtifacts/LinkFileArtifactSource.swift index 07a04d83585c..8ea327b590f0 100644 --- a/cnx-ios/CnxKit/Sources/CnxArtifacts/LinkFileArtifactSource.swift +++ b/cnx-ios/CnxKit/Sources/CnxArtifacts/LinkFileArtifactSource.swift @@ -109,8 +109,9 @@ public struct LinkFileArtifactSource: ArtifactSource { var value: Bool { lock.withLock { fired } } } - /// Where the person shares a folder: the Mac's Chat Folders setting (cnx-hostd's file roots). - static let shareHint = "To open it here, add its project folder in cmux on the Mac: Settings › General › Chats › Chat Folders." + /// Where the person shares a folder: a workspace open on the Mac in that folder, or the Mac's + /// Chat Folders setting (cnx-hostd's file roots and the Mac's workspace folders). + static let shareHint = "To open it here, open a workspace in its project folder on the Mac, or add the folder in cmux on the Mac: Settings › General › Chats › Chat Folders." static func unavailable(_ error: LinkError, path: String, macName: String) -> ArtifactUnavailable { let name = (path as NSString).lastPathComponent diff --git a/cnx-ios/SECURITY.md b/cnx-ios/SECURITY.md index 39aae1acfef2..b691d1d8faff 100644 --- a/cnx-ios/SECURITY.md +++ b/cnx-ios/SECURITY.md @@ -93,9 +93,20 @@ allowlist gets a local `forbidden` error line and never reaches the daemon. ## `file`: read-only previews -- **Path checks.** Only absolute paths inside `CNX_FILE_ROOTS` are served. - These are the chat roots from Settings, and there are none by default, which - refuses every read. +- **Path checks.** Only absolute paths inside `CNX_FILE_ROOTS` or one of the + Mac's workspace folders are served. + - `CNX_FILE_ROOTS` are the chat roots from Settings; there are none by default. + - The workspace folders are the folders of the tabs the Mac's daemon lists + at the time of the read (`list-workspaces`, canonicalized): the same + folders the app already shares with phones for agent chats + (`_acpmux/workspace_roots_set`, the agentcwd decision). As acpmux does, a + folder that is `/`, the home folder or any ancestor of it is dropped, so a + tab in `~` never opens `~/.ssh` or `~/Library`. They are read only when a + path is outside `CNX_FILE_ROOTS`; with no daemon answer the read is refused + (full verify D9, 2026-10-19: the Files and Artifacts list showed files the + phone's own chat edited in its workspace folder, and every preview was + refused). This does not widen what a phone can reach: an agent chat the + phone starts there can already read those folders. - The path is canonicalized (symlinks resolved) before the open. - After the open, the descriptor's real path (`F_GETPATH`) is checked again, so a component swapped for a symlink in between cannot escape. From c3e0cf8e9882c65fd299ff4115c9ea28dd4cca65 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:36:37 -0700 Subject: [PATCH 12/31] acpmux: a catalog start model the harness does not offer is not used (cnx-ios D4) A Codex account without GPT-6.1 Sol accepted session/set_model and then failed every prompt, so a new chat (the phone's Default) never worked. The catalog default now applies only when the harness lists it (or lists nothing, as Claude Code); otherwise New Agent Chat says no start model and the chat runs the harness's own default, its config's model. A soft start model this session's harness does not list is skipped with start_default_skipped. Co-Authored-By: Claude Opus 5.5 --- cmux-tui/crates/acpmux/src/hub/lifecycle.rs | 8 +++++- .../crates/acpmux/src/hub/start_defaults.rs | 22 ++++++++++++++++ cmux-tui/crates/acpmux/tests/fake_agent.py | 7 ++++- .../crates/acpmux/tests/start_defaults.rs | 26 +++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs index 9c2a8f25a7d4..ded0e25eb2da 100644 --- a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs +++ b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs @@ -214,7 +214,13 @@ 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. + let reported = self.reported_model_ids(agent); + if model_soft && !super::start_defaults::offers(&reported, &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 { diff --git a/cmux-tui/crates/acpmux/src/hub/start_defaults.rs b/cmux-tui/crates/acpmux/src/hub/start_defaults.rs index bba321bcebf2..29224e76d117 100644 --- a/cmux-tui/crates/acpmux/src/hub/start_defaults.rs +++ b/cmux-tui/crates/acpmux/src/hub/start_defaults.rs @@ -223,6 +223,10 @@ impl Hub { model: Option<&str>, ) -> (Option, Option) { 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 @@ -233,6 +237,7 @@ impl Hub { curated .as_ref() .and_then(|c| c.default_model.clone()) + .filter(|m| offers(&reported, m)) .map(|value| StartValue { value, source: StartSource::Catalog }) }), }; @@ -257,6 +262,17 @@ 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 { + 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() + } + /// `start` for one `_acpmux/harnesses` row: `{model?, effort?, /// modelSource?, effortSource?}`, what a new chat there starts on. pub(super) fn start_view( @@ -294,3 +310,9 @@ impl Hub { Some(v) } } + +/// Whether a harness that reported `reported` offers `model`: any model when +/// it reported none, else only a listed one. +pub(super) fn offers(reported: &[String], model: &str) -> bool { + reported.is_empty() || reported.iter().any(|id| id == model) +} diff --git a/cmux-tui/crates/acpmux/tests/fake_agent.py b/cmux-tui/crates/acpmux/tests/fake_agent.py index bd01f97fb283..cfa7fa14577b 100644 --- a/cmux-tui/crates/acpmux/tests/fake_agent.py +++ b/cmux-tui/crates/acpmux/tests/fake_agent.py @@ -606,7 +606,12 @@ def main(): "options": [{"value": v, "name": v} for v in ("low", "medium", "high")]} if m == "session/new": sessions += 1 - send({"jsonrpc": "2.0", "id": rid, "result": {"sessionId": f"{SESSION_PREFIX}{sessions}", "configOptions": [effort]}}) + result = {"sessionId": f"{SESSION_PREFIX}{sessions}", "configOptions": [effort]} + # FAKE_MODELS=: the models this account offers (ACP `models`), as Codex reports them. + if os.environ.get("FAKE_MODELS"): + ids = os.environ["FAKE_MODELS"].split(",") + result["models"] = {"currentModelId": ids[0], "availableModels": [{"modelId": i, "name": i} for i in ids]} + send({"jsonrpc": "2.0", "id": rid, "result": result}) elif m == "session/set_model" and params.get("modelId") == os.environ.get("FAKE_REFUSE_ID"): send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": "model not available"}}) elif m == "session/set_model": diff --git a/cmux-tui/crates/acpmux/tests/start_defaults.rs b/cmux-tui/crates/acpmux/tests/start_defaults.rs index b0a9f184850e..a15c952224fe 100644 --- a/cmux-tui/crates/acpmux/tests/start_defaults.rs +++ b/cmux-tui/crates/acpmux/tests/start_defaults.rs @@ -213,3 +213,29 @@ async fn a_model_set_from_the_cli_is_no_saved_pick() { assert_eq!(saved(&dir, "codex"), Value::Null); let _ = std::fs::remove_dir_all(&dir); } + +/// cnx-ios full verify D4 (2026-10-19): an account whose Codex does not offer +/// the catalog default (GPT-6.1 Sol) failed every prompt of a new chat. The +/// catalog default applies only when the harness lists it; otherwise the chat +/// starts on the harness's own default (its config's model). +#[tokio::test] +async fn a_catalog_default_the_harness_does_not_offer_is_not_used() { + let (dir, calls) = home("not-offered"); + let mut config: Value = serde_json::from_slice(&std::fs::read(dir.join("config.json")).unwrap()).unwrap(); + config["harnesses"]["codex"]["env"]["FAKE_MODELS"] = json!("gpt-5.5,gpt-6-astra"); + std::fs::write(dir.join("config.json"), serde_json::to_vec_pretty(&config).unwrap()).unwrap(); + let daemon = hub(&dir); + let mut app = client(&daemon); + + // The first chat learns the list; it never sets the model the account lacks. + app.new_chat("codex").await; + assert!(!take_calls(&calls).iter().any(|(k, v)| k == "model" && v == "gpt-6.1-sol")); + // Now the list is known: New Agent Chat says no start model, and none is set. + let harnesses = app.call("_acpmux/harnesses", json!({})).await; + assert!(harnesses["harnesses"]["codex"]["start"]["model"].is_null(), "{}", harnesses["harnesses"]["codex"]["start"]); + app.new_chat("codex").await; + assert!(!take_calls(&calls).iter().any(|(k, _)| k == "model")); + // A harness that lists no models (Claude Code) still starts on the catalog default. + app.new_chat("claude").await; + assert_eq!(take_calls(&calls).first(), Some(&("model".to_owned(), "claude-opus-5-5".to_owned()))); +} From 3d1e5bbf8f052904fe905b30229035545c95cc12 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:37:25 -0700 Subject: [PATCH 13/31] cnx-ios D4 tests: Default is the Mac's own pick, unlisted defaults unnamed, model refusals read plain (red) Co-Authored-By: Claude Opus 5.5 --- .../TurnFailureCopyTests.swift | 13 +++++++++++ .../CnxLiveTests/AgentCatalogTests.swift | 23 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/cnx-ios/CnxKit/Tests/CnxAgentChatTests/TurnFailureCopyTests.swift b/cnx-ios/CnxKit/Tests/CnxAgentChatTests/TurnFailureCopyTests.swift index 2d070a92900e..aa8ea5ea58a9 100644 --- a/cnx-ios/CnxKit/Tests/CnxAgentChatTests/TurnFailureCopyTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxAgentChatTests/TurnFailureCopyTests.swift @@ -64,4 +64,17 @@ import Testing store.apply(.retrying(attempt: 3, maxAttempts: 10, status: nil)) #expect(store.turns.last?.retry == nil) } + + /// Full verify D4: the account refusing the chat's model reads as a sentence, never raw JSON. + @Test func aModelTheAccountLacksIsPlain() { + let raw = #"{"type":"error","status":400,"error":{"type":"invalid_request_error","message":"The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account."}}"# + let copy = TurnFailureCopy(message: raw, harness: "codex", harnessName: "Codex") + #expect(copy.kind == .modelUnavailable) + #expect(copy.sentence == "Codex can't use this model with the Mac's account. Pick another model.") + #expect(copy.detail == raw) + #expect(!TurnFailureCopy.plain(raw, harness: "codex", harnessName: "Codex").contains("{")) + // Plain text stays as it is. + #expect(TurnFailureCopy.plain("Already answered.") == "Already answered.") + } } + diff --git a/cnx-ios/CnxKit/Tests/CnxLiveTests/AgentCatalogTests.swift b/cnx-ios/CnxKit/Tests/CnxLiveTests/AgentCatalogTests.swift index deca10ce9037..0282fe94716e 100644 --- a/cnx-ios/CnxKit/Tests/CnxLiveTests/AgentCatalogTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxLiveTests/AgentCatalogTests.swift @@ -171,6 +171,29 @@ import Testing #expect(agentOnly.resolve(.macDefault)?.harness.id == "claude") } + /// Full verify D4: Default on a named agent is the Mac's own pick for that agent, and a + /// catalog default the Mac's account doesn't list is not offered by name. + @Test func defaultOnANamedAgentIsTheMacsOwnPick() throws { + let catalog = AgentCatalog(harnesses: try json(harnesses)) + #expect(catalog.resolve(AgentChatStart(harness: "codex"), folder: "/Users/p/other")?.model == "gpt-5.5") + #expect(catalog.resolve(AgentChatStart(harness: "cmux"), folder: "/Users/p/web")?.model == "z-ai/glm-5.3") + // No pick for that agent: the agent's own default (none sent). + #expect(catalog.resolve(AgentChatStart(harness: "claude"), folder: "/Users/p/web")?.model == nil) + // An explicit model still wins. + #expect(catalog.resolve(AgentChatStart(harness: "codex", model: "gpt-6-astra"))?.model == "gpt-6-astra") + } + + @Test func aDefaultTheAccountDoesNotListIsNotNamed() { + let listed = AgentHarnessChoice(id: "codex", name: "Codex", family: "codex", + models: [AgentModelChoice(id: "gpt-5.5", name: "GPT-5.5")], defaultModel: "gpt-5.5") + #expect(listed.offeredDefaultModel == "gpt-5.5") + let unlisted = AgentHarnessChoice(id: "codex", name: "Codex", family: "codex", + models: [AgentModelChoice(id: "gpt-5.5", name: "GPT-5.5")], defaultModel: "gpt-6.1-sol") + #expect(unlisted.offeredDefaultModel == nil) + // No list (Claude Code): the default stands. + #expect(AgentHarnessChoice(id: "claude", name: "Claude", family: "claude", defaultModel: "opus").offeredDefaultModel == "opus") + } + @Test func aPhonePicksOnlyModesThatAsk() { let modes = ["default", "acceptEdits", "plan", "bypassPermissions", "ask", "agent", "agent-full-access"] .map { ACPMode(id: $0, name: $0) } From e48fd1f2397e3bd472763a50a16b11a94fa22572 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:38:25 -0700 Subject: [PATCH 14/31] cnx-ios D4: Default is the Mac's own pick; a model the account lacks reads plainly; model menu in a running chat - New Agent Chat on a named agent with Default starts on the Mac's own last pick for that agent, and does not name a default the Mac's account does not list. - A refused prompt's raw JSON error ({"type":"error","status":400,...}) becomes a sentence ("Codex can't use this model with the Mac's account. Pick another model."), in the toast and on the failed-turn card. - The composer's model menu shows whenever the agent lists models, even when the running model is not among them, so a chat can switch. Co-Authored-By: Claude Opus 5.5 --- .../Sources/CnxAgentChat/ChatComposer.swift | 6 ++++-- .../Sources/CnxAgentChat/TurnFailureCopy.swift | 13 ++++++++++++- cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift | 3 ++- .../CnxKit/Sources/CnxModel/AgentCatalog.swift | 16 +++++++++++++++- .../Sources/CnxWorkspaces/AgentChooser.swift | 2 +- 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift b/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift index 8f3731156285..cea9792c1d0b 100644 --- a/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift +++ b/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift @@ -148,7 +148,9 @@ struct ChatComposer: View { .accessibilityAddTraits(.isButton) .accessibilityLabel(placeholder) } else { - if store.currentModelName != nil || !store.modes.isEmpty { modelPill } + // The menu is there whenever the agent offers models, even when the + // current one is not among them (a default the account lacks, D4). + if store.currentModelName != nil || !store.models.isEmpty || !store.modes.isEmpty { modelPill } Spacer(minLength: 0) if let usage = store.contextUsage { ContextUsageRing(usage: usage) } } @@ -309,7 +311,7 @@ struct ChatComposer: View { private func modelPillLabel(withEffort: Bool) -> some View { HStack(spacing: 4) { - Text(store.currentModelName ?? currentModeName ?? "") + Text(store.currentModelName ?? store.currentModelID ?? currentModeName ?? "Model") if withEffort, store.currentModelName != nil, let mode = currentModeName, store.currentModeID != store.modes.first?.id { Text(mode).foregroundStyle(.secondary) } diff --git a/cnx-ios/CnxKit/Sources/CnxAgentChat/TurnFailureCopy.swift b/cnx-ios/CnxKit/Sources/CnxAgentChat/TurnFailureCopy.swift index e9138e73b92e..36cd3a80437f 100644 --- a/cnx-ios/CnxKit/Sources/CnxAgentChat/TurnFailureCopy.swift +++ b/cnx-ios/CnxKit/Sources/CnxAgentChat/TurnFailureCopy.swift @@ -10,7 +10,7 @@ public struct TurnFailureCopy: Hashable, Sendable { public enum Kind: String, Hashable, Sendable { case trustPending, trustUntrusted case modelServiceUnavailable, modelServiceNotConnected, modelServiceExpired, modelServiceUnreachable - case rateLimited, unreachable, invalidKey, subscriptionLogin, proxyAuth, auth, other + case rateLimited, unreachable, invalidKey, subscriptionLogin, proxyAuth, auth, modelUnavailable, other } public let kind: Kind @@ -51,6 +51,7 @@ public struct TurnFailureCopy: Hashable, Sendable { case .proxyAuth: "\(via ?? "The route") refused \(name)’s request (\(status ?? "401")). Check that route’s key or account." case .subscriptionLogin: "\(name) is signed out or its sign-in expired." case .auth: "The provider refused \(name)’s credentials (\(status ?? "401"))." + case .modelUnavailable: "\(name) can't use this model with the Mac's account. Pick another model." case .other: parts.message.map(Self.capitalized) ?? raw } self.kind = kind @@ -58,6 +59,13 @@ public struct TurnFailureCopy: Hashable, Sendable { detail = sentence == raw ? nil : raw } + /// `message` for a toast or a refusal line: a JSON error body becomes its sentence; + /// plain text stays as it is. + public static func plain(_ message: String, harness: String? = nil, harnessName: String? = nil) -> String { + guard message.contains("{"), message.contains("}") else { return message } + return TurnFailureCopy(message: message, harness: harness, harnessName: harnessName).sentence + } + // MARK: - Classification (the Mac's order: trust, capacity and network first) static func kind(_ raw: String, parts: (code: String?, message: String?), harness: String?) -> Kind { @@ -75,6 +83,8 @@ public struct TurnFailureCopy: Hashable, Sendable { if routerRefusal, parts.code == "auth.forbidden" || raw.range(of: #"\b403\b"#, options: .regularExpression) != nil { return .modelServiceUnavailable } + // The account can't run the chat's model (Codex with a ChatGPT account, a retired model). + if matches(Self.modelUnavailable, parts.message ?? raw) { return .modelUnavailable } if matches(Self.rate, raw) { return .rateLimited } if matches(Self.unreachable, raw) { return .unreachable } let via = via(raw, harness: harness) @@ -98,6 +108,7 @@ public struct TurnFailureCopy: Hashable, Sendable { return (code, message?.isEmpty == false ? message : nil) } + private static let modelUnavailable = #"\bmodel\b.{0,80}\b(?:is not supported|isn't supported|not supported|does not exist|is not available|not available|not found|unknown model|no access)|(?:unknown|unsupported|invalid) model"# private static let rate = #"\b(?:429|529)\b|rate.?limit|too many requests|overloaded|at capacity|no non-exhausted|usage limit|quota|\b503\b|service unavailable"# private static let unreachable = #"ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|fetch failed|socket hang up|connection (?:refused|reset|closed|error)|could not (?:connect|resolve)|network (?:error|is unreachable)|\bunreachable\b|timed out"# private static let invalidKey = #"invalid[ _-]?(?:x-)?api[ _-]?key|incorrect api key|api key (?:is )?(?:missing|not set|not found|invalid)|no api key|(?:ANTHROPIC|OPENAI)_API_KEY"# diff --git a/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift b/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift index 39678fc43614..c63605545096 100644 --- a/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift +++ b/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift @@ -545,7 +545,8 @@ public final class AcpmuxSessionSource: AgentSessionSource { case "remote.lasting_grant_refused": return "From an iPhone you can allow once or deny. Allow for this chat on the Mac." default: if error.message.hasPrefix("no pending permission") { return "Already answered on your Mac." } - return error.message + // An agent's raw JSON error (a model the account lacks) reads as a sentence. + return TurnFailureCopy.plain(error.message) } } return LinkController.reason(error) diff --git a/cnx-ios/CnxKit/Sources/CnxModel/AgentCatalog.swift b/cnx-ios/CnxKit/Sources/CnxModel/AgentCatalog.swift index e2710b164140..996eae73d7c6 100644 --- a/cnx-ios/CnxKit/Sources/CnxModel/AgentCatalog.swift +++ b/cnx-ios/CnxKit/Sources/CnxModel/AgentCatalog.swift @@ -60,6 +60,14 @@ public struct AgentHarnessChoice: Identifiable, Hashable, Sendable { } public var canStart: Bool { unavailableReason == nil } + + /// ``defaultModel`` when this Mac's account offers it: a harness that lists its models + /// and leaves the default out can't run it (full verify D4), so Default is not named. + public var offeredDefaultModel: String? { + guard let defaultModel else { return nil } + guard !models.isEmpty else { return defaultModel } + return models.contains { $0.id == defaultModel } ? defaultModel : nil + } } /// The agents a Mac offers a phone: the Mac's catalog (`_acpmux/harnesses`, `_acpmux/models`) @@ -102,7 +110,13 @@ public struct AgentCatalog: Hashable, Sendable { } } guard let harness = start.harness.map({ harness($0) }) ?? defaultChoice, harness.canStart else { return nil } - guard let model = start.model, model != harness.defaultModel, model != "default" else { return (harness, nil) } + guard let model = start.model, model != "default" else { + // Default on a named agent: the Mac's own pick for that agent (in `folder`, else + // anywhere), so a phone's chat runs the model the Mac's chats do (full verify D4). + let picked = lastPicks?.picks(in: folder).first { $0.harness == harness.id }?.model + return (harness, picked.flatMap { $0 == harness.defaultModel || $0 == "default" ? nil : $0 }) + } + guard model != harness.defaultModel else { return (harness, nil) } return (harness, model) } diff --git a/cnx-ios/CnxKit/Sources/CnxWorkspaces/AgentChooser.swift b/cnx-ios/CnxKit/Sources/CnxWorkspaces/AgentChooser.swift index 4bdfb8a6ed5f..f1b56a5aaf1f 100644 --- a/cnx-ios/CnxKit/Sources/CnxWorkspaces/AgentChooser.swift +++ b/cnx-ios/CnxKit/Sources/CnxWorkspaces/AgentChooser.swift @@ -65,7 +65,7 @@ struct AgentChooser: View { } if let harness = selectedHarness(in: catalog), !harness.models.isEmpty { Section("Model") { - modelRow(id: nil, name: harness.defaultModel.map { "Default (\(modelName($0, in: harness)))" } ?? "Default", + modelRow(id: nil, name: harness.offeredDefaultModel.map { "Default (\(modelName($0, in: harness)))" } ?? "Default", reason: nil) ForEach(harness.models.filter { $0.id != "default" }) { model in modelRow(id: model.id, name: model.name, reason: model.unavailableReason) From c859cf273c030d4c48afc953dad4d0cf382be298 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:40:02 -0700 Subject: [PATCH 15/31] cnx-ios D5, D8: no autocorrect in name fields and the agent composer; a second tab request says so D8: a tab request while another opens returned silently; it now shows "Still opening ..." and every request is logged with its option. Co-Authored-By: Claude Opus 5.5 --- cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift | 6 ++++++ cnx-ios/CnxKit/Sources/CnxChief/ConversationsHome.swift | 3 +++ cnx-ios/CnxKit/Sources/CnxShell/TeamSidebar.swift | 3 +++ .../Sources/CnxWorkspaces/TabMoveAndGroupSheets.swift | 3 +++ .../CnxKit/Sources/CnxWorkspaces/WorkspaceScreen.swift | 3 +++ .../Sources/CnxWorkspaces/WorkspaceTabsSource.swift | 8 +++++++- .../CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift | 9 +++++++++ 7 files changed, 34 insertions(+), 1 deletion(-) diff --git a/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift b/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift index cea9792c1d0b..e3ddfc56a6a1 100644 --- a/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift +++ b/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift @@ -126,6 +126,9 @@ struct ChatComposer: View { attachmentStrip } TextField(placeholder, text: $text, axis: .vertical) + // Prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) .font(.body) .lineLimit(1...8) .focused(focus) @@ -525,6 +528,9 @@ struct QueuedPromptsView: View { .alert("Edit queued prompt", isPresented: Binding(get: { editing != nil }, set: { if !$0 { editing = nil } }), presenting: editing) { item in TextField("Message", text: $editText, axis: .vertical) + // Prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) Button("Cancel", role: .cancel) {} Button("Save") { store.editQueued(item.id, text: editText) } .disabled(editText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) diff --git a/cnx-ios/CnxKit/Sources/CnxChief/ConversationsHome.swift b/cnx-ios/CnxKit/Sources/CnxChief/ConversationsHome.swift index 39e0813f7d1f..f5244743bdcf 100644 --- a/cnx-ios/CnxKit/Sources/CnxChief/ConversationsHome.swift +++ b/cnx-ios/CnxKit/Sources/CnxChief/ConversationsHome.swift @@ -293,6 +293,9 @@ public struct ConversationsHome: View { .task { sources.refreshIfNeeded() } .alert("New Chief", isPresented: $namingChief) { TextField("Name", text: $newChiefName) + // Names and prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) .accessibilityIdentifier("home.chief.newName") Button("Cancel", role: .cancel) {} Button("Create") { diff --git a/cnx-ios/CnxKit/Sources/CnxShell/TeamSidebar.swift b/cnx-ios/CnxKit/Sources/CnxShell/TeamSidebar.swift index e81fbf4ec582..c847d1cf7af3 100644 --- a/cnx-ios/CnxKit/Sources/CnxShell/TeamSidebar.swift +++ b/cnx-ios/CnxKit/Sources/CnxShell/TeamSidebar.swift @@ -62,6 +62,9 @@ struct TeamSidebar: View { .background(theme.colors.bg.color.ignoresSafeArea()) .alert("Create team", isPresented: $creatingTeam) { TextField("Team name", text: $newTeamName) + // Names and prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) Button("Cancel", role: .cancel) { newTeamName = "" } Button("Create") { shell.createTeam(named: newTeamName); newTeamName = "" } } message: { diff --git a/cnx-ios/CnxKit/Sources/CnxWorkspaces/TabMoveAndGroupSheets.swift b/cnx-ios/CnxKit/Sources/CnxWorkspaces/TabMoveAndGroupSheets.swift index 2ae09732517d..328a5879d97a 100644 --- a/cnx-ios/CnxKit/Sources/CnxWorkspaces/TabMoveAndGroupSheets.swift +++ b/cnx-ios/CnxKit/Sources/CnxWorkspaces/TabMoveAndGroupSheets.swift @@ -57,6 +57,9 @@ struct TabMoveAndGroupSheets: ViewModifier { set: { shown in if !shown { source.renamingGroupTab = nil } } ), presenting: source.renamingGroupTab) { tab in TextField(tab.group?.name.isEmpty == false ? tab.group!.name : "Name", text: $groupName) + // Names and prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) Button("Cancel", role: .cancel) {} Button("Rename") { Task { await source.editGroup(.rename(groupName), tab.id) } } } diff --git a/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspaceScreen.swift b/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspaceScreen.swift index dd6f649eeec7..330822957ca2 100644 --- a/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspaceScreen.swift +++ b/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspaceScreen.swift @@ -108,6 +108,9 @@ public struct WorkspaceScreen: View { set: { shown in if !shown { source.renamingTab = nil } } )) { TextField(renamePlaceholder, text: $renameText) + // Names and prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) Button("Cancel", role: .cancel) {} Button("Rename") { if let id = renameID { Task { await source.rename(id, to: renameText) } } diff --git a/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspaceTabsSource.swift b/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspaceTabsSource.swift index d84a33bd9d71..10d39f5a149d 100644 --- a/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspaceTabsSource.swift +++ b/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspaceTabsSource.swift @@ -368,7 +368,13 @@ public final class WorkspaceTabsSource: TabsDataSource { showUnavailable(option) return nil } - guard pendingTabTitle == nil else { return nil } + // One tab at a time; a second request says so instead of doing nothing (full verify D8). + if let pending = pendingTabTitle { + tabsLog.info("tab request \(option.id, privacy: .public) while \(pending, privacy: .public) is opening") + show("Still opening \(pending). Try again once it's open.") + return nil + } + tabsLog.info("tab request \(option.id, privacy: .public) on \(self.macName, privacy: .public)") pendingTabTitle = option.title Task { @MainActor [weak self] in guard let self else { return } diff --git a/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift b/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift index 335c883e536d..7da28b1a0a82 100644 --- a/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift +++ b/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift @@ -175,6 +175,9 @@ public struct WorkspacesList: View { } .alert("Rename Workspace", isPresented: isPresented($renaming), presenting: renaming) { ws in TextField("Name", text: $renameText) + // Names and prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) .submitLabel(.done) Button("Cancel", role: .cancel) {} Button("Rename") { creator.rename(ws, to: renameText) } @@ -187,6 +190,9 @@ public struct WorkspacesList: View { view .alert("Move Workspace to New Group", isPresented: isPresented($newGroupFor), presenting: newGroupFor) { (ws: Workspace) in TextField(MacWorkspaceGroup.newGroupName, text: $groupNameText) + // Names and prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) Button("Cancel", role: .cancel) {} Button("Create") { withAnimation(theme.motion.smooth.animation) { creator.moveToNewGroup(ws, name: groupNameText) } @@ -194,6 +200,9 @@ public struct WorkspacesList: View { } .alert("Rename Group", isPresented: isPresented($renamingGroup), presenting: renamingGroup) { (target: GroupTarget) in TextField("Name", text: $groupNameText) + // Names and prompts are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) .submitLabel(.done) Button("Cancel", role: .cancel) {} Button("Rename") { creator.renameGroup(target.group, on: target.macID, to: groupNameText) } From 71bdf450d2b97611ee207532cbda3196e3decc5e Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:41:39 -0700 Subject: [PATCH 16/31] cnx-ios D7, D11, D6, D12: placeholders never cover the Mac lines (Connect works); plain diff and Feed wording - D7/D11: while loading, the skeleton was an overlay over the List and drew over the Mac section lines and took their Connect taps; with Mac lines it now sits below them as a non-interactive row. - D6: a snippet edit's rows read without a line number, never "line 0". - D12: an empty terminal finished event reads "Finished its turn in the terminal..."; an Allow reads "Allowed" / "Allowed once", not "Allowed: Allow". Co-Authored-By: Claude Opus 5.5 --- .../CnxKit/Sources/CnxDiff/DiffFileScreen.swift | 11 +++++++---- cnx-ios/CnxKit/Sources/CnxFeed/FeedStore.swift | 15 +++++++++++++-- cnx-ios/CnxKit/Sources/CnxLive/TerminalFeed.swift | 4 +++- .../Sources/CnxWorkspaces/WorkspacesList.swift | 13 ++++++++++++- .../Tests/CnxLiveTests/FeedPendingTests.swift | 2 +- .../Tests/CnxLiveTests/TerminalFeedTests.swift | 2 +- 6 files changed, 37 insertions(+), 10 deletions(-) diff --git a/cnx-ios/CnxKit/Sources/CnxDiff/DiffFileScreen.swift b/cnx-ios/CnxKit/Sources/CnxDiff/DiffFileScreen.swift index 8ebf26c1231d..69dcc9e20b94 100644 --- a/cnx-ios/CnxKit/Sources/CnxDiff/DiffFileScreen.swift +++ b/cnx-ios/CnxKit/Sources/CnxDiff/DiffFileScreen.swift @@ -541,11 +541,14 @@ extension DiffLine { } } + /// VoiceOver's reading of the row. A snippet edit has no line numbers (where it sits in + /// the file is unknown): it reads without one, never "line 0" (full verify D6). var accessibilityText: String { - switch kind { - case .added: "added line \(newNumber ?? 0): \(text)" - case .removed: "removed line \(oldNumber ?? 0): \(text)" - case .context: "line \(newNumber ?? 0): \(text)" + func at(_ number: Int?) -> String { number.map { " \($0)" } ?? "" } + return switch kind { + case .added: "added line\(at(newNumber)): \(text)" + case .removed: "removed line\(at(oldNumber)): \(text)" + case .context: "line\(at(newNumber)): \(text)" } } } diff --git a/cnx-ios/CnxKit/Sources/CnxFeed/FeedStore.swift b/cnx-ios/CnxKit/Sources/CnxFeed/FeedStore.swift index 90267225e74f..e60b9858bb26 100644 --- a/cnx-ios/CnxKit/Sources/CnxFeed/FeedStore.swift +++ b/cnx-ios/CnxKit/Sources/CnxFeed/FeedStore.swift @@ -483,7 +483,7 @@ public final class FeedStore { mutate(id) { guard $0.attempt == attempt else { return } $0.pending = nil - $0.resolution = "Allowed: \(option.name)" + $0.resolution = Self.allowedText(option.name) $0.isRead = true } } catch { @@ -513,9 +513,20 @@ public final class FeedStore { applyRespond(id, option: option) } + /// The card's line after an Allow: the option's own words when they say more than + /// "Allow" ("Allowed: Always for this chat"), else "Allowed" (full verify D12: "Allowed: Allow"). + static func allowedText(_ name: String) -> String { + let plain = name.trimmingCharacters(in: .whitespacesAndNewlines) + switch plain.lowercased() { + case "", "allow", "yes", "approve", "ok": return "Allowed" + case "allow once", "yes, once", "once": return "Allowed once" + default: return "Allowed: \(plain)" + } + } + private func applyRespond(_ id: String, option: ACPPermissionOption) { mutate(id) { - $0.resolution = option.isAllow ? "Allowed: \(option.name)" : "Denied" + $0.resolution = option.isAllow ? Self.allowedText(option.name) : "Denied" $0.isRead = true } } diff --git a/cnx-ios/CnxKit/Sources/CnxLive/TerminalFeed.swift b/cnx-ios/CnxKit/Sources/CnxLive/TerminalFeed.swift index 7d1998b2d8a0..9dd7fd984cd3 100644 --- a/cnx-ios/CnxKit/Sources/CnxLive/TerminalFeed.swift +++ b/cnx-ios/CnxKit/Sources/CnxLive/TerminalFeed.swift @@ -343,7 +343,9 @@ public final class TerminalFeed { .filter { !$0.isEmpty && $0 != headline } .joined(separator: "\n\n") switch (headline.isEmpty, detail.isEmpty) { - case (true, true): return kind == .needsInput ? "Needs your input." : kind == .failed ? "Reported an error." : "Finished." + case (true, true): return kind == .needsInput ? "Needs your input." : kind == .failed ? "Reported an error." + // The hook said nothing more (Codex's finished event has no body): say where (D12). + : "Finished its turn in the terminal. Open it to see the result." case (false, true): return headline.hasSuffix(".") || headline.hasSuffix("?") || headline.hasSuffix("!") ? headline : headline + "." case (true, false): return detail case (false, false): return "**\(headline):** \(detail)" diff --git a/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift b/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift index 7da28b1a0a82..b6a093c798b3 100644 --- a/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift +++ b/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift @@ -384,6 +384,17 @@ public struct WorkspacesList: View { } } } + // Loading with Mac lines shown: the placeholder rows go below them, never over + // them (full verify D7, D11: an overlay drew over the lines and took their + // Connect taps). + if inbox.isLoading, !inbox.folds.isEmpty { + SkeletonList(rows: 4, leadingSquare: nil) + .allowsHitTesting(false) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .accessibilityIdentifier("workspaces.loading") + } } .listStyle(.plain) .onScrollGeometryChange(for: CGFloat.self) { $0.containerSize.height - $0.contentInsets.bottom } action: { _, height in @@ -395,7 +406,7 @@ public struct WorkspacesList: View { .overlay(alignment: .topLeading) { closeAnchor } .overlay { if inbox.isLoading { - skeleton + if inbox.folds.isEmpty { skeleton } } else if inbox.isEmpty { emptyState } diff --git a/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPendingTests.swift b/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPendingTests.swift index b5da3ae0fca8..9f73d628d165 100644 --- a/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPendingTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPendingTests.swift @@ -49,7 +49,7 @@ import Testing #expect(actions.responds == 1) actions.release?.resume() await settle { store.item(id: "p1")?.resolution != nil } - #expect(store.item(id: "p1")?.resolution == "Allowed: Allow once") + #expect(store.item(id: "p1")?.resolution == "Allowed once") #expect(store.item(id: "p1")?.pending == nil) } diff --git a/cnx-ios/CnxKit/Tests/CnxLiveTests/TerminalFeedTests.swift b/cnx-ios/CnxKit/Tests/CnxLiveTests/TerminalFeedTests.swift index aafac4e091ae..3c0be9eed208 100644 --- a/cnx-ios/CnxKit/Tests/CnxLiveTests/TerminalFeedTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxLiveTests/TerminalFeedTests.swift @@ -90,7 +90,7 @@ import Testing #expect(finished?.workspaceID == "ws-a") #expect(finished?.workspaceTitle == "cmux") #expect(finished?.tabID == "s1") - #expect(finished?.markdown == "Finished.") + #expect(finished?.markdown == "Finished its turn in the terminal. Open it to see the result.") #expect(finished?.opensTerminal == true) #expect(finished?.createdAt == Date(timeIntervalSince1970: 1000)) From 8c4c737126b07921053b57d30e2f1deb17f8a2b9 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:44:08 -0700 Subject: [PATCH 17/31] cnx-ios D12, D13, D14: Codex's bare "finished" says where to look; Home previews the newest reply; VoiceOver's time matches "now" Co-Authored-By: Claude Opus 5.5 --- .../CnxKit/Sources/CnxAgentChat/AgentChatStore.swift | 7 +++++++ .../CnxKit/Sources/CnxChief/ConversationsHome.swift | 2 +- cnx-ios/CnxKit/Sources/CnxLive/TerminalFeed.swift | 4 ++++ cnx-ios/CnxKit/Sources/CnxModel/RelativeStamp.swift | 11 +++++++++++ .../CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift | 2 +- .../CnxAgentChatTests/TurnFailureCopyTests.swift | 10 ++++++++++ .../CnxModelTests/ServiceTeamCreationTests.swift | 11 +++++++++++ 7 files changed, 45 insertions(+), 2 deletions(-) diff --git a/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentChatStore.swift b/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentChatStore.swift index 2a24906257c7..c6b101fab460 100644 --- a/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentChatStore.swift +++ b/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentChatStore.swift @@ -79,6 +79,13 @@ public struct AgentTurn: Identifiable, Hashable, Sendable { return nil } + /// The turn's last agent message: what a list's preview shows (the chat's newest + /// words, not the turn's first message, full verify D13). + public var lastAnswerMarkdown: String? { + for case .message(_, let text) in items.reversed() where !text.isEmpty { return text } + return nil + } + /// The final answer text (copy, share). public var answerMarkdown: String { items.compactMap { diff --git a/cnx-ios/CnxKit/Sources/CnxChief/ConversationsHome.swift b/cnx-ios/CnxKit/Sources/CnxChief/ConversationsHome.swift index f5244743bdcf..27eb43fe6170 100644 --- a/cnx-ios/CnxKit/Sources/CnxChief/ConversationsHome.swift +++ b/cnx-ios/CnxKit/Sources/CnxChief/ConversationsHome.swift @@ -95,7 +95,7 @@ public struct ConversationsHome: View { private func conversation(_ tab: WorkspaceTab, in workspace: Workspace) -> AgentConversation { let chat = AgentSessionDirectory.shared.existingStore(id: tab.id) let lastTurn = chat?.turns.last - let answer: String? = lastTurn.map { $0.answerMarkdown.isEmpty ? $0.prompt : $0.answerMarkdown } + let answer: String? = lastTurn.map { $0.lastAnswerMarkdown ?? $0.prompt } let agentTabs = workspace.tabs.filter { $0.kind == .agent } return AgentConversation( workspaceID: workspace.id, tabID: tab.id, diff --git a/cnx-ios/CnxKit/Sources/CnxLive/TerminalFeed.swift b/cnx-ios/CnxKit/Sources/CnxLive/TerminalFeed.swift index 9dd7fd984cd3..d885d1171a94 100644 --- a/cnx-ios/CnxKit/Sources/CnxLive/TerminalFeed.swift +++ b/cnx-ios/CnxKit/Sources/CnxLive/TerminalFeed.swift @@ -346,6 +346,10 @@ public final class TerminalFeed { case (true, true): return kind == .needsInput ? "Needs your input." : kind == .failed ? "Reported an error." // The hook said nothing more (Codex's finished event has no body): say where (D12). : "Finished its turn in the terminal. Open it to see the result." + case (false, true) where kind == .finished && ["finished", "done", "completed", "turn complete", "turn completed"] + .contains(headline.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".!"))): + // "Codex finished" and nothing else (Codex's hook): say where to look (D12). + return "Finished its turn in the terminal. Open it to see the result." case (false, true): return headline.hasSuffix(".") || headline.hasSuffix("?") || headline.hasSuffix("!") ? headline : headline + "." case (true, false): return detail case (false, false): return "**\(headline):** \(detail)" diff --git a/cnx-ios/CnxKit/Sources/CnxModel/RelativeStamp.swift b/cnx-ios/CnxKit/Sources/CnxModel/RelativeStamp.swift index c669966a45bf..b2cfff297ee1 100644 --- a/cnx-ios/CnxKit/Sources/CnxModel/RelativeStamp.swift +++ b/cnx-ios/CnxKit/Sources/CnxModel/RelativeStamp.swift @@ -14,6 +14,17 @@ public struct RelativeStamp: Sendable { self.calendar = calendar } + /// What VoiceOver says for `date`, matching ``string(for:)``: "now" under a minute + /// (also for a time a little ahead of the minute clock, which read "in 10 seconds", + /// full verify D14), else "5 minutes ago". + public func spoken(for date: Date) -> String { + let seconds = now.timeIntervalSince(date) + if seconds < 60 { return "now" } + let formatter = RelativeDateTimeFormatter() + formatter.calendar = calendar + return formatter.localizedString(for: date, relativeTo: now) + } + public func string(for date: Date) -> String { let seconds = max(0, now.timeIntervalSince(date)) switch seconds { diff --git a/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift b/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift index b6a093c798b3..775186ef9078 100644 --- a/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift +++ b/cnx-ios/CnxKit/Sources/CnxWorkspaces/WorkspacesList.swift @@ -1321,7 +1321,7 @@ struct WorkspaceRow: View { let line = StatusLine.words(workspace, status: status, macName: macName) parts.append(contentsOf: line) if workspace.hasActivityTime, status != .creating { - parts.append(RelativeDateTimeFormatter().localizedString(for: workspace.lastActivityAt, relativeTo: MinuteClock.shared.now)) + parts.append(RelativeStamp(now: MinuteClock.shared.now).spoken(for: workspace.lastActivityAt)) } return parts.joined(separator: ", ") } diff --git a/cnx-ios/CnxKit/Tests/CnxAgentChatTests/TurnFailureCopyTests.swift b/cnx-ios/CnxKit/Tests/CnxAgentChatTests/TurnFailureCopyTests.swift index aa8ea5ea58a9..7ac331a079a6 100644 --- a/cnx-ios/CnxKit/Tests/CnxAgentChatTests/TurnFailureCopyTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxAgentChatTests/TurnFailureCopyTests.swift @@ -78,3 +78,13 @@ import Testing } } + +/// Full verify D13: a chat's preview is its newest agent message, not the turn's first. +@Suite struct LastAnswerTests { + @Test func theLastMessageOfTheTurn() { + var turn = AgentTurn(id: "t", prompt: "update both files", promptAt: Date()) + turn.items = [.message(id: "a", markdown: "I'll update both files"), .message(id: "b", markdown: "Done: both updated.")] + #expect(turn.lastAnswerMarkdown == "Done: both updated.") + #expect(AgentTurn(id: "u", prompt: "x", promptAt: Date()).lastAnswerMarkdown == nil) + } +} diff --git a/cnx-ios/CnxKit/Tests/CnxModelTests/ServiceTeamCreationTests.swift b/cnx-ios/CnxKit/Tests/CnxModelTests/ServiceTeamCreationTests.swift index e8b1804d656c..655e1ece5d5c 100644 --- a/cnx-ios/CnxKit/Tests/CnxModelTests/ServiceTeamCreationTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxModelTests/ServiceTeamCreationTests.swift @@ -29,3 +29,14 @@ import Testing #expect(throws: ServiceTeamCreation.Failure.failed) { try creation.team(from: Data(), status: 502) } } } + +/// Full verify D14: VoiceOver's time matches the row's "now". +@Suite struct RelativeStampSpokenTests { + @Test func underAMinuteAndSlightlyAheadIsNow() { + let now = Date(timeIntervalSince1970: 1_000_000) + let stamp = RelativeStamp(now: now) + #expect(stamp.spoken(for: now.addingTimeInterval(10)) == "now") + #expect(stamp.spoken(for: now.addingTimeInterval(-30)) == "now") + #expect(stamp.spoken(for: now.addingTimeInterval(-300)) != "now") + } +} From 4a31def5f5a6a6dbc7141548029fd9990c20537f Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:45:28 -0700 Subject: [PATCH 18/31] cnx-ios D10: short inline Feed rows draw as native text, not a web view each Co-Authored-By: Claude Opus 5.5 --- cnx-ios/CnxKit/Sources/CnxFeed/FeedCell.swift | 33 +++++++++++---- .../Sources/CnxFeed/FeedPlainText.swift | 41 +++++++++++++++++++ .../CnxLiveTests/FeedPlainTextTests.swift | 22 ++++++++++ 3 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 cnx-ios/CnxKit/Sources/CnxFeed/FeedPlainText.swift create mode 100644 cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPlainTextTests.swift diff --git a/cnx-ios/CnxKit/Sources/CnxFeed/FeedCell.swift b/cnx-ios/CnxKit/Sources/CnxFeed/FeedCell.swift index e510934f2597..3e95f6cbb08c 100644 --- a/cnx-ios/CnxKit/Sources/CnxFeed/FeedCell.swift +++ b/cnx-ios/CnxKit/Sources/CnxFeed/FeedCell.swift @@ -39,9 +39,16 @@ struct FeedCell: View { self.item = item self.context = context self.expanded = expanded - _contentHeight = State(initialValue: WebMarkdownRenderer.shared.cachedHeight(markdown: item.markdown, style: .body)) + let plain = FeedPlainText(item.markdown) + inlineText = plain.isInline ? plain.attributed : nil + // Native rows never overflow; only web rows need the measured height. + _contentHeight = State(initialValue: inlineText == nil + ? WebMarkdownRenderer.shared.cachedHeight(markdown: item.markdown, style: .body) : 0) } + /// Short inline text drawn natively instead of in a web view (D10); nil: the web view. + private let inlineText: AttributedString? + private var store: FeedStore { context.store } private func open() { context.open(item) } @@ -211,12 +218,24 @@ struct FeedCell: View { private var bodyView: some View { VStack(alignment: .leading, spacing: 6) { - WebMarkdownView( - markdown: item.markdown, style: .body, - collapsedHeight: collapsedHeight, isExpanded: expanded || !isOverflowing, - onTap: open - ) { height in - contentHeight = height + Group { + if let inlineText { + Text(inlineText) + .font(.body) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .contentShape(.rect) + .onTapGesture(perform: open) + } else { + WebMarkdownView( + markdown: item.markdown, style: .body, + collapsedHeight: collapsedHeight, isExpanded: expanded || !isOverflowing, + onTap: open + ) { height in + contentHeight = height + } + } } .overlay(alignment: .bottom) { if isOverflowing, !expanded { diff --git a/cnx-ios/CnxKit/Sources/CnxFeed/FeedPlainText.swift b/cnx-ios/CnxKit/Sources/CnxFeed/FeedPlainText.swift new file mode 100644 index 000000000000..52d3471d79f9 --- /dev/null +++ b/cnx-ios/CnxKit/Sources/CnxFeed/FeedPlainText.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Feed rows whose text needs no web view (full verify D10: 86 events, each +/// with a pooled WKWebView measured in JavaScript, hitched while scrolling). +/// Short text with at most inline markdown (bold, italics, code spans, links) +/// draws as native text; anything with blocks (code fences, lists, quotes, +/// headings, tables, HTML) keeps the web renderer. +public struct FeedPlainText: Sendable { + /// At most this many characters and lines: always under the collapsed + /// height (8 body lines), so no Show more is needed. + static let maxCharacters = 280 + static let maxLines = 4 + + public let markdown: String + + public init(_ markdown: String) { + self.markdown = markdown + } + + /// Whether native text draws this as the web view would. + public var isInline: Bool { + guard markdown.count <= Self.maxCharacters, !markdown.contains("```"), !markdown.contains("<") else { return false } + let lines = markdown.split(separator: "\n", omittingEmptySubsequences: false) + guard lines.count <= Self.maxLines else { return false } + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("#") || trimmed.hasPrefix(">") || trimmed.hasPrefix("|") || trimmed.hasPrefix("- ") + || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") || trimmed.hasPrefix(" ") + || trimmed.range(of: #"^\d+[.)] "#, options: .regularExpression) != nil { + return false + } + if line.hasPrefix(" ") || line.hasPrefix("\t") { return false } + } + return true + } + + /// The inline markdown as attributed text; nil when it does not parse. + public var attributed: AttributedString? { + try? AttributedString(markdown: markdown, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) + } +} diff --git a/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPlainTextTests.swift b/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPlainTextTests.swift new file mode 100644 index 000000000000..a38bc1022f8d --- /dev/null +++ b/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPlainTextTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing +@testable import CnxFeed + +/// Full verify D10: short inline rows skip the web view. +@Suite struct FeedPlainTextTests { + @Test func shortInlineTextIsNative() { + #expect(FeedPlainText("Finished its turn in the terminal. Open it to see the result.").isInline) + #expect(FeedPlainText("**Needs approval:** run `ls -la` in **web**").isInline) + #expect(FeedPlainText("**Needs approval:** run `ls`").attributed != nil) + } + + @Test func blocksKeepTheWebRenderer() { + #expect(!FeedPlainText("Here:\n```swift\nlet a = 1\n```").isInline) + #expect(!FeedPlainText("- one\n- two").isInline) + #expect(!FeedPlainText("# Title").isInline) + #expect(!FeedPlainText("| a | b |\n|---|---|").isInline) + #expect(!FeedPlainText("1. first").isInline) + #expect(!FeedPlainText(String(repeating: "word ", count: 80)).isInline) + #expect(!FeedPlainText("a\nb\nc\nd\ne").isInline) + } +} From 903b7acd275bb5f9efd7b9c909d53bd9b059fee4 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:53:45 -0700 Subject: [PATCH 19/31] acpmux review fix: skip only a catalog start model, and only when the harness lists catalog ids A saved pick is never skipped. A harness whose reported ids are not the catalog's (Claude Code's aliases) keeps the catalog default; DSH route ids match as model_catalog::resolve accepts them. Co-Authored-By: Claude Opus 5.5 --- cmux-tui/crates/acpmux/src/hub/lifecycle.rs | 6 ++- .../crates/acpmux/src/hub/start_defaults.rs | 39 ++++++++++++++++--- .../crates/acpmux/tests/start_defaults.rs | 5 ++- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs index ded0e25eb2da..7862682aeb66 100644 --- a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs +++ b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs @@ -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), @@ -216,8 +219,7 @@ impl Hub { let m = if !catalog.is_empty() && !catalog.iter().any(|id| id == m) && catalog.contains(&full) { full } else { m.clone() }; // A start value this session's harness does not list (its account // lacks it): the harness accepts set_model, then fails every prompt. - let reported = self.reported_model_ids(agent); - if model_soft && !super::start_defaults::offers(&reported, &m) { + 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 { diff --git a/cmux-tui/crates/acpmux/src/hub/start_defaults.rs b/cmux-tui/crates/acpmux/src/hub/start_defaults.rs index 29224e76d117..9745edf979ef 100644 --- a/cmux-tui/crates/acpmux/src/hub/start_defaults.rs +++ b/cmux-tui/crates/acpmux/src/hub/start_defaults.rs @@ -237,7 +237,10 @@ impl Hub { curated .as_ref() .and_then(|c| c.default_model.clone()) - .filter(|m| offers(&reported, m)) + .filter(|m| { + let ids: Vec = c_ids(curated.as_ref()); + offers(&reported, &ids, m) + }) .map(|value| StartValue { value, source: StartSource::Catalog }) }), }; @@ -273,6 +276,12 @@ impl Hub { .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( @@ -311,8 +320,28 @@ impl Hub { } } -/// Whether a harness that reported `reported` offers `model`: any model when -/// it reported none, else only a listed one. -pub(super) fn offers(reported: &[String], model: &str) -> bool { - reported.is_empty() || reported.iter().any(|id| id == model) +/// The curated model ids of a catalog entry. +fn c_ids(curated: Option<&CatalogHarness>) -> Vec { + 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::>(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)) } diff --git a/cmux-tui/crates/acpmux/tests/start_defaults.rs b/cmux-tui/crates/acpmux/tests/start_defaults.rs index a15c952224fe..c8fca3318e5a 100644 --- a/cmux-tui/crates/acpmux/tests/start_defaults.rs +++ b/cmux-tui/crates/acpmux/tests/start_defaults.rs @@ -223,6 +223,8 @@ async fn a_catalog_default_the_harness_does_not_offer_is_not_used() { let (dir, calls) = home("not-offered"); let mut config: Value = serde_json::from_slice(&std::fs::read(dir.join("config.json")).unwrap()).unwrap(); config["harnesses"]["codex"]["env"]["FAKE_MODELS"] = json!("gpt-5.5,gpt-6-astra"); + // Claude Code lists aliases, not the catalog's ids: the catalog default still applies. + config["harnesses"]["claude"]["env"]["FAKE_MODELS"] = json!("default,opus"); std::fs::write(dir.join("config.json"), serde_json::to_vec_pretty(&config).unwrap()).unwrap(); let daemon = hub(&dir); let mut app = client(&daemon); @@ -235,7 +237,8 @@ async fn a_catalog_default_the_harness_does_not_offer_is_not_used() { assert!(harnesses["harnesses"]["codex"]["start"]["model"].is_null(), "{}", harnesses["harnesses"]["codex"]["start"]); app.new_chat("codex").await; assert!(!take_calls(&calls).iter().any(|(k, _)| k == "model")); - // A harness that lists no models (Claude Code) still starts on the catalog default. + // A harness whose ids are not the catalog's (Claude Code's aliases) still starts on the catalog default. + app.new_chat("claude").await; app.new_chat("claude").await; assert_eq!(take_calls(&calls).first(), Some(&("model".to_owned(), "claude-opus-5-5".to_owned()))); } From e96061c4508dbbe9eadfe2691aba7861e8c77975 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:54:33 -0700 Subject: [PATCH 20/31] cnx-hostd review fix: no workspace folder without a resolvable HOME; compare in the stored spelling as acpmux does Co-Authored-By: Claude Opus 5.5 --- cnx-ios/rust/crates/cnx-hostd/src/services.rs | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/cnx-ios/rust/crates/cnx-hostd/src/services.rs b/cnx-ios/rust/crates/cnx-hostd/src/services.rs index b9678cb45fb2..ce7c64516d3b 100644 --- a/cnx-ios/rust/crates/cnx-hostd/src/services.rs +++ b/cnx-ios/rust/crates/cnx-hostd/src/services.rs @@ -671,10 +671,32 @@ pub fn shareable_workspace_folders(folders: Vec, home: &Path) -> Vec Option { + let canonical = std::fs::canonicalize(path).ok()?; + #[cfg(target_vendor = "apple")] + { + use std::os::fd::AsRawFd; + let file = std::fs::File::open(&canonical).ok()?; + let mut buffer = vec![0u8; libc::PATH_MAX as usize]; + // SAFETY: F_GETPATH writes at most PATH_MAX bytes into `buffer`, which holds + // PATH_MAX bytes, for a descriptor this function owns. + if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETPATH, buffer.as_mut_ptr()) } != -1 { + let end = buffer.iter().position(|&b| b == 0).unwrap_or(buffer.len()); + return Some(PathBuf::from(String::from_utf8_lossy(&buffer[..end]).into_owned())); + } + } + Some(canonical) +} + +/// `CNX_FILE_ROOTS` (the Chat Folders) and the shareable workspace folders. Without a +/// home folder that resolves, no workspace folder is shared (one might be home). pub fn shared_file_roots(file_roots: &[PathBuf], folders: Vec, home: &Path) -> Vec { - let home = std::fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf()); let mut roots = file_roots.to_vec(); + let Some(home) = stored_spelling(home) else { return roots }; + let folders = folders.iter().filter_map(|f| stored_spelling(f)).collect(); for folder in shareable_workspace_folders(folders, &home) { if !roots.contains(&folder) { roots.push(folder); @@ -694,8 +716,10 @@ async fn file(config: &ServiceConfig, args: &Value, sender: &ChannelSender) -> R let (resolved, roots) = match confine(&config.file_roots, Path::new(requested)) { Ok(resolved) => (resolved, config.file_roots.clone()), Err(refusal) if refusal.code == "forbidden" => { - let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")); - // No daemon answer: only the Chat Folders (the refusal stands). + // No home folder or no daemon answer: only the Chat Folders (the refusal stands). + let Some(home) = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(PathBuf::from) else { + return Err(refusal); + }; let folders = daemon_folders(config).await.unwrap_or_default(); let roots = shared_file_roots(&config.file_roots, folders, &home); (confine(&roots, Path::new(requested))?, roots) @@ -1240,6 +1264,9 @@ mod tests { let roots = shared_file_roots(&[], vec![project.path().to_path_buf(), home.path().to_path_buf()], home.path()); assert!(confine(&roots, &project.path().join("README.md")).is_ok()); assert_eq!(roots.len(), 1, "home is never a root: {roots:?}"); + // A home that doesn't resolve shares no workspace folder at all. + let gone = home.path().join("missing"); + assert!(shared_file_roots(&[], vec![project.path().to_path_buf()], &gone).is_empty()); } #[test] From 186b62ff3a86759430f44426d021809d333078a5 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:56:42 -0700 Subject: [PATCH 21/31] cnx-ios review fixes: Try Again always ends and never opens Chief twice; voice and + gated before Chief connects; team creation limiter; copy nits - LiveCoordinator.startChief ends a Try Again on every exit (defer) and a retry while one opens does nothing. - Before Chief connects, the record and + buttons are off too (they clear and drop like Send); package views are looked up once. - POST /v1/teams: its own TEAM_LIMIT (3 a minute), no control characters. - A capacity error naming the model stays a capacity error; setext and thematic breaks keep the Feed's web renderer. - http.test.ts expected API 0.8.0 while wrangler says 0.9.0 (stale on base). Co-Authored-By: Claude Opus 5.5 --- .../CnxAgentChat/TurnFailureCopy.swift | 2 +- .../CnxChief/ChiefConversationScreen.swift | 22 ++++++++++++++++++- .../Sources/CnxChief/ChiefSession.swift | 5 +++++ .../Sources/CnxFeed/FeedPlainText.swift | 3 +++ .../Sources/CnxLive/LiveCoordinator.swift | 10 +++++++++ .../TurnFailureCopyTests.swift | 2 ++ .../CnxLiveTests/FeedPlainTextTests.swift | 2 ++ cnx-ios/PROTOCOL.md | 2 +- cnx-ios/backend/src/env.ts | 2 ++ cnx-ios/backend/src/index.ts | 4 ++-- cnx-ios/backend/test/http.test.ts | 2 +- cnx-ios/backend/test/teams.test.ts | 1 + cnx-ios/backend/wrangler.jsonc | 6 +++-- 13 files changed, 55 insertions(+), 8 deletions(-) diff --git a/cnx-ios/CnxKit/Sources/CnxAgentChat/TurnFailureCopy.swift b/cnx-ios/CnxKit/Sources/CnxAgentChat/TurnFailureCopy.swift index 36cd3a80437f..77e68a46261d 100644 --- a/cnx-ios/CnxKit/Sources/CnxAgentChat/TurnFailureCopy.swift +++ b/cnx-ios/CnxKit/Sources/CnxAgentChat/TurnFailureCopy.swift @@ -84,8 +84,8 @@ public struct TurnFailureCopy: Hashable, Sendable { return .modelServiceUnavailable } // The account can't run the chat's model (Codex with a ChatGPT account, a retired model). - if matches(Self.modelUnavailable, parts.message ?? raw) { return .modelUnavailable } if matches(Self.rate, raw) { return .rateLimited } + if matches(Self.modelUnavailable, parts.message ?? raw) { return .modelUnavailable } if matches(Self.unreachable, raw) { return .unreachable } let via = via(raw, harness: harness) if matches(Self.invalidKey, raw), !(matches(Self.subscription, raw) && via == nil) { return .invalidKey } diff --git a/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift b/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift index 909a57f3bb7c..729240356147 100644 --- a/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift +++ b/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift @@ -230,13 +230,33 @@ public final class ChiefConversationContainer: UIViewController, UIGestureRecogn private func updateAvailability() { let unavailable: Bool if case .unavailable? = session.barItem { unavailable = true } else { unavailable = false } - conversation.view.firstSubview { $0.accessibilityIdentifier == "conversation.initialLoading" }?.alpha = unavailable ? 0 : 1 let sends = session.acceptsSendsNow + // Found once (the store changes on every keystroke and stream chunk). + if gatedViews.spinner == nil { gatedViews.spinner = packageView("conversation.initialLoading") } + if gatedViews.record == nil { gatedViews.record = packageView("conversation.composer.record") as? UIControl } + if gatedViews.plus == nil { gatedViews.plus = packageView("conversation.composer.plus") as? UIControl } + gatedViews.spinner?.alpha = unavailable ? 0 : 1 + // Voice messages, photos, polls and Send Later clear and drop the same way before + // Chief connects: off until it takes sends (the text field and its gated Send stay). + gatedViews.record?.isEnabled = sends + gatedViews.plus?.isEnabled = sends for recognizer in sendButton?.gestureRecognizers ?? [] where recognizer is UILongPressGestureRecognizer { recognizer.isEnabled = sends } } + private struct GatedViews { + weak var spinner: UIView? + weak var record: UIControl? + weak var plus: UIControl? + } + + private var gatedViews = GatedViews() + + private func packageView(_ identifier: String) -> UIView? { + conversation.view.firstSubview { $0.accessibilityIdentifier == identifier } + } + // MARK: Chief's questions /// The bar under the header while Chief asks (``ChiefQuestionBar``). diff --git a/cnx-ios/CnxKit/Sources/CnxChief/ChiefSession.swift b/cnx-ios/CnxKit/Sources/CnxChief/ChiefSession.swift index 41a55bab0732..2901be1f7c93 100644 --- a/cnx-ios/CnxKit/Sources/CnxChief/ChiefSession.swift +++ b/cnx-ios/CnxKit/Sources/CnxChief/ChiefSession.swift @@ -193,6 +193,11 @@ public final class ChiefSession { reconnect() } + /// The coordinator's try ended (connected, refused or replaced): Try Again is offered again. + public func endReconnect() { + if isReconnecting { isReconnecting = false } + } + /// The conversation's own send takes a message now. Before Chief ever /// connected it has no sender and drops the message after clearing the /// composer (D3), so the screen holds the text and sends it through diff --git a/cnx-ios/CnxKit/Sources/CnxFeed/FeedPlainText.swift b/cnx-ios/CnxKit/Sources/CnxFeed/FeedPlainText.swift index 52d3471d79f9..5926be6a573e 100644 --- a/cnx-ios/CnxKit/Sources/CnxFeed/FeedPlainText.swift +++ b/cnx-ios/CnxKit/Sources/CnxFeed/FeedPlainText.swift @@ -30,6 +30,9 @@ public struct FeedPlainText: Sendable { return false } if line.hasPrefix(" ") || line.hasPrefix("\t") { return false } + // Setext underlines and thematic breaks (`===`, `---`, `***`, `___`). + if trimmed.count >= 3, Set(trimmed.replacingOccurrences(of: " ", with: "")).count == 1, + let mark = trimmed.first, "=-*_".contains(mark) { return false } } return true } diff --git a/cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift b/cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift index a30a8c394c5f..8d73a96dcd6e 100644 --- a/cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift +++ b/cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift @@ -100,6 +100,8 @@ public final class LiveCoordinator { @ObservationIgnored private var terminals: [WeakTerminal] = [] private struct WeakTerminal { weak var session: DaemonTerminalSession? } @ObservationIgnored private var chiefLive = false + /// A `startChief` is opening the channel (Try Again does not start a second one). + @ObservationIgnored private var chiefStarting = false @ObservationIgnored private var discoveryTask: Task? /// Follows the phone's network (Wi-Fi, cellular, airplane mode). @ObservationIgnored private var networkTask: Task? @@ -1113,6 +1115,12 @@ public final class LiveCoordinator { /// reason; there is no stand-in Chief. private func startChief(_ link: any MacLink) async { let session = ChiefSession.shared + chiefStarting = true + // Every way out ends a Try Again in flight, even one for a link replaced meanwhile. + defer { + chiefStarting = false + session.endReconnect() + } // Try Again on Chief's unavailable bar opens the channel again on this link. session.reconnect = { [weak self] in self?.retryChief() } session.macName = self.link.target?.name @@ -1172,6 +1180,8 @@ public final class LiveCoordinator { /// Try Again on Chief's unavailable bar: the Chief channel again, on the /// link that is up now. Without one, the bar says so. func retryChief() { + // One open at a time: the one running now ends the try. + guard !chiefStarting else { return } guard let link = servedLink else { ChiefSession.shared.status = String(localized: "Your Mac isn't connected. Chief comes back when it is.") return diff --git a/cnx-ios/CnxKit/Tests/CnxAgentChatTests/TurnFailureCopyTests.swift b/cnx-ios/CnxKit/Tests/CnxAgentChatTests/TurnFailureCopyTests.swift index 7ac331a079a6..af13a06f498e 100644 --- a/cnx-ios/CnxKit/Tests/CnxAgentChatTests/TurnFailureCopyTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxAgentChatTests/TurnFailureCopyTests.swift @@ -73,6 +73,8 @@ import Testing #expect(copy.sentence == "Codex can't use this model with the Mac's account. Pick another model.") #expect(copy.detail == raw) #expect(!TurnFailureCopy.plain(raw, harness: "codex", harnessName: "Codex").contains("{")) + // A capacity error that mentions the model stays a capacity error. + #expect(TurnFailureCopy(message: "The model is currently not available (503)").kind == .rateLimited) // Plain text stays as it is. #expect(TurnFailureCopy.plain("Already answered.") == "Already answered.") } diff --git a/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPlainTextTests.swift b/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPlainTextTests.swift index a38bc1022f8d..4c5782f108e2 100644 --- a/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPlainTextTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxLiveTests/FeedPlainTextTests.swift @@ -18,5 +18,7 @@ import Testing #expect(!FeedPlainText("1. first").isInline) #expect(!FeedPlainText(String(repeating: "word ", count: 80)).isInline) #expect(!FeedPlainText("a\nb\nc\nd\ne").isInline) + #expect(!FeedPlainText("Title\n===").isInline) + #expect(!FeedPlainText("above\n---\nbelow").isInline) } } diff --git a/cnx-ios/PROTOCOL.md b/cnx-ios/PROTOCOL.md index 2684ef9bb859..6f0789ee9e78 100644 --- a/cnx-ios/PROTOCOL.md +++ b/cnx-ios/PROTOCOL.md @@ -71,7 +71,7 @@ echoes the token), | `GET /v1/me/macs` | → `{macs:[{deviceId,name,teamId,online,lastSeenAt,ownerUserId}]}`: the caller's own Macs (registered by the caller, the only Macs v1 lets it dial) in every team the caller still belongs to and in its personal space, with presence from each team's hub. Added in 0.5.0 so a phone finds its Mac whichever team either side has selected; the app polls it (every 4 s while its active team has no online Mac, else every 30 s) and moves to the team of an online Mac. `502 membership_unavailable` when Stack cannot list the caller's teams | | `GET /v1/teams/:teamId/prefs` | → `{color, emoji}` for the caller (per user per team) | | `PUT /v1/teams/:teamId/prefs` | `{color?: "#RRGGBB", emoji?: string\|null}`. `emoji` is the avatar glyph: `null` = automatic (team initial), `""` = none (color only), otherwise exactly one grapheme (letter, digit, symbol or emoji; not whitespace) | -| `POST /v1/teams` | `{name}` (1 to 64 characters, trimmed) → `{team:{id,name}}`: creates a Stack team with the caller as creator and first member, using the project's server key (Stack refuses client-side team creation). `503 team_creation_unavailable` when the Worker has no server key for the caller's project; `403`/`502 team_create_failed` when Stack refuses. | +| `POST /v1/teams` | `{name}` (1 to 64 characters, trimmed, no control characters; 3 a minute per user) → `{team:{id,name}}`: creates a Stack team with the caller as creator and first member, using the project's server key (Stack refuses client-side team creation). `503 team_creation_unavailable` when the Worker has no server key for the caller's project; `403`/`502 team_create_failed` when Stack refuses. | | `POST /v1/teams/:teamId/invites` | `{email}` → `{url, sent:true}`: Stack emails a team invitation whose link opens `url` (the configured callback) with the code. Changed from a bare link: without a Stack server key the API can only send the invitation email, not mint a shareable link. Needs the caller's `$invite_members` team permission. | | `GET /v1/ice` | → `{iceServers:[{urls:[...],username?,credential?}], ttl, turn}` from the Cloudflare TURN key (generate-ice-servers), STUN `stun:stun.cloudflare.com:3478` always included (STUN only if TURN is unavailable). `turn` (0.7.0) is `false` when no TURN server could be included: clients must say so (off-LAN WebRTC may then fail) instead of failing silently. `ttl` is 1 h (was 4 h) | | `GET /v1/me/devices` | → `{devices:[{deviceId,kind,name,teamId,createdAt,updatedAt,lastSeenAt,online,push}]}` (0.7.0): every Mac and phone the caller registered, in any team or personal space. `online` is live presence for Macs and `null` for phones; `push` is whether the phone has an APNs token | diff --git a/cnx-ios/backend/src/env.ts b/cnx-ios/backend/src/env.ts index 30910f33f93e..dd18b51c030b 100644 --- a/cnx-ios/backend/src/env.ts +++ b/cnx-ios/backend/src/env.ts @@ -28,6 +28,8 @@ export interface Env { readonly ICE_LIMIT?: RateLimit readonly INVITE_LIMIT?: RateLimit readonly NOTIFY_LIMIT?: RateLimit + /** Team creations per user (they make Stack teams with the server key). */ + readonly TEAM_LIMIT?: RateLimit // Test only (ENVIRONMENT=test). readonly STACK_TEST_JWKS?: string /** JSON {userId: [teamId]} for primary-project users, {":": [teamId]} for the others; replaces Stack membership calls. */ diff --git a/cnx-ios/backend/src/index.ts b/cnx-ios/backend/src/index.ts index 5ec0f23934a7..c701c165aeee 100644 --- a/cnx-ios/backend/src/index.ts +++ b/cnx-ios/backend/src/index.ts @@ -416,8 +416,8 @@ const routes = async (req: Request, env: Env, ctx: ExecutionContext): Promise 64) throw new HttpError(400, "bad_request", "name must be 1 to 64 characters") - await limit(env.INVITE_LIMIT, me, "team creations") + if (!name || name.length > 64 || /\p{Cc}/u.test(name)) throw new HttpError(400, "bad_request", "name must be 1 to 64 characters, no control characters") + await limit(env.TEAM_LIMIT, me, "team creations") const r = await createTeam(env, caller, name) if (!r.ok) { if (r.code === "team_creation_unavailable") return fail(503, r.code, "this service can't create teams for your account yet") diff --git a/cnx-ios/backend/test/http.test.ts b/cnx-ios/backend/test/http.test.ts index 02e33caf0365..561132c8824b 100644 --- a/cnx-ios/backend/test/http.test.ts +++ b/cnx-ios/backend/test/http.test.ts @@ -4,7 +4,7 @@ import { call, DEV_PROJECT, PROD_PROJECT, register, token } from "./helpers.ts" describe("HTTP API", () => { it("health is public", async () => { const r = await call("GET", "/v1/health", undefined) - expect(r).toMatchObject({ status: 200, json: { ok: true, version: "0.8.0", stackProjects: [DEV_PROJECT, PROD_PROJECT] } }) + expect(r).toMatchObject({ status: 200, json: { ok: true, version: "0.9.0", stackProjects: [DEV_PROJECT, PROD_PROJECT] } }) expect(r.json).toHaveProperty("colo") }) diff --git a/cnx-ios/backend/test/teams.test.ts b/cnx-ios/backend/test/teams.test.ts index 6d16e1d621cc..2c096b3bcf8c 100644 --- a/cnx-ios/backend/test/teams.test.ts +++ b/cnx-ios/backend/test/teams.test.ts @@ -16,6 +16,7 @@ describe("POST /v1/teams", () => { expect((await call("POST", "/v1/teams", tok, {})).status).toBe(400) expect((await call("POST", "/v1/teams", tok, { name: " " })).status).toBe(400) expect((await call("POST", "/v1/teams", tok, { name: "x".repeat(65) })).status).toBe(400) + expect((await call("POST", "/v1/teams", tok, { name: "a\nb" })).status).toBe(400) }) it("says so when this Worker cannot create teams for the caller's project", async () => { diff --git a/cnx-ios/backend/wrangler.jsonc b/cnx-ios/backend/wrangler.jsonc index b789e612db40..ed5ef8bfc608 100644 --- a/cnx-ios/backend/wrangler.jsonc +++ b/cnx-ios/backend/wrangler.jsonc @@ -44,7 +44,8 @@ { "name": "USER_LIMIT", "namespace_id": "4101", "simple": { "limit": 300, "period": 60 } }, { "name": "ICE_LIMIT", "namespace_id": "4102", "simple": { "limit": 30, "period": 60 } }, { "name": "INVITE_LIMIT", "namespace_id": "4103", "simple": { "limit": 10, "period": 60 } }, - { "name": "NOTIFY_LIMIT", "namespace_id": "4104", "simple": { "limit": 60, "period": 60 } } + { "name": "NOTIFY_LIMIT", "namespace_id": "4104", "simple": { "limit": 60, "period": 60 } }, + { "name": "TEAM_LIMIT", "namespace_id": "4105", "simple": { "limit": 3, "period": 60 } } ], "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "e4fd2e88cfcd4f00841ddc5b49c3ee06" }] }, @@ -67,7 +68,8 @@ { "name": "USER_LIMIT", "namespace_id": "4201", "simple": { "limit": 300, "period": 60 } }, { "name": "ICE_LIMIT", "namespace_id": "4202", "simple": { "limit": 30, "period": 60 } }, { "name": "INVITE_LIMIT", "namespace_id": "4203", "simple": { "limit": 10, "period": 60 } }, - { "name": "NOTIFY_LIMIT", "namespace_id": "4204", "simple": { "limit": 60, "period": 60 } } + { "name": "NOTIFY_LIMIT", "namespace_id": "4204", "simple": { "limit": 60, "period": 60 } }, + { "name": "TEAM_LIMIT", "namespace_id": "4205", "simple": { "limit": 3, "period": 60 } } ], "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "11158756425f41469964743aab2f6319" }] } From 5beee45966a5538a0d64bd819d5d5be94cdf1d30 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:39:32 -0700 Subject: [PATCH 22/31] cnx-ios verify: fixes-r1 live results over the real link (4a31def5f5a) Results table for D1-D14 on fleet build cnx-e2e-4a31def5 and a Demo phone build of the same SHA; the fresh verifier's fv.py/fvr.sh driver, with FV_ENV launch variables for the app. Co-Authored-By: Claude Opus 5.5 --- cnx-ios/verify/fixes-r1-20261020.md | 65 +++++++++++ cnx-ios/verify/fullverify/fv.py | 172 ++++++++++++++++++++++++++++ cnx-ios/verify/fullverify/fvr.sh | 9 ++ 3 files changed, 246 insertions(+) create mode 100644 cnx-ios/verify/fixes-r1-20261020.md create mode 100644 cnx-ios/verify/fullverify/fv.py create mode 100755 cnx-ios/verify/fullverify/fvr.sh diff --git a/cnx-ios/verify/fixes-r1-20261020.md b/cnx-ios/verify/fixes-r1-20261020.md new file mode 100644 index 000000000000..9c3d07ee6823 --- /dev/null +++ b/cnx-ios/verify/fixes-r1-20261020.md @@ -0,0 +1,65 @@ +# Fixes round 1: live verification, 2026-10-20 + +Verifies the fixes for `full-verify-app-20261019.md` (D1 to D14) over the real +link: phone app > link > cnx-hostd > daemon. No local socket, mock or +standalone hostd. + +## Build under test + +- Source: `4a31def5f5a` (origin/feat-cnx-ios-fixes-r1 when this run started). + Commits after it (`903b7acd275`, `e96061c4508`, `186b62ff3a8`) are not in + this run. +- Mac rig: fleet job `fe725b381ce18bc8f760c02d`, tag `cnx-e2e-4a31def5`. + It ran hidden (`open -g -j -n`, `verify/parity/rmac.py`) on + mini-cmuxs-mac-mini-2, device id `cb292c76…ae26`. The phone shows it as + "cmux’s Mac mini (2) AE26". +- Phone: Demo configuration, built with Xcode 26.6 on mini-cmux7s-mac-mini + (`ARCHS=arm64`, `SWIFT_ACTIVE_COMPILATION_CONDITIONS=$(inherited) DEBUG CNX_DEMO` + so the packages see DEBUG too). Bundle `dev.cmux.cnx`. +- CnxFFI.xcframework: the FFI key changed from `ccc2097337090383` to + `363f2707310702be`, because cnx-hostd sits in `cnx-ios/rust`. No cache had + either key, so it was built from this SHA on mini-cmux7s. The regenerated + `CnxFFI.swift` is byte-identical to the committed copy. cnx-hostd is only a + dev-dependency of cnx-ffi. +- One headless simulator `cnx-fixr1-1` (iPhone 17 Pro, iOS 26.3) on mini-2, + driven with AXe through `verify/fullverify/fv.py`. Simulator.app was never + opened. +- Driver change: `fv.py launch` takes `FV_ENV` (JSON launch environment, used + for `CNX_FRAMELOG=1`). `fvr.sh` forwards it. + +Evidence (git-ignored): `verify/evidence/2026-10-20/fixes-r1/` +(`shots/*.png`, `video/01…08-*.mp4`, `feed-framelog.jsonl`, `cnx-hostd.log`). + +## Results + +| ID | Result | Evidence | Notes | +| --- | --- | --- | --- | +| D2 Chief loads | **Pass** | `07-chief-open.png`, `08-chief-sent.png`, `19-chief-mac-reply.png`, `video/01`, `video/03` | hostd starts with `CNX_CHIEF_SESSION=cmux-chief-d9e6c81d` and `CNX_CHIEF_SOCKET=…/cmux-chief-d9e6c81d.sock`. Home > Chief opens the real Mac conversation with no spinner. A sent message is read by the Mac. The Mac Chief's own reply ("Chief couldn't reach its model…") arrives over the link. Replies with content are blocked because the rig's Claude gets a 503 from subrouter. | +| D2 unavailable bar | **Pass, with a copy issue** | `10-chief-unavailable.png` | Chief was made unavailable by hiding only the rig's Chief socket. Quitting the owner does not work: the app respawns it within seconds. The bar shows "Chief isn't available". hostd refuses with `cmux-tui session chief: No such file or directory`, but the phone shows "didn't answer (daemon channel closed)." instead of the "isn't running on … Open cmux" copy. The refusal reason does not reach the phone's mapping. | +| D2 Try Again | **Fail** | `14-…`, `15-…`, `16-chief-after-tryagain.png`, `17-chief-after-tryagain-60s.png`, `18-chief-reenter.png`, `video/03` | After the socket was restored, Try Again opened a new `chief` channel at hostd (no refusal) and removed the bar. The open screen then stayed on `conversation.initialLoading` for more than 60 s, with no title and no transcript. Leaving and reopening Chief showed the whole conversation, including the queued message. The likely cause is that the reconnected session does not rebind the conversation the screen already shows. `186b62ff3a8` ("Try Again always ends…") may cover this; this run does not include that commit. | +| D3 | **Pass** | `11-chief-not-sent-alert.png`, `12-chief-outboxed.png`, `13-home-waiting.png`, `18-chief-reenter.png` | Send while unavailable keeps the text and shows "Message Not Sent" with Try Again, Send When Chief Connects and OK. OK keeps the text. Send When Chief Connects moves the message to the outbox, and Home reads "Waiting to send: queued hello fixr1". It was sent and read at 9:11 when Try Again connected. Reopening Chief with a queued message shows a "Message Not Sent Yet" alert with Keep and Delete. The queued message is not drawn in the transcript while it waits. | +| D9 preview | **Pass** | `36-artifacts-list.png`, `37-…`, `38-preview-notes-code.png`, `video/05` | A phone-started Codex chat in workspace `fixproj2` (`~/cnx-fixr1/ws/fixproj`) created `notes.md`. Files and Artifacts > notes.md opens; the code view shows "fixr1". The rendered Markdown view stayed blank for this one-line file (`37-preview-notes.png`), a low-severity issue. | +| D9 refusal | **Pass** | `39-preview-outside-refused.png` | `~/fixr1-outside.txt` (in home, outside every workspace folder) shows "Can't open this file … is outside the folders…". hostd logs `forbidden: path is outside the workspace roots`, and a `workspace-1` tab in `~` did not share home. | +| D4 default | **Pass** | `23-codex-model-list.png`, `23-codex-default-selected.png`, `28-codex-state.png`, `33-turn-done.png` | The chooser reads "Default" (no gpt-6.1-sol). Codex > Default runs a full turn: calc.py gets `mul`, and the reply is "done". No 400. The "model error reads as a sentence" check was not reproducible, because the default no longer fails. | +| D4 model pill | **Fail / open** | `40-composer-expanded.png` | No model pill appears in the expanded composer of a running Codex chat, either with Default or with GPT-6 Astra. The bar has only + and Send. Either the `codex` harness reports no models or modes over acpmux (`store.models` empty), or the pill condition misses it. This needs an owner check against acpmux `session/new` models. | +| D8 | **Pass (tab); "Still opening" not reproduced** | `41-claude-chooser.png`, `42-claude-start.png`, `43-claude-reply.png`, `44-still-opening.png`, `fixr1-tabs-log.txt`, `video/06` | New Agent Chat With… > Claude Code > Start Chat opens a Claude tab at once. A prompt there shows "Retrying (6 of 10, error 502)" because Claude is blocked on mini-2 by subrouter, which is a clear state. Two quick New Agent Chat requests opened two tabs; the first finished before the second arrived, so "Still opening" never showed. The device log has `tab request agent on cmux’s Mac mini (2)`. New Agent Chat With… requests do not log `tab request`. | +| D5 | **Pass, with one entry point missed** | `25-composer-typed.png`, `48-rename-typed.png`, `49-sidebar-create-team-typed.png`, `02-onboarding-team-autocap.png`, `video/08` | Soft-keyboard typing stays as typed in the agent composer ("edit calc.py … mul"), Rename Workspace ("fvrenamed"; the Mac shows `fixproj2fvrenamed`), the sidebar Create team ("fv team") and the Chief composer. **Missed:** the onboarding "Create your team" field (`CnxOnboarding/Setup.swift:455`) has neither `.autocorrectionDisabled()` nor `.textInputAutocapitalization(.never)`. It auto-capitalized "Fv team" and shows QuickType suggestions. | +| D7 | **Pass** | `05-ws-lines.png`, `06-ws-after-connect.png`, `video/02` | The fresh sign-in landed on another Mac (cmux11's, which failed pairing). Tapping Connect on the "cmux’s Mac mini (2) AE26" line moved the link to the rig: hostd logged link up, and the rig's workspaces were listed. | +| D11 | **Pass (not stressed)** | `05-ws-a.png`, `05-ws-lines.png` | Five Mac lines are drawn without overlapping placeholder rows. The loading phase was short on this run. | +| D10 | **Pass (improved)** | `feed-framelog.jsonl`, `video/07-d10-feed-scroll.mp4` | 86 Codex `Stop` events were emitted from the Mac (`cmux agent hook emit`, through the link), plus the real ones. 12 finger flings with `CNX_FRAMELOG=1`: 1940 frames, p50 16.7 ms, p95 16.7 ms, p99 34.6 ms, max 49.8 ms, 29 hitches, 31 dropped, **16.8 hitch ms/s**. The earlier run had 91.3 hitch ms/s, p99 49.2 ms, max 234.5 ms and 47 hitches. Mini-2 load was about 2 to 3 here, against about 5 in the earlier run. | +| D1 | **Open (live)** | `03-onboarding-create-team-result.png`, `50-sidebar-create-team-result.png` | The shared dev Worker does not have `POST /v1/teams`. The phone now says so: "Something went wrong. Please try again." in onboarding, and "Couldn't create the team" in the sidebar. There is no silent success. No separate Worker was deployed: the 1Password CLI is not signed in on this laptop (`op whoami`: not signed in), so the dev Stack server key was not available. What is missing: `wrangler deploy --env dev --name cnx-ios-api-fixr1` with `STACK_SECRET_SERVER_KEY` from 1Password, then both apps pointed at it. | +| D12 | **Pass** | `46-feed-d12-finished.png` | A bare Codex finish reads "Finished its turn in the terminal. Open it to see the result." | +| D13 | **Not confirmed** | `47-home-previews.png` | After a relaunch, the Home rows for the fixproj2 Codex and Claude chats show no preview line, where the newest reply "done" was expected. "You don't have a Cloud Chief yet" still flashes on Home. | +| D14 | **Pass (time label)** | `20-ws-row-now.png` | The new workspace row's label ends in "now", which matches the screen. | +| D6 | Not run | — | The chat with edits was closed by mistake (below); not repeated. | + +## Other findings + +- **Composer refilled after a link drop.** At 04:18:22 hostd's signaling WebSocket reset and the link came back. A Codex chat whose prompt was already sent then had that prompt back in its composer, with Send active, for the rest of the session (`30-acpmux-closed.png`, `33-turn-done.png`). A Send would run it twice. The "acpmux channel closed" toast showed at the same time. +- **Close All Tabs has no confirmation.** The workspace More menu puts "Close All Tabs" at the spot of the More button itself. A double tap closed every tab and so the workspace `fixproj` (operator error, `04b-close-all-tabs-strip.png`). The toast that followed read "Couldn't open an agent chat on Mac." (device log: `tab request refused on Mac: unknown pane`). +- Face ID on the simulator: Allow once falls back to the passcode until Face ID is enrolled (`notifyutil … enrollmentChanged`). After that, `pearl.match` approves. + +## Rig changes and cleanup + +- The rig app was quit and its leftover daemons (cmux-tui, optchat-chief, acpmux) killed. The simulator `cnx-fixr1-1` was shut down and deleted. `~/cnx-fixr1`, the rig's Chief home and App Support folder and `~/fixr1-outside.txt` were removed from mini-2, and `~/cnx-fixr1` from mini-cmux7s. +- `~/cnx-ios-e2e/*`, other agents' rigs (`cnx-e2e-604fc689`) and the other booted simulator on mini-2 were left alone. diff --git a/cnx-ios/verify/fullverify/fv.py b/cnx-ios/verify/fullverify/fv.py new file mode 100644 index 000000000000..415c696129d1 --- /dev/null +++ b/cnx-ios/verify/fullverify/fv.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Fresh-verifier phone driver (verify/full-verify-app-20261019.md). Runs ON the rig mini +(mini-cmuxs-mac-mini-2) next to rmac.py and the e2e modules, one command per call: + + fv.py setup create/boot the one simulator (cnx-fv-1) + fv.py install [--fresh] install the Demo app (fresh: uninstall + keychain reset) + fv.py launch | terminate + fv.py ls [needle] visible AX elements (kind, label, id, value, frame) + fv.py tap (--label L|--ident I|--contains C|--prefix P) [--kind K] [--idx N] + fv.py press/long/tapxy X Y [SECONDS] + fv.py swipe X1 Y1 X2 Y2 [DUR] + fv.py type TEXT software keyboard taps (falls back to axe type) + fv.py shot NAME screenshot to evidence/shots/NAME.png + fv.py rec start NAME | rec stop simctl recordVideo (detached, pid file) + fv.py ocr [needle] + +Every phone action is a real HID tap or typed text through AXe; nothing reaches the +app any other way. +""" +import json +import os +import signal +import subprocess +import sys +import time + +BASE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(BASE, "e2e")) +os.environ["PATH"] = os.path.join(BASE, "bin") + ":/opt/homebrew/bin:" + os.environ.get("PATH", "") +os.environ.setdefault("DEVELOPER_DIR", "/Applications/Xcode.app/Contents/Developer") +from sim import Sim # noqa: E402 + +STATE = os.path.join(BASE, "fv-state.json") +EV = os.path.join(BASE, "evidence") +APP = os.path.join(BASE, "app/CnxApp.app") +BUNDLE = "dev.cmux.cnx" +SIM_NAME = os.environ.get("FV_SIM", "cnx-fixr1-1") + + +def st(): + return json.load(open(STATE)) if os.path.exists(STATE) else {} + + +def sim(): + return Sim(st()["udid"], os.path.join(EV, "shots")) + + +def run(*a, **k): + return subprocess.run(list(a), capture_output=True, text=True, **k) + + +def query(args): + q, kind, idx = {}, None, 0 + it = iter(args) + for a in it: + if a == "--label": q["label"] = next(it) + elif a == "--ident": q["ident"] = next(it) + elif a == "--contains": q["contains"] = next(it) + elif a == "--prefix": q["prefix"] = next(it) + elif a == "--kind": kind = next(it) + elif a == "--idx": idx = int(next(it)) + if kind: + q["kind"] = kind + return q, idx + + +def visible(e): + x, y, w, h = e.frame + return w > 0 and h > 0 and x + w > 0 and x < 402 and y + h > 0 and y < 874 + + +def main(): + cmd, args = sys.argv[1], sys.argv[2:] + if cmd == "setup": + lst = json.loads(run("xcrun", "simctl", "list", "devices", "-j").stdout) + udid = next((d["udid"] for devs in lst["devices"].values() for d in devs if d["name"] == SIM_NAME), None) + if not udid: + udid = run("xcrun", "simctl", "create", SIM_NAME, "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro", + "com.apple.CoreSimulator.SimRuntime.iOS-26-3").stdout.strip() + run("xcrun", "simctl", "boot", udid) + run("xcrun", "simctl", "bootstatus", udid, "-b", timeout=600) + s = st(); s["udid"] = udid + json.dump(s, open(STATE, "w"), indent=2) + print(udid) + elif cmd == "install": + s = sim() + if "--fresh" in args: + s.terminate(BUNDLE) + run("xcrun", "simctl", "uninstall", s.udid, BUNDLE) + run("xcrun", "simctl", "keychain", s.udid, "reset") + r = run("xcrun", "simctl", "install", s.udid, APP) + print("installed" if r.returncode == 0 else r.stderr) + elif cmd == "launch": + # FV_ENV: launch variables for the app as JSON (e.g. {"CNX_FRAMELOG": "1"}). + print(sim().launch(BUNDLE, env=json.loads(os.environ.get("FV_ENV", "{}")))) + elif cmd == "terminate": + sim().terminate(BUNDLE) + elif cmd == "ls": + needle = (args[0].lower() if args else None) + for e in sim().tree(): + if not visible(e) or e.kind in ("Application",) or (e.kind == "Group" and not e.label and not e.ident) or e.kind == "Slider": + continue + line = f"{e.kind:<12} {e.label!r:<44.44} id={e.ident or ''} v={str(e.value or '')[:40]!r} {tuple(round(v) for v in e.frame)}" + if needle is None or needle in line.lower(): + print(line) + elif cmd == "tap": + q, idx = query(args) + s = sim() + hits = [e for e in s.find(**q) if visible(e)] + if len(hits) <= idx: + print(f"NOT FOUND {q}"); sys.exit(2) + s.tap_el(hits[idx]); print(f"tapped {hits[idx]!r}") + elif cmd == "wait": + q, idx = query(args) + e = sim().wait(timeout=float(os.environ.get("FV_TIMEOUT", "20")), **q) + print(repr(e) if e else f"TIMEOUT {q}"); sys.exit(0 if e else 2) + elif cmd == "tapxy": + sim().tap(float(args[0]), float(args[1])); print("ok") + elif cmd == "press": + sim().press(float(args[0]), float(args[1])); print("ok") + elif cmd == "long": + sim().long_press(float(args[0]), float(args[1]), float(args[2]) if len(args) > 2 else 1.0); print("ok") + elif cmd == "swipe": + a = [float(v) for v in args] + sim().swipe(a[0], a[1], a[2], a[3], a[4] if len(a) > 4 else 0.4); print("ok") + elif cmd == "drag": + a = [float(v) for v in args] + sim().drag(a[0], a[1], a[2], a[3], a[4] if len(a) > 4 else 1.0); print("ok") + elif cmd == "type": + sim().softtype(" ".join(args)); print("typed") + elif cmd == "hwtype": + run("axe", "type", " ".join(args), "--udid", sim().udid); print("typed (hw)") + elif cmd == "key": + run("axe", "key", args[0], "--udid", sim().udid); print("key") + elif cmd == "shot": + print(sim().shot(args[0])) + elif cmd == "rec": + os.makedirs(os.path.join(EV, "video"), exist_ok=True) + pidf = os.path.join(BASE, "rec.pid") + if args[0] == "start": + path = os.path.join(EV, "video", args[1] + ".mp4") + log = open(os.path.join(BASE, "rec.log"), "w") + p = subprocess.Popen(["xcrun", "simctl", "io", st()["udid"], "recordVideo", "--codec", "h264", "--force", path], + stdout=log, stderr=log, start_new_session=True) + open(pidf, "w").write(f"{p.pid} {path}") + print(f"recording {path} pid {p.pid}") + else: + if os.path.exists(pidf): + pid, path = open(pidf).read().split(" ", 1) + try: + os.kill(int(pid), signal.SIGINT) + except ProcessLookupError: + pass + for _ in range(100): + try: + os.kill(int(pid), 0) + except ProcessLookupError: + break + time.sleep(0.2) + os.remove(pidf) + print(path, os.path.getsize(path) if os.path.exists(path) else "missing") + elif cmd == "ocr": + needle = args[0].lower() if args else None + for o in sim().ocr(): + if needle is None or needle in o[4].lower(): + print(f"{o[0]:.0f},{o[1]:.0f} {o[2]:.0f}x{o[3]:.0f} {o[4]}") + else: + sys.exit(f"unknown {cmd}") + + +if __name__ == "__main__": + main() diff --git a/cnx-ios/verify/fullverify/fvr.sh b/cnx-ios/verify/fullverify/fvr.sh new file mode 100755 index 000000000000..0aab582b72f7 --- /dev/null +++ b/cnx-ios/verify/fullverify/fvr.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Laptop-side shim: run fv.py commands on the rig mini. usage: fvr.sh CMD ARGS... ; fvr.sh sh 'shell on mini' +# FV_ENV (JSON) is passed to `fv.py launch` as the app's launch environment. +host=${FV_HOST:-mini-cmuxs-mac-mini-2} +dir=${FV_DIR:-cnx-fixr1} +if [[ "$1" == sh ]]; then shift; exec ssh -o BatchMode=yes "$host" "cd ~/$dir && export PATH=\$HOME/$dir/bin:/opt/homebrew/bin:\$PATH DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer; $*"; fi +q=$(printf '%q ' "$@") +fe=$(printf '%q' "${FV_ENV:-"{}"}") +exec ssh -o BatchMode=yes "$host" "cd ~/$dir && FV_ENV=$fe python3 fv.py $q" From ffbcd54affd7282b4a397acfdb2f17376fa1fb53 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:41:38 -0700 Subject: [PATCH 23/31] cnx-ios live recheck fixes: Chief rebuilds on the new store after Try Again; plain refusal copy; onboarding team name not autocorrected - D2: after Try Again connected, the open Chief screen kept drawing the old store and spun; the screen is now keyed by the session's store. - D2: hostd closing the chief channel (owner socket missing) reads "Chief isn't running on " instead of "didn't answer (daemon channel closed)". - D5: the onboarding Create your team field was missed. Co-Authored-By: Claude Opus 5.5 --- cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift | 3 +++ cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift | 4 +++- cnx-ios/CnxKit/Sources/CnxOnboarding/Setup.swift | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift b/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift index 729240356147..9d9776df819e 100644 --- a/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift +++ b/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift @@ -43,6 +43,9 @@ public struct ChiefConversationScreen: View { public var body: some View { ChiefConversationController(session: session, compose: compose, focusesComposer: focusesComposer, back: { dismiss() }) + // The conversation draws one store; a new backend (Try Again, a new link) makes a + // new store, so the open screen is rebuilt on it instead of waiting on the old one. + .id(ObjectIdentifier(session.store)) .ignoresSafeArea() .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) diff --git a/cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift b/cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift index 8d73a96dcd6e..e6383654aef4 100644 --- a/cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift +++ b/cnx-ios/CnxKit/Sources/CnxLive/LiveCoordinator.swift @@ -1196,7 +1196,9 @@ public final class LiveCoordinator { if reason.contains("unknown_service") || reason.contains("no service chief") { return String(localized: "This Mac's cmux is too old for Chief on iPhone. Update cmux on the Mac.") } - if reason.contains("No such file") || reason.contains("not set up") || reason.contains("Connection refused") { + // hostd closes the channel when the owner's socket is missing ("daemon channel closed"). + if reason.contains("No such file") || reason.contains("not set up") || reason.contains("Connection refused") + || reason.contains("channel closed") { return String(localized: "Chief isn't running on \(mac). Open cmux on the Mac to start it, then try again.") } return String(localized: "Chief on \(mac) didn't answer (\(reason)).") diff --git a/cnx-ios/CnxKit/Sources/CnxOnboarding/Setup.swift b/cnx-ios/CnxKit/Sources/CnxOnboarding/Setup.swift index 14c5113f2c5c..be9444be9d25 100644 --- a/cnx-ios/CnxKit/Sources/CnxOnboarding/Setup.swift +++ b/cnx-ios/CnxKit/Sources/CnxOnboarding/Setup.swift @@ -453,6 +453,9 @@ private struct CreateTeamPage: View { SetupPage(symbol: "person.2", title: "Create your team") { VStack(alignment: .leading, spacing: 10) { TextField("Team name", text: $name) + // Names are code-like: no autocorrect, no capitals (full verify D5). + .autocorrectionDisabled() + .textInputAutocapitalization(.never) .font(theme.type.body) .cnxInputField() .onSubmit(create) From d42a11659447a71894cbe705d3c7a565f33d8866 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:46:51 -0700 Subject: [PATCH 24/31] CmuxTabsKit: sync d785980a15c (Close All Tabs asks first) Co-Authored-By: Claude Opus 5.5 --- .../CmuxTabsKit/Logic/CloseAllConfirmation.swift | 9 +++++++++ .../Sources/CmuxTabsKit/UI/TabsView.swift | 14 +++++++++++++- .../CloseAllConfirmationTests.swift | 12 ++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/Logic/CloseAllConfirmation.swift create mode 100644 Packages/iOS/CmuxTabsKit/Tests/CmuxTabsKitTests/CloseAllConfirmationTests.swift diff --git a/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/Logic/CloseAllConfirmation.swift b/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/Logic/CloseAllConfirmation.swift new file mode 100644 index 000000000000..7a9d3ae4ce97 --- /dev/null +++ b/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/Logic/CloseAllConfirmation.swift @@ -0,0 +1,9 @@ +/// What Close All Tabs asks before it closes every tab (as Safari's grid does). +struct CloseAllConfirmation: Equatable { + var count: Int + + /// 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." } +} diff --git a/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/UI/TabsView.swift b/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/UI/TabsView.swift index 03954ed3efbc..33080b5748ef 100644 --- a/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/UI/TabsView.swift +++ b/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/UI/TabsView.swift @@ -51,6 +51,8 @@ struct TabsScreen: 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. @@ -154,7 +156,7 @@ struct TabsScreen: View { onClose: { id in await closeFromGrid(id) }, onNewTab: newTabFromOverview, onDone: { closeOverview(selecting: activeID) }, - onCloseAll: closeAll, + onCloseAll: { confirmingCloseAll = CloseAllConfirmation(count: tabs.count) }, pending: newTabSlot?.showsPlaceholder(in: tabs.map(\.id)) == true ? pendingNewTab : nil, onScrolling: { source.overviewScrollingChanged($0) }, onQueueSelect: { queuedOpen = $0 }, @@ -162,6 +164,16 @@ struct TabsScreen: View { .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() } + .accessibilityIdentifier("tabs.closeAll.confirm") + Button("Cancel", role: .cancel) {} + } message: { confirmation in + Text(confirmation.message) + } if chrome.swiping { swipeNeighbor diff --git a/Packages/iOS/CmuxTabsKit/Tests/CmuxTabsKitTests/CloseAllConfirmationTests.swift b/Packages/iOS/CmuxTabsKit/Tests/CmuxTabsKitTests/CloseAllConfirmationTests.swift new file mode 100644 index 000000000000..33a11053a2f1 --- /dev/null +++ b/Packages/iOS/CmuxTabsKit/Tests/CmuxTabsKitTests/CloseAllConfirmationTests.swift @@ -0,0 +1,12 @@ +@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(count: 5).confirmTitle == "Close All 5 Tabs") + #expect(CloseAllConfirmation(count: 1).confirmTitle == "Close 1 Tab") + #expect(CloseAllConfirmation(count: 5).message == "This can’t be undone.") + } +} From 49b95086c3f72dc3cd49c8f7f516df695c8c30db Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:47:31 -0700 Subject: [PATCH 25/31] cnx-ios test: a returned prompt keeps its id on resend; a draft the Mac ran is withdrawn (red) Co-Authored-By: Claude Opus 5.5 --- .../CnxAgentChatTests/PromptResendTests.swift | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 cnx-ios/CnxKit/Tests/CnxAgentChatTests/PromptResendTests.swift diff --git a/cnx-ios/CnxKit/Tests/CnxAgentChatTests/PromptResendTests.swift b/cnx-ios/CnxKit/Tests/CnxAgentChatTests/PromptResendTests.swift new file mode 100644 index 000000000000..e93a102adc37 --- /dev/null +++ b/cnx-ios/CnxKit/Tests/CnxAgentChatTests/PromptResendTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import CnxAgentChat + +/// Live recheck (2026-10-20): after a link drop, a prompt the Mac already ran came back into +/// the composer, and Send would have run it twice. A prompt keeps one id across a resend +/// (acpmux runs each `promptId` once), and a returned draft goes away once the Mac shows it ran. +@MainActor +@Suite struct PromptResendTests { + final class Recorder: AgentSessionSource { + let summary = AgentSessionSummary(id: "s", name: "S", harness: "codex", agentName: "Codex", cwd: "~", workspaceTitle: "w", macName: "m") + var sent: [AgentClientIntent] = [] + func events() -> AsyncStream { AsyncStream { _ in } } + func send(_ intent: AgentClientIntent) { sent.append(intent) } + } + + static func prompt(_ intent: AgentClientIntent?) -> (text: String, id: String, resend: Bool)? { + guard case .prompt(let text, _, _, let id, let resend)? = intent else { return nil } + return (text, id, resend) + } + + @Test func aReturnedPromptSentAgainKeepsItsID() throws { + let source = Recorder() + let store = AgentChatStore(source: source) + store.send(prompt: "run the tests") + let first = try #require(Self.prompt(source.sent.last)) + #expect(!first.resend) + // The link dropped before the reply: the draft comes back with its id. + store.apply(.promptNotSent(text: "run the tests", attachments: [], promptID: first.id, reason: "link down")) + #expect(store.returnedDraft?.promptID == first.id) + store.returnedDraft = nil // the composer took it + store.send(prompt: "run the tests") + let again = try #require(Self.prompt(source.sent.last)) + #expect(again.id == first.id) + #expect(again.resend) + // A new prompt gets a new id. + store.send(prompt: "something else") + #expect(Self.prompt(source.sent.last)?.id != first.id) + } + + @Test func anEditedDraftIsANewPrompt() throws { + let source = Recorder() + let store = AgentChatStore(source: source) + store.send(prompt: "run the tests") + let first = try #require(Self.prompt(source.sent.last)) + store.apply(.promptNotSent(text: "run the tests", attachments: [], promptID: first.id, reason: "link down")) + store.returnedDraft = nil + store.send(prompt: "run the unit tests") + #expect(Self.prompt(source.sent.last)?.id != first.id) + } + + @Test func aDraftTheMacRanIsWithdrawn() throws { + let source = Recorder() + let store = AgentChatStore(source: source) + store.send(prompt: "run the tests") + let first = try #require(Self.prompt(source.sent.last)) + store.apply(.promptNotSent(text: "run the tests", attachments: [], promptID: first.id, reason: "link down")) + store.returnedDraft = nil + // The replayed transcript shows the Mac ran it. + store.apply(.userMessage(text: "run the tests", promptId: first.id, turnId: "t1", attachments: [], at: Date())) + #expect(store.withdrawnDraft?.text == "run the tests") + // Nothing is left to resend under that id. + store.send(prompt: "run the tests") + let again = try #require(Self.prompt(source.sent.last)) + #expect(again.id != first.id) + #expect(!again.resend) + } +} From 1b4e275df0e43a893470bd5f3107d1ac8a765882 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:48:59 -0700 Subject: [PATCH 26/31] cnx-ios: a prompt keeps one acpmux id across a resend; a returned draft the Mac ran leaves the composer After a link drop, session/prompt's reply was lost although the Mac had run the prompt, so it came back into the composer and Send would run it twice. The prompt's promptId now travels with the returned draft; sending the same text again reuses it with resend (acpmux runs each id once), and when the replayed transcript shows that id ran or queued, the draft is withdrawn from the composer. Also updates two preview refusal tests for the D9 hint. Co-Authored-By: Claude Opus 5.5 --- .../Sources/CnxAgentChat/AgentChatStore.swift | 35 ++++++++++++++++--- .../CnxAgentChat/AgentSessionSource.swift | 7 ++-- .../Sources/CnxAgentChat/ChatComposer.swift | 8 +++++ .../CnxAgentChat/MockAgentSession.swift | 2 +- .../CnxKit/Sources/CnxLive/AcpmuxHub.swift | 16 ++++++--- .../CnxLiveTests/TerminalFloodTests.swift | 4 +-- 6 files changed, 57 insertions(+), 15 deletions(-) diff --git a/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentChatStore.swift b/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentChatStore.swift index c6b101fab460..f43c92b8bd5d 100644 --- a/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentChatStore.swift +++ b/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentChatStore.swift @@ -193,7 +193,14 @@ public final class AgentChatStore { public var id = UUID() public var text: String public var attachments: [AgentAttachment] - } + /// The prompt's acpmux id: sending the same text again reuses it, so the Mac runs it once. + public var promptID: String? + } + /// A returned draft the Mac turned out to have run (its prompt id showed up in the + /// transcript after the link came back): the composer takes it out again (then clears this). + public var withdrawnDraft: ReturnedDraft? + /// The last returned prompt still waiting for a resend: its text and id. + private var resendable: (text: String, promptID: String)? /// Bumps on every streamed change (follow-mode scroll keys off it). public private(set) var revision = 0 private var currentMessageID: String? @@ -253,7 +260,22 @@ public final class AgentChatStore { let deny = options.first { $0.kind == .rejectOnce } ?? options.first { !$0.isAllow } respond(to: permission.request, option: deny) } - source.send(.prompt(text: text, attachments: attachments, steer: steer)) + // The same prompt sent again after its reply was lost keeps its id: acpmux runs it once. + let resend = resendable.flatMap { $0.text == text ? $0.promptID : nil } + resendable = nil + source.send(.prompt(text: text, attachments: attachments, steer: steer, + promptID: resend ?? UUID().uuidString.lowercased(), resend: resend != nil)) + } + + /// The Mac shows `promptID` ran (or queued): a draft returned for it goes away, and it is + /// never sent again under that id. + private func reached(_ promptID: String) { + guard let pending = resendable, pending.promptID == promptID else { return } + resendable = nil + let draft = returnedDraft?.promptID == promptID ? returnedDraft : nil + if draft != nil { returnedDraft = nil } + withdrawnDraft = draft ?? ReturnedDraft(text: pending.text, attachments: [], promptID: promptID) + if clientError != nil { clientError = nil } } public func cancel() { source.send(.cancel) } @@ -303,12 +325,14 @@ public final class AgentChatStore { func apply(_ event: AgentSessionEvent) { revision &+= 1 switch event { - case .userMessage(let text, _, let turnId, let attachments, let at): + case .userMessage(let text, let promptId, let turnId, let attachments, let at): currentMessageID = nil + reached(promptId) if !turns.contains(where: { $0.id == turnId }) { turns.append(AgentTurn(id: turnId, prompt: text, attachments: attachments, promptAt: at)) } case .queued(let text, let promptId, _): + reached(promptId) queue.append(QueuedPrompt(id: promptId, text: text)) case .dequeued(let promptId): queue.removeAll { $0.id == promptId } @@ -403,9 +427,10 @@ public final class AgentChatStore { case .clientError(let message): approvingOptionID = nil clientError = message - case .promptNotSent(let text, let attachments, let reason): + case .promptNotSent(let text, let attachments, let promptID, let reason): clientError = reason - returnedDraft = ReturnedDraft(text: text, attachments: attachments) + returnedDraft = ReturnedDraft(text: text, attachments: attachments, promptID: promptID) + resendable = (text, promptID) case .subagent(let change): applySubagent(change) case .batch(let events): diff --git a/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentSessionSource.swift b/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentSessionSource.swift index c6c3444eafc0..f81820dffee9 100644 --- a/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentSessionSource.swift +++ b/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentSessionSource.swift @@ -62,7 +62,8 @@ public enum AgentSessionEvent: Hashable, Sendable { case clientError(String) /// A prompt that never reached the agent (an attachment upload failed, the /// link was down): the composer gets the draft back with the reason. - case promptNotSent(text: String, attachments: [AgentAttachment], reason: String) + /// `promptID` is the prompt's acpmux id: sent again with it, the Mac runs it at most once. + case promptNotSent(text: String, attachments: [AgentAttachment], promptID: String, reason: String) /// `_meta.acpmux.subagents`: a subagent started or changed state. case subagent(AgentSubagentChange) /// An update from a subagent's own session (`_meta.acpmux.subagent`). @@ -76,7 +77,9 @@ public enum AgentSessionEvent: Hashable, Sendable { /// `session/prompt`, `session/cancel`, `_acpmux/permission_respond`, `session/set_model`, /// `session/set_mode`, `session/set_config_option`, `_acpmux/queue_remove`, `_acpmux/queue_edit`. public enum AgentClientIntent: Hashable, Sendable { - case prompt(text: String, attachments: [AgentAttachment], steer: Bool) + /// `promptID`: `_meta.acpmux.promptId`, which acpmux runs once; `resend`: the same prompt + /// sent again after its reply was lost (acpmux also looks for the id in the session log). + case prompt(text: String, attachments: [AgentAttachment], steer: Bool, promptID: String, resend: Bool) case cancel case respondPermission(permissionId: String, optionId: String?) case answerQuestion(questionId: String, answer: AgentQuestionAnswer) diff --git a/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift b/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift index e3ddfc56a6a1..65da860e387c 100644 --- a/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift +++ b/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift @@ -81,6 +81,14 @@ struct ChatComposer: View { } .onAppear { if expandOnAppear { expand() } } // A prompt that never reached the agent comes back to the composer. + // A returned prompt the Mac turned out to have run: out of the composer again, + // unless the person already changed it. + .onChange(of: store.withdrawnDraft) { _, draft in + guard let draft else { return } + store.withdrawnDraft = nil + if trimmed == draft.text.trimmingCharacters(in: .whitespacesAndNewlines) { text = "" } + attachments.removeAll { own in draft.attachments.contains { $0.id == own.id } } + } .onChange(of: store.returnedDraft) { _, draft in guard let draft else { return } store.returnedDraft = nil diff --git a/cnx-ios/CnxKit/Sources/CnxAgentChat/MockAgentSession.swift b/cnx-ios/CnxKit/Sources/CnxAgentChat/MockAgentSession.swift index 2faa616e87a4..25a223a4ad04 100644 --- a/cnx-ios/CnxKit/Sources/CnxAgentChat/MockAgentSession.swift +++ b/cnx-ios/CnxKit/Sources/CnxAgentChat/MockAgentSession.swift @@ -61,7 +61,7 @@ public final class MockAgentSession: AgentSessionSource { public func send(_ intent: AgentClientIntent) { switch intent { - case .prompt(let text, let attachments, let steer): + case .prompt(let text, let attachments, let steer, _, _): let promptID = UUID().uuidString if running != nil, !steer { queued.append((promptID, text, attachments)) diff --git a/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift b/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift index c63605545096..594a5f344951 100644 --- a/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift +++ b/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift @@ -314,22 +314,28 @@ public final class AcpmuxSessionSource: AgentSessionSource { Task { do { switch intent { - case .prompt(let text, let attachments, let steer): + case .prompt(let text, let attachments, let steer, let promptID, let resend): let blocks: [JSONValue] do { blocks = try await self.promptBlocks(text: text, attachments: attachments, hub: hub) } catch { - self.emit(.promptNotSent(text: text, attachments: attachments, reason: AcpmuxSessionSource.message(error))) + self.emit(.promptNotSent(text: text, attachments: attachments, promptID: promptID, + reason: AcpmuxSessionSource.message(error))) return } - let meta: JSONValue = .object(["acpmux": .object(["promptId": .string(UUID().uuidString.lowercased()), "steer": .bool(steer)])]) + // acpmux runs each promptId once; `resend` also checks the session log. + let meta: JSONValue = .object(["acpmux": .object([ + "promptId": .string(promptID), "steer": .bool(steer), "resend": .bool(resend), + ])]) do { _ = try await hub.request("session/prompt", .object([ "sessionId": session, "prompt": .array(blocks), "_meta": meta, ])) } catch let error as LinkError { - // Never reached the Mac: the draft comes back. - self.emit(.promptNotSent(text: text, attachments: attachments, reason: AcpmuxSessionSource.message(error))) + // Maybe never reached the Mac: the draft comes back with its id, so a resend + // runs at most once, and it leaves again if the transcript shows it ran. + self.emit(.promptNotSent(text: text, attachments: attachments, promptID: promptID, + reason: AcpmuxSessionSource.message(error))) } case .cancel: try await hub.notify("session/cancel", .object(["sessionId": session])) diff --git a/cnx-ios/CnxKit/Tests/CnxLiveTests/TerminalFloodTests.swift b/cnx-ios/CnxKit/Tests/CnxLiveTests/TerminalFloodTests.swift index da0455d749d3..321bfabb4a81 100644 --- a/cnx-ios/CnxKit/Tests/CnxLiveTests/TerminalFloodTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxLiveTests/TerminalFloodTests.swift @@ -160,7 +160,7 @@ import Testing Issue.record("a refused read must not load") } catch let error as ArtifactUnavailable { #expect(error.message == "sample.md is outside the folders Studio shares with this phone. " - + "To open it here, add its project folder in cmux on the Mac: Settings › General › Chats › Chat Folders.") + + "To open it here, open a workspace in its project folder on the Mac, or add the folder in cmux on the Mac: Settings › General › Chats › Chat Folders.") } catch { Issue.record("unexpected \(error)") } @@ -174,7 +174,7 @@ import Testing Issue.record("a refused read must not load") } catch let error as ArtifactUnavailable { #expect(error.message == "Studio isn't sharing any folders with this phone yet, so notes.md can't be opened here. " - + "To open it here, add its project folder in cmux on the Mac: Settings › General › Chats › Chat Folders.") + + "To open it here, open a workspace in its project folder on the Mac, or add the folder in cmux on the Mac: Settings › General › Chats › Chat Folders.") } catch { Issue.record("unexpected \(error)") } From 1f521e8f076e45c57407274e369b10a4ec86ed65 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:50:27 -0700 Subject: [PATCH 27/31] cnx-ios D4 test: a chat's models, modes and options come from _acpmux/attach's session detail (red) Co-Authored-By: Claude Opus 5.5 --- .../CnxLiveTests/SessionStateTests.swift | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 cnx-ios/CnxKit/Tests/CnxLiveTests/SessionStateTests.swift diff --git a/cnx-ios/CnxKit/Tests/CnxLiveTests/SessionStateTests.swift b/cnx-ios/CnxKit/Tests/CnxLiveTests/SessionStateTests.swift new file mode 100644 index 000000000000..dad38b4f6e22 --- /dev/null +++ b/cnx-ios/CnxKit/Tests/CnxLiveTests/SessionStateTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import CnxAgentChat +@testable import CnxLive + +/// Full verify D4 (live recheck): a running Codex chat on the phone had no model menu. The phone +/// read the session's models and modes only from later updates, never from `_acpmux/attach`'s +/// session detail, where acpmux keeps them (`models`, `modes`, `configOptions`). +@Suite struct SessionStateTests { + func json(_ text: String) throws -> JSONValue { + try JSONDecoder().decode(JSONValue.self, from: Data(text.utf8)) + } + + @Test func attachCarriesModelsModesAndOptions() throws { + let detail = try json(""" + {"models": {"currentModelId": "gpt-5.5", "availableModels": [{"modelId": "gpt-5.5", "name": "GPT-5.5"}, {"modelId": "gpt-6-astra", "name": "GPT-6 Astra"}]}, + "modes": {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}, {"id": "auto", "name": "Auto"}]}, + "configOptions": [{"id": "effort", "name": "Reasoning", "category": "thought_level", "currentValue": "medium", "type": "select", + "options": [{"value": "low", "name": "low"}, {"value": "medium", "name": "medium"}]}]} + """) + let events = AcpmuxSessionSource.sessionState(detail) + #expect(events.contains(.modelsAvailable([ACPModelInfo(id: "gpt-5.5", name: "GPT-5.5"), ACPModelInfo(id: "gpt-6-astra", name: "GPT-6 Astra")], + current: "gpt-5.5"))) + #expect(events.contains(.modesAvailable([ACPMode(id: "ask", name: "Ask"), ACPMode(id: "auto", name: "Auto")], current: "ask"))) + #expect(events.contains { if case .update(.configOptions(let options)) = $0 { return options.first?.isEffort == true }; return false }) + } + + /// A harness that offers its model as a config option (category `model`) still gets a menu. + @Test func aModelConfigOptionIsTheModelList() throws { + let detail = try json(""" + {"configOptions": [{"id": "model", "name": "Model", "category": "model", "currentValue": "gpt-5.5", "type": "select", + "options": [{"value": "gpt-5.5", "name": "GPT-5.5"}, {"value": "gpt-6-astra"}]}]} + """) + let events = AcpmuxSessionSource.sessionState(detail) + #expect(events.contains(.modelsAvailable([ACPModelInfo(id: "gpt-5.5", name: "GPT-5.5"), ACPModelInfo(id: "gpt-6-astra", name: "gpt-6-astra")], + current: "gpt-5.5"))) + } + + @Test func nothingKnownIsNothing() throws { + #expect(AcpmuxSessionSource.sessionState(try json(#"{"models": null, "modes": null, "configOptions": null}"#)).isEmpty) + } +} From a553ef2b4775e57f88105203afe52a95b78a7f4c Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:51:37 -0700 Subject: [PATCH 28/31] cnx-ios D4: a chat's models, modes and options come from _acpmux/attach, so a running chat has its model menu The phone took models and modes only from later session updates, which a Codex chat never sends after it starts; acpmux keeps them in the session detail _acpmux/attach returns. A config option of category model also serves as the list. The menu's check follows a pick. Co-Authored-By: Claude Opus 5.5 --- .../CnxKit/Sources/CnxLive/AcpmuxHub.swift | 41 +++++++++++++++++++ .../CnxLiveTests/SessionStateTests.swift | 1 + 2 files changed, 42 insertions(+) diff --git a/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift b/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift index 594a5f344951..fdee5eee18de 100644 --- a/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift +++ b/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift @@ -349,6 +349,8 @@ public final class AcpmuxSessionSource: AgentSessionSource { try await hub.answer(question, with: answer) case .setModel(let id): _ = try await hub.request("session/set_model", .object(["sessionId": session, "modelId": .string(id)])) + // The menu's check follows the pick (no record carries the model change). + if !self.knownModels.isEmpty { self.emit(.modelsAvailable(self.knownModels, current: id)) } case .setMode(let id): _ = try await hub.request("session/set_mode", .object(["sessionId": session, "modeId": .string(id)])) case .setConfigOption(let id, let value): @@ -598,6 +600,14 @@ public final class AcpmuxSessionSource: AgentSessionSource { summary.status = session.summaryStatus if !session.name.isEmpty { summary.name = session.name } } + // The session's models, modes and options as acpmux keeps them now (D4: a running + // chat had no model menu, as they only came with later updates). + if let detail = result["session"] { + for event in Self.sessionState(detail) { + if case .modelsAvailable(let models, _) = event { knownModels = models } + emit(event) + } + } attached = true for record in page { receive(record: record) } let pending = held @@ -614,6 +624,37 @@ public final class AcpmuxSessionSource: AgentSessionSource { } } + /// The session's models (from attach), for the menu after a pick. + private var knownModels: [ACPModelInfo] = [] + + /// `_acpmux/attach`'s session detail as chat events: `models` ({currentModelId, + /// availableModels}), else a config option of category `model`; `modes` ({currentModeId, + /// availableModes}); `configOptions` (effort and the rest). + static func sessionState(_ detail: JSONValue) -> [AgentSessionEvent] { + var events: [AgentSessionEvent] = [] + let options: [ACPConfigOption] = detail["configOptions"].flatMap { value in + guard case .array = value, let data = try? JSONEncoder().encode(value) else { return nil } + return try? JSONDecoder().decode([ACPConfigOption].self, from: data) + } ?? [] + var models = (detail["models"]?["availableModels"]?.arrayValue ?? []).compactMap { model -> ACPModelInfo? in + guard let id = model["modelId"]?.stringValue, !id.isEmpty else { return nil } + return ACPModelInfo(id: id, name: model["name"]?.stringValue.flatMap { $0.isEmpty ? nil : $0 } ?? id) + } + var current = detail["models"]?["currentModelId"]?.stringValue + if models.isEmpty, let option = options.first(where: { $0.category == "model" || $0.id == "model" }) { + models = option.options.map { ACPModelInfo(id: $0.value, name: $0.name.flatMap { $0.isEmpty ? nil : $0 } ?? $0.value) } + current = option.currentValue + } + if !models.isEmpty { events.append(.modelsAvailable(models, current: current)) } + let modes = (detail["modes"]?["availableModes"]?.arrayValue ?? []).compactMap { mode -> ACPMode? in + guard let id = mode["id"]?.stringValue, !id.isEmpty else { return nil } + return ACPMode(id: id, name: mode["name"]?.stringValue ?? id, description: mode["description"]?.stringValue) + } + if !modes.isEmpty { events.append(.modesAvailable(modes, current: detail["modes"]?["currentModeId"]?.stringValue)) } + if !options.isEmpty { events.append(.update(.configOptions(options))) } + return events + } + func receive(record: JSONValue) { guard attached else { held.append(record) diff --git a/cnx-ios/CnxKit/Tests/CnxLiveTests/SessionStateTests.swift b/cnx-ios/CnxKit/Tests/CnxLiveTests/SessionStateTests.swift index dad38b4f6e22..d03666594693 100644 --- a/cnx-ios/CnxKit/Tests/CnxLiveTests/SessionStateTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxLiveTests/SessionStateTests.swift @@ -6,6 +6,7 @@ import Testing /// Full verify D4 (live recheck): a running Codex chat on the phone had no model menu. The phone /// read the session's models and modes only from later updates, never from `_acpmux/attach`'s /// session detail, where acpmux keeps them (`models`, `modes`, `configOptions`). +@MainActor @Suite struct SessionStateTests { func json(_ text: String) throws -> JSONValue { try JSONDecoder().decode(JSONValue.self, from: Data(text.utf8)) From 5180efa01e27ae98a663a5a08eebbe7f09703e0f Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:57:51 -0700 Subject: [PATCH 29/31] cnx-ios review fixes: no returned draft for a prompt seen running; attachments count and leave with a withdrawn draft; Chief keeps unsent text across a rebuild; attach state after the page Co-Authored-By: Claude Opus 5.5 --- .../Sources/CnxAgentChat/AgentChatStore.swift | 18 +++++++--- .../Sources/CnxAgentChat/ChatComposer.swift | 2 +- .../CnxChief/ChiefConversationScreen.swift | 25 ++++++++++++-- .../Sources/CnxChief/ChiefSession.swift | 14 ++++++++ .../CnxKit/Sources/CnxLive/AcpmuxHub.swift | 8 +++-- .../CnxAgentChatTests/PromptResendTests.swift | 34 +++++++++++++++++++ 6 files changed, 89 insertions(+), 12 deletions(-) diff --git a/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentChatStore.swift b/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentChatStore.swift index f43c92b8bd5d..c0120a3c55b4 100644 --- a/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentChatStore.swift +++ b/cnx-ios/CnxKit/Sources/CnxAgentChat/AgentChatStore.swift @@ -199,8 +199,11 @@ public final class AgentChatStore { /// A returned draft the Mac turned out to have run (its prompt id showed up in the /// transcript after the link came back): the composer takes it out again (then clears this). public var withdrawnDraft: ReturnedDraft? - /// The last returned prompt still waiting for a resend: its text and id. - private var resendable: (text: String, promptID: String)? + /// The last returned prompt still waiting for a resend: what it held and its id. + private var resendable: (text: String, attachments: [AgentAttachment], promptID: String)? + /// Prompt ids the transcript showed running or queued (a lost reply for one of them is not + /// a prompt that never went out). + private var seenPromptIDs: Set = [] /// Bumps on every streamed change (follow-mode scroll keys off it). public private(set) var revision = 0 private var currentMessageID: String? @@ -261,7 +264,9 @@ public final class AgentChatStore { respond(to: permission.request, option: deny) } // The same prompt sent again after its reply was lost keeps its id: acpmux runs it once. - let resend = resendable.flatMap { $0.text == text ? $0.promptID : nil } + let resend = resendable.flatMap { pending in + pending.text == text && pending.attachments.map(\.id) == attachments.map(\.id) ? pending.promptID : nil + } resendable = nil source.send(.prompt(text: text, attachments: attachments, steer: steer, promptID: resend ?? UUID().uuidString.lowercased(), resend: resend != nil)) @@ -270,11 +275,12 @@ public final class AgentChatStore { /// The Mac shows `promptID` ran (or queued): a draft returned for it goes away, and it is /// never sent again under that id. private func reached(_ promptID: String) { + seenPromptIDs.insert(promptID) guard let pending = resendable, pending.promptID == promptID else { return } resendable = nil let draft = returnedDraft?.promptID == promptID ? returnedDraft : nil if draft != nil { returnedDraft = nil } - withdrawnDraft = draft ?? ReturnedDraft(text: pending.text, attachments: [], promptID: promptID) + withdrawnDraft = draft ?? ReturnedDraft(text: pending.text, attachments: pending.attachments, promptID: promptID) if clientError != nil { clientError = nil } } @@ -428,9 +434,11 @@ public final class AgentChatStore { approvingOptionID = nil clientError = message case .promptNotSent(let text, let attachments, let promptID, let reason): + // The transcript already showed it run: only its reply was lost. + guard !seenPromptIDs.contains(promptID) else { break } clientError = reason returnedDraft = ReturnedDraft(text: text, attachments: attachments, promptID: promptID) - resendable = (text, promptID) + resendable = (text, attachments, promptID) case .subagent(let change): applySubagent(change) case .batch(let events): diff --git a/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift b/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift index 65da860e387c..ee7ee738e3b4 100644 --- a/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift +++ b/cnx-ios/CnxKit/Sources/CnxAgentChat/ChatComposer.swift @@ -80,7 +80,6 @@ struct ChatComposer: View { if focused { expandRequested = false } } .onAppear { if expandOnAppear { expand() } } - // A prompt that never reached the agent comes back to the composer. // A returned prompt the Mac turned out to have run: out of the composer again, // unless the person already changed it. .onChange(of: store.withdrawnDraft) { _, draft in @@ -89,6 +88,7 @@ struct ChatComposer: View { if trimmed == draft.text.trimmingCharacters(in: .whitespacesAndNewlines) { text = "" } attachments.removeAll { own in draft.attachments.contains { $0.id == own.id } } } + // A prompt that never reached the agent comes back to the composer. .onChange(of: store.returnedDraft) { _, draft in guard let draft else { return } store.returnedDraft = nil diff --git a/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift b/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift index 9d9776df819e..6479fd30bdfd 100644 --- a/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift +++ b/cnx-ios/CnxKit/Sources/CnxChief/ChiefConversationScreen.swift @@ -537,7 +537,15 @@ public final class ChiefConversationContainer: UIViewController, UIGestureRecogn if let pop = navigation.interactivePopGestureRecognizer { recognizers.append(pop) } if #available(iOS 26.0, *), let content = navigation.interactiveContentPopGestureRecognizer { recognizers.append(content) } for recognizer in recognizers { - if recognizer.delegate !== self { previousPopDelegates[ObjectIdentifier(recognizer)] = WeakDelegate(value: recognizer.delegate) } + if recognizer.delegate !== self { + // A Chief screen rebuilt on a new store (Try Again) takes over from the old one: + // keep the navigation's own delegate, never the outgoing Chief screen. + if let outgoing = recognizer.delegate as? ChiefConversationContainer { + previousPopDelegates[ObjectIdentifier(recognizer)] = outgoing.previousPopDelegates[ObjectIdentifier(recognizer)] + } else { + previousPopDelegates[ObjectIdentifier(recognizer)] = WeakDelegate(value: recognizer.delegate) + } + } recognizer.isEnabled = true recognizer.delegate = self } @@ -571,10 +579,19 @@ public final class ChiefConversationContainer: UIViewController, UIGestureRecogn field?.becomeFirstResponder() } + /// The Chief screen on screen now: a rebuilt one may appear before the old one disappears. + private static weak var shown: ChiefConversationContainer? + public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) enableEdgePop() + Self.shown = self session.isVisible = true + // Text written on the screen this one replaced (Try Again rebuilt it on a new store). + if let carried = session.takeCarriedComposerText(), let field = composerTextView, field.text.isEmpty { + field.text = carried + field.delegate?.textViewDidChange?(field) + } session.markSeen() showOutboxIfWaiting() if focusesComposerOnAppear { @@ -589,12 +606,14 @@ public final class ChiefConversationContainer: UIViewController, UIGestureRecogn public override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - // A sheet over Chief keeps it on screen. - if presentedViewController == nil { session.isVisible = false } + // A sheet over Chief keeps it on screen; so does the screen that replaced this one. + if presentedViewController == nil, Self.shown === self || Self.shown == nil { session.isVisible = false } } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + // Unsent text goes with the session to a rebuilt screen (it reads it on appear). + session.carryComposerText(composerTextView?.text ?? "") // Hand the recognizers back to their own delegates for the next screen. for recognizer in popRecognizers where recognizer.delegate === self { recognizer.delegate = previousPopDelegates[ObjectIdentifier(recognizer)]?.value diff --git a/cnx-ios/CnxKit/Sources/CnxChief/ChiefSession.swift b/cnx-ios/CnxKit/Sources/CnxChief/ChiefSession.swift index 2901be1f7c93..46bd201d4c18 100644 --- a/cnx-ios/CnxKit/Sources/CnxChief/ChiefSession.swift +++ b/cnx-ios/CnxKit/Sources/CnxChief/ChiefSession.swift @@ -193,6 +193,20 @@ public final class ChiefSession { reconnect() } + /// Composer text of a Chief screen that went away, for the screen that replaces it. + private var carriedComposerText: String? + + /// The leaving screen's unsent text (empty clears it). + public func carryComposerText(_ text: String) { + carriedComposerText = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : text + } + + /// The carried text, once. + public func takeCarriedComposerText() -> String? { + defer { carriedComposerText = nil } + return carriedComposerText + } + /// The coordinator's try ended (connected, refused or replaced): Try Again is offered again. public func endReconnect() { if isReconnecting { isReconnecting = false } diff --git a/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift b/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift index fdee5eee18de..b998d3d86120 100644 --- a/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift +++ b/cnx-ios/CnxKit/Sources/CnxLive/AcpmuxHub.swift @@ -600,16 +600,18 @@ public final class AcpmuxSessionSource: AgentSessionSource { summary.status = session.summaryStatus if !session.name.isEmpty { summary.name = session.name } } + attached = true + for record in page { receive(record: record) } // The session's models, modes and options as acpmux keeps them now (D4: a running - // chat had no model menu, as they only came with later updates). + // chat had no model menu, as they only came with later updates). After the page: + // a pick (set_model, set_mode, set_config_option) writes no record, so an older + // update in the page must not win over the state now. if let detail = result["session"] { for event in Self.sessionState(detail) { if case .modelsAvailable(let models, _) = event { knownModels = models } emit(event) } } - attached = true - for record in page { receive(record: record) } let pending = held held = [] for record in pending { receive(record: record) } diff --git a/cnx-ios/CnxKit/Tests/CnxAgentChatTests/PromptResendTests.swift b/cnx-ios/CnxKit/Tests/CnxAgentChatTests/PromptResendTests.swift index e93a102adc37..6b46dca41d33 100644 --- a/cnx-ios/CnxKit/Tests/CnxAgentChatTests/PromptResendTests.swift +++ b/cnx-ios/CnxKit/Tests/CnxAgentChatTests/PromptResendTests.swift @@ -65,4 +65,38 @@ import Testing #expect(again.id != first.id) #expect(!again.resend) } + + /// Review: the reply was lost after the transcript already showed the prompt: nothing comes back. + @Test func aPromptSeenRunningNeverComesBack() throws { + let source = Recorder() + let store = AgentChatStore(source: source) + store.send(prompt: "run the tests") + let first = try #require(Self.prompt(source.sent.last)) + store.apply(.userMessage(text: "run the tests", promptId: first.id, turnId: "t1", attachments: [], at: Date())) + store.apply(.promptNotSent(text: "run the tests", attachments: [], promptID: first.id, reason: "link down")) + #expect(store.returnedDraft == nil) + store.send(prompt: "run the tests") + #expect(Self.prompt(source.sent.last)?.id != first.id) + } + + /// Review: other attachments are another prompt; a withdrawal takes the attachments too. + @Test func attachmentsCountAndLeaveWithTheDraft() throws { + let source = Recorder() + let store = AgentChatStore(source: source) + let photo = AgentAttachment(kind: .file, name: "a.txt") + store.send(prompt: "look", attachments: [photo]) + let first = try #require(Self.prompt(source.sent.last)) + store.apply(.promptNotSent(text: "look", attachments: [photo], promptID: first.id, reason: "link down")) + store.returnedDraft = nil + store.send(prompt: "look", attachments: []) + #expect(Self.prompt(source.sent.last)?.id != first.id) + + store.send(prompt: "look again", attachments: [photo]) + let second = try #require(Self.prompt(source.sent.last)) + store.apply(.promptNotSent(text: "look again", attachments: [photo], promptID: second.id, reason: "link down")) + store.returnedDraft = nil + store.apply(.queued(text: "look again", promptId: second.id, position: 0)) + #expect(store.withdrawnDraft?.attachments == [photo]) + } } + From d79ea22ea420d13e0697bda515aea8cef5002daf Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:58:46 -0700 Subject: [PATCH 30/31] CmuxTabsKit: sync 087c69c04b0 (Close All closes the tabs it asked about) Co-Authored-By: Claude Opus 5.5 --- .../CmuxTabsKit/Logic/CloseAllConfirmation.swift | 4 +++- .../CmuxTabsKit/Sources/CmuxTabsKit/UI/TabsView.swift | 7 +++---- .../CmuxTabsKitTests/CloseAllConfirmationTests.swift | 11 ++++++++--- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/Logic/CloseAllConfirmation.swift b/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/Logic/CloseAllConfirmation.swift index 7a9d3ae4ce97..eaa09b521a53 100644 --- a/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/Logic/CloseAllConfirmation.swift +++ b/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/Logic/CloseAllConfirmation.swift @@ -1,7 +1,9 @@ /// What Close All Tabs asks before it closes every tab (as Safari's grid does). struct CloseAllConfirmation: Equatable { - var count: Int + /// 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. diff --git a/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/UI/TabsView.swift b/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/UI/TabsView.swift index 33080b5748ef..cf8d4408fdff 100644 --- a/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/UI/TabsView.swift +++ b/Packages/iOS/CmuxTabsKit/Sources/CmuxTabsKit/UI/TabsView.swift @@ -156,7 +156,7 @@ struct TabsScreen: View { onClose: { id in await closeFromGrid(id) }, onNewTab: newTabFromOverview, onDone: { closeOverview(selecting: activeID) }, - onCloseAll: { confirmingCloseAll = CloseAllConfirmation(count: tabs.count) }, + onCloseAll: { confirmingCloseAll = CloseAllConfirmation(ids: tabs.map(\.id)) }, pending: newTabSlot?.showsPlaceholder(in: tabs.map(\.id)) == true ? pendingNewTab : nil, onScrolling: { source.overviewScrollingChanged($0) }, onQueueSelect: { queuedOpen = $0 }, @@ -168,7 +168,7 @@ struct TabsScreen: View { .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() } + Button(confirmation.confirmTitle, role: .destructive) { closeAll(confirmation.ids) } .accessibilityIdentifier("tabs.closeAll.confirm") Button("Cancel", role: .cancel) {} } message: { confirmation in @@ -627,8 +627,7 @@ struct TabsScreen: 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) } } diff --git a/Packages/iOS/CmuxTabsKit/Tests/CmuxTabsKitTests/CloseAllConfirmationTests.swift b/Packages/iOS/CmuxTabsKit/Tests/CmuxTabsKitTests/CloseAllConfirmationTests.swift index 33a11053a2f1..7b9be4aa8013 100644 --- a/Packages/iOS/CmuxTabsKit/Tests/CmuxTabsKitTests/CloseAllConfirmationTests.swift +++ b/Packages/iOS/CmuxTabsKit/Tests/CmuxTabsKitTests/CloseAllConfirmationTests.swift @@ -5,8 +5,13 @@ import Testing /// closed every tab of a workspace with no way back (cnx-ios live recheck, 2026-10-20). struct CloseAllConfirmationTests { @Test func namesHowManyTabsClose() { - #expect(CloseAllConfirmation(count: 5).confirmTitle == "Close All 5 Tabs") - #expect(CloseAllConfirmation(count: 1).confirmTitle == "Close 1 Tab") - #expect(CloseAllConfirmation(count: 5).message == "This can’t be undone.") + #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"]) } } From e9e9bf0b2326ed0d40445ef7f0e310032a227dbf Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:20:33 -0700 Subject: [PATCH 31/31] cnx-ios verify: fixes-r1 second live pass (Mac a553ef2b, phone d79ea22) Co-Authored-By: Claude Opus 5.5 --- cnx-ios/verify/fixes-r1-20261020.md | 39 +++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/cnx-ios/verify/fixes-r1-20261020.md b/cnx-ios/verify/fixes-r1-20261020.md index 9c3d07ee6823..72d5f916ae84 100644 --- a/cnx-ios/verify/fixes-r1-20261020.md +++ b/cnx-ios/verify/fixes-r1-20261020.md @@ -63,3 +63,42 @@ Evidence (git-ignored): `verify/evidence/2026-10-20/fixes-r1/` - The rig app was quit and its leftover daemons (cmux-tui, optchat-chief, acpmux) killed. The simulator `cnx-fixr1-1` was shut down and deleted. `~/cnx-fixr1`, the rig's Chief home and App Support folder and `~/fixr1-outside.txt` were removed from mini-2, and `~/cnx-fixr1` from mini-cmux7s. - `~/cnx-ios-e2e/*`, other agents' rigs (`cnx-e2e-604fc689`) and the other booted simulator on mini-2 were left alone. + +## Second pass, 2026-10-20 + +### Build under test + +- Mac rig: fleet job `8ddc14f818104818f5d51e2e`, tag `cnx-e2e-a553ef2b` + (`a553ef2b477`). `d79ea22ea42` changes nothing on the Mac side. +- Phone: Demo build of `d79ea22ea42`, made with Xcode 26.6 on mini-cmux7s + with the same flags as the first pass. It is built incrementally on the + `a553ef2b477` tree plus the nine files that differ between the two SHAs. +- CnxFFI key `86981cab09c8dd5c`, built from `a553ef2b477`. The regenerated + `CnxFFI.swift` is identical to the committed one. +- Rig: hidden on mini-cmuxs-mac-mini-2 (device `ff7bebfb…0087`), one simulator + `cnx-fixr2-1`. +- Simulator keyboard: after a hardware `axe type`, the simulator's soft + keyboard stayed hidden until `AutomaticMinimizationEnabled` and + `HardwareKeyboardLastSeen` were set to false in that simulator's + `com.apple.keyboard.preferences` and it was rebooted. + +Evidence (git-ignored): `verify/evidence/2026-10-20/fixes-r1-pass2/` +(`shots/`, `video/01…04-*.mp4`, `04-chief-unavailable-ax.json`, `cnx-hostd.log`). + +| Item | Result | Evidence | Notes | +| --- | --- | --- | --- | +| Onboarding "Create your team" (D5) | **Pass** | `shots/02-onboarding-team-typed.png`, `video/01b` | Soft keys typed "fv team" and it stayed as typed. The first pass got "Fv team". | +| Chief refusal copy | **Pass** | `shots/04-chief-unavailable.png` | With the socket hidden, the bar reads "Chief isn't running on cmux’s Mac mini (2). Open cmux on the Mac to start it, then try again." | +| Chief + and record off while unavailable | **Pass** | `04-chief-unavailable-ax.json` | `conversation.composer.plus` and `conversation.composer.record` both report `enabled: false`. | +| Try Again with the screen open (ffbcd54) | **Pass** | `shots/05-before-tryagain.png`, `06-after-tryagain.png`, `video/02` | After the socket was restored, Try Again drew the conversation (title, header) on the same screen: no spinner, and no need to leave. | +| Try Again keeps the draft (d79ea22) | **Pass** | `shots/07-draft-while-unavailable.png`, `08-after-tryagain-draft-kept.png` | Text typed while Chief was unavailable stayed in the composer after Try Again connected. | +| Codex Default (review fix) | **Pass** | `shots/09-chooser-codex-default.png`, `14-turn-done-composer-empty.png` | A turn on Default (GPT-6-Astra) added `mul` to calc.py and answered "Done — …". | +| D9 preview / refusal | **Pass** | `shots/19-preview-calc.png`, `20-preview-outside-refused.png` | calc.py in the workspace folder opens and shows `def mul`. `~/fixr2-outside.txt` gets "Can't open this file". | +| Prompt resend (1b4e275) | **Pass** | `shots/11-turn-running.png`, `12-link-dropped.png`, `13-after-reconnect.png`, `14-…`, `video/03`, `cnx-hostd.log` | Mid-turn I killed only the rig's cnx-hostd with SIGTERM at 05:09:21. The app restarted it, and the link was back at 05:09:23. The turn finished and the composer stayed empty ("Reply to Codex"). The transcript holds the prompt once, and calc.py has one `mul`. | +| Close All Tabs confirmation (d785980a15c) | **Pass, one note** | `shots/22-more-menu.png`, `23-close-all-confirm.png`, `24-after-cancel.png`, `video/04` | More > Close All Tabs opens a popover: "This can't be undone." and "Close All 2 Tabs". It has no visible Cancel row; tapping outside (VoiceOver: "dismiss popup") cancels it. Both tabs stay on the phone and the Mac. | +| D4 model menu | **Pass** | `shots/10-codex-prompt.png`, `15-model-menu.png`, `16-model-switched.png`, `17-turn-after-switch.png` | The expanded composer shows the pill "GPT-6-Astra, Default". The menu lists GPT-6-Astra, GPT-5.6-Sol, GPT-5.6-Terra, GPT-5.6-Luna and GPT-5.5, plus mode (Ask) and Reasoning effort. Picking GPT-5.6-Luna moves the check and the pill. The Mac's Codex rollout for this chat records `gpt-6-astra` for the first turn and `gpt-5.6-luna` for the turns after the switch. | +| D6 diff rows | **Partial** | `shots/21-diff-rows.png` | VoiceOver labels read "line: def add(a, b):" and "added line: def mul(a, b):"; "line 0" is gone. The gutter still has no line numbers. | +| D13 Home preview | **Pass** | `shots/25-home-preview.png` | The proj2 row reads "Codex, 10:15 PM, done", the newest reply. | +| D1 | Not touched | — | As instructed: no Worker and no keys. | + +Cleanup: the rig app was quit and its daemons killed. The simulator was deleted. `~/cnx-fixr2`, the rig's Chief home, its App Support folder and `~/fixr2-outside.txt` were removed from mini-2, and `~/cnx-fixr2` from mini-cmux7s.