Skip to content

iOS: multi-Mac host switcher - #5513

Merged
lawrencecchen merged 7 commits into
mainfrom
feat-ios-multi-mac-switcher
Jun 6, 2026
Merged

lawrencecchen merged 7 commits into
mainfrom
feat-ios-multi-mac-switcher

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • A multi-Mac host switcher for iOS: Settings → Connection → Switch Mac opens a picker listing every paired Mac, with the active one checkmarked. Tap to switch (re-points the live connection), swipe to forget, and Pair Another Mac scans a new Mac's QR without dropping the others.

How it works

  • The on-device SQLite store already persists N paired Macs (loadAll/setActive/activeMac); only the UI to use them was missing. loadAll was previously test-only.
  • MobileShellComposite gains pairedMacs, activeMacDeviceID, loadPairedMacs(), switchToMac(macDeviceID:) (setActive → reconnect via the existing launch-time reconnect path), and forgetMac(macDeviceID:).
  • MobileHostPickerView (new) drives those; it's reached from MobileSettingsView's Connection section. The store is threaded as an optional through WorkspaceShellView → WorkspaceListView → MobileSettingsView (workspace rows stay value-only; the picker is a small modal).
  • en + ja localized.

Scope

  • This switches among distinct Macs (each QR pairing stores a real macDeviceID). Running multiple cmux instances on one Mac (same macDeviceID, different ports) collapses to one stored row today, so that case is a deliberate follow-up: it needs the paired-Mac schema keyed by macDeviceID + port/instance id. Noted rather than half-built.
  • Manual-host connections are intentionally not persisted by the existing code, so they don't appear in the switcher; QR-paired Macs are the switchable set.

Testing

  • iOS simulator build green.
  • Localization: en + ja added for every new string.

Issues

Merge order

Third of three stacked iOS PRs. It overlaps #5512 in WorkspaceShellView.swift, WorkspaceListView.swift, MobileShellComposite.swift, and Localizable.xcstrings (both append at the same call sites). Merge #5510 → #5512 → #5513; after #5512 lands, rebase this on main and resolve those call-site overlaps (both just append params/methods — trivial to reconcile).


Note

Medium Risk
Changes live connection switching and paired-Mac persistence; mistakes could strand sessions or leak hosts across Stack users on shared devices, though the PR adds explicit guards and tests for the latter.

Overview
Adds an iOS multi-Mac host switcher: Settings → Connection → Switch Mac opens a picker that lists paired Macs (scoped to the signed-in Stack user), marks the active one, reconnects on tap, forgets on swipe, and can Pair Another Mac via QR without removing existing pairings.

Shell / store: MobileShellComposite exposes pairedMacs, loadPairedMacs(), switchToMac, and forgetMac. Switching only persists the active row after a successful connect and can fall back to the previous Mac if the target is offline; sign-out and loads discard stale data so another user on a shared device never sees prior hosts. disconnectLiveConnection() is split out from “rescan QR” forget flow.

SQLite: setActive clears is_active only within the target Mac’s stack_user_id scope (matching upsert), fixing shared-device cases where one user’s switch wiped another user’s active Mac.

UI: New MobileHostPickerView; CmuxMobileShellUI depends on CmuxMobilePairedMac. Store is threaded through workspace shell → settings. en/ja strings added; unit test covers scoped setActive.

Reviewed by Cursor Bugbot for commit 27b7020. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features

    • iOS host picker and settings UI to switch, add/pair, and remove paired Macs; picker shows active checkmark, Done/dismiss, and loads/refreshes paired list (cleared on sign-out).
  • Bug Fixes

    • Changing the active Mac no longer clears other users’ active selections on shared devices.
  • Localization

    • Added strings for host picker, switch-Mac label, and TestFlight help/link.
  • Tests

    • Added test ensuring active-device changes are scoped per user.

Adds a Settings -> Connection -> "Switch Mac" picker that lists every Mac
paired with this device, marks the active one, switches the live connection on
tap, forgets on swipe, and pairs another Mac by scanning its QR without
dropping the others.

The on-device SQLite store already persisted N paired Macs; only the UI was
missing (loadAll was test-only). MobileShellComposite gains pairedMacs,
activeMacDeviceID, loadPairedMacs(), switchToMac(macDeviceID:) (setActive then
reconnect via the existing launch-time reconnect path), and
forgetMac(macDeviceID:). MobileHostPickerView drives them, reached from
MobileSettingsView; the store is threaded as an optional through
WorkspaceShellView -> WorkspaceListView -> MobileSettingsView so workspace rows
stay value-only.

Scope: switches among distinct Macs (each QR pairing stores a real
macDeviceID). Multiple cmux instances on one Mac share a macDeviceID and need a
schema change to distinguish, so that remains a deliberate follow-up. en+ja
localized.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 6, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 6, 2026 3:31pm
cmux-staging Building Building Preview, Comment Jun 6, 2026 3:31pm

@coderabbitai

coderabbitai Bot commented Jun 6, 2026 •

Copy link
Copy Markdown

Looking for one thing? Review this PR in Change Stack to search files, summaries, diffs, and code without losing your place.

Review Change Stack

📝 Walkthrough

Walkthrough

Adds paired Mac host switching: MobileShellComposite gains paired-Mac state and async load/switch/forget actions; an iOS MobileHostPickerView, settings integration, navigation wiring, localization updates, and a scoped paired-mac store change with tests are included.

Changes

Paired Mac Host Switching

Layer / File(s) Summary
Core API and Package Dependencies
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift, Packages/CmuxMobileShellUI/Package.swift
MobileShellComposite exposes pairedMacs and async methods loadPairedMacs(), switchToMac(macDeviceID:), and forgetMac(macDeviceID:); disconnect logic refactored. CmuxMobilePairedMac added as a package dependency.
Host Picker UI
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift
New iOS-only MobileHostPickerView lists paired Macs, supports tap-to-switch with active indicator, swipe-to-forget, a "Pair Another Mac" scanner flow, and loads paired Macs on appear.
Settings UI Integration
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift
MobileSettingsView adds optional store: CMUXMobileShellStore?, shows a "Switch Mac" button when provided, and presents MobileHostPickerView in a sheet.
Navigation and Data Flow
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift, Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift
CMUXMobileShellStore is threaded from WorkspaceShellView → WorkspaceListView → MobileSettingsView in both compact NavigationStack and NavigationSplitView sidebar layouts.
User-Facing Strings
ios/cmux/Resources/Localizable.xcstrings
Adds mobile.hostPicker.* keys, mobile.settings.switchMac, and relocates/adds mobile.testflight.help and mobile.testflight.link.
Scoped Paired-Mac Store Update & Tests
Packages/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift, Packages/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacStoreTests.swift
setActive(macDeviceID:) now clears is_active only for rows with the same stack_user_id (NULL-safe). A new test verifies scoped clearing across multiple stackUserID values.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant MobileHostPickerView
  participant MobileShellComposite
  participant MobilePairedMacStore
  participant MobilePairingScanner

  User->>MobileHostPickerView: open view (onAppear)
  MobileHostPickerView->>MobileShellComposite: loadPairedMacs()
  MobileShellComposite->>MobilePairedMacStore: query pairedMacs for current user
  MobilePairedMacStore-->>MobileShellComposite: pairedMacs list
  MobileShellComposite-->>MobileHostPickerView: pairedMacs

  User->>MobileHostPickerView: tap mac row (switch)
  MobileHostPickerView->>MobileShellComposite: switchToMac(macDeviceID)
  MobileShellComposite->>MobileShellComposite: reconnect to selected host/port route
  MobileShellComposite->>MobilePairedMacStore: setActive(macDeviceID)
  MobilePairedMacStore-->>MobileShellComposite: success
  MobileShellComposite-->>MobileHostPickerView: updated pairedMacs

  User->>MobileHostPickerView: tap "Pair Another Mac"
  MobileHostPickerView->>MobilePairingScanner: present scanner
  MobilePairingScanner-->>MobileHostPickerView: pairing URL
  MobileHostPickerView->>MobileShellComposite: connect pairing URL / loadPairedMacs()
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

A rabbit hops through Mac-filled meadows bright,
Tapping rows to switch by soft moonlight,
Paired friends remembered per signed-in name,
Forgotten ones fade without a blame,
Sheets and strings sing—hop, celebrate! 🐰✨


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (4 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Concurrency ❌ Error Fire-and-forget Task in disconnectAndForgetActiveMac() (line 717) with meaningful lifecycle not stored/cancelled/tied to caller, violating modernization rule. Make disconnectAndForgetActiveMac async or store the Task to allow cancellation.
Cmux Swift Logging ❌ Error MobileShellComposite.swift declares file-scoped logger as private let instead of nonisolated private let in @MainActor-marked class, violating swift-logging.md rule. Change private let mobileShellLog to nonisolated private let mobileShellLog in MobileShellComposite.swift to match MainActor isolation requirements.
Cmux Swiftui State Layout ❌ Error MobileHostPickerView violates rule: list row subtree (macRow) directly accesses store in closures instead of receiving action closures. Pass action closures to macRow instead of having it call store.switchToMac/forgetMac directly.
Cmux Architecture Rethink ❌ Error PR introduces split lifecycle ownership: disconnectLiveConnection() clears UI state but leaves recoveryTask alive, allowing recovery to reconnect forgotten Macs after explicit disconnect. Add to disconnectLiveConnection(): recoveryTask?.cancel(); recoveryTask = nil; recoveryInFlight = false; isRecoveringConnection = false; connectionRecoveryFailed = false to synchronize lifecycle.
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (14 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding a multi-Mac host switcher for iOS, which is the primary feature of this changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed Code properly maintains actor isolation: @MainActor MobileShellComposite, Sendable MobilePairedMac, actor MobilePairedMacStore, and @Bindable SwiftUI access patterns all comply with Swift 6 rules.
Cmux Swift Blocking Runtime ✅ Passed No blocking or timing-based synchronization primitives (Task.sleep, DispatchSemaphore, NSLock, etc.) were introduced in the production Swift code changes for paired Mac switching.
Cmux No Hacky Sleeps ✅ Passed PR contains only Swift code and localization strings. Rule scope excludes Swift and only applies to TypeScript, JavaScript, shell, and non-Swift build/runtime scripts.
Cmux Algorithmic Complexity ✅ Passed Code uses single .first(where:) lookups (not nested loops), performs database queries for storage ops, and has explicit small bounds (1-10 paired Macs per user in non-hot paths).
Cmux Swift @Concurrent ✅ Passed New async methods (loadPairedMacs, switchToMac, forgetMac) properly suspend for heavy work; no @concurrent needed as they suspend immediately, not blocking between suspension points.
Cmux Swift File And Package Boundaries ✅ Passed PR adds ~95 lines to MobileShellComposite (under 250 threshold), new UI file 111 lines with clear responsibility, logic fits coherently with connection management, no mixed responsibilities.
Cmux User-Facing Error Privacy ✅ Passed PR adds multi-Mac switcher UI and functionality. All user-facing strings are generic, localized product terms with no vendor names, credentials, raw error messages, or sensitive data exposed.
Cmux Full Internationalization ✅ Passed All new user-facing text uses L10n.string() with defaultValue and has complete translations (en, ja) in Localizable.xcstrings; no hardcoded strings found outside localization system.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR adds iOS-only SwiftUI View presented as a sheet, not a standalone window. No NSWindow, NSPanel, NSWindowController, or Window/WindowGroup constructs—complies with allowed cases.
Cmux Source Artifacts ✅ Passed All changed files are intentional source code, tests, manifests, localization catalogs, or documentation. No source control artifacts detected.
Description check ✅ Passed The pull request description is comprehensive and covers all required template sections with detailed context.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ios-multi-mac-switcher

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Jun 6, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an iOS multi-Mac host switcher: a MobileHostPickerView lists all QR-paired Macs, lets the user tap to switch, swipe to forget, and scan a new QR to pair another without dropping the existing list. MobileShellComposite gains pairedMacs, loadPairedMacs, switchToMac, and forgetMac; setActive in the SQLite store is fixed to scope its is_active clear to the target user, preventing shared-device collisions.

  • Store fix: setActive now clears is_active only within the same stack_user_id scope via a correlated subquery; a new test (setActiveScopesClearToTargetStackUser) covers the per-user isolation.
  • Shell additions: MobileShellComposite exposes the switcher API with sign-out clearing, post-await identity guards, and a fallback reconnect on failed switch.
  • UI: MobileHostPickerView is wired through WorkspaceShellView → WorkspaceListView → MobileSettingsView; en + ja localization added for all new strings.

Confidence Score: 3/5

Multiple defects in the live-connection and store-state paths need to be resolved before merging; the new QR-from-picker flow silently drops a working session on failure with no recovery path.

The switchToMac and forgetMac paths carry several confirmed defects (flagged in prior review rounds): connection torn down before the new one is established, in-flight reconnects not cancelled when forgetting an active Mac, a NULL-scoping hole in setActive that can silently corrupt the active flag, and a race between loadPairedMacs and the fire-and-forget removal in the active-Mac forget path. A new issue compounds these: in MobileHostPickerView, scanning a QR code for a new Mac calls connectPairingURL, which tears down the existing session on any failure, and then unconditionally calls dismiss() regardless of outcome — stranding the user disconnected and back at the workspace list with no recovery path. Unlike switchToMac, this flow has no fallback reconnect.

MobileShellComposite.swift (switcher logic), MobileHostPickerView.swift (QR scan dismiss path), and MobilePairedMacStore.swift (NULL-scoping in setActive)

Important Files Changed

Filename Overview
Packages/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift Adds per-user scoping to setActive via a correlated subquery; the NULL-scoping bug (subquery returning no rows fires a NULL-scoped UPDATE) remains unaddressed.
Packages/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacStoreTests.swift Adds setActiveScopesClearToTargetStackUser test covering the per-user isolation fix; good coverage of the primary happy path.
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Adds pairedMacs, loadPairedMacs, switchToMac, and forgetMac; carries multiple defects already flagged (stale list after forget, in-flight reconnect missed in forgetMac, connection dropped before new one established in switchToMac, nil currentUserID in loadPairedMacs).
Packages/CmuxMobileShellUI/Package.swift Adds CmuxMobilePairedMac dependency to CmuxMobileShellUI; straightforward and correct.
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift New view with list/checkmark/swipe-to-forget/pair-another flow; has untracked Tasks for switch and forget (flagged elsewhere), and unconditional dismiss() after connectPairingURL silently drops the existing session on QR scan failure.
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift Adds optional store param and 'Switch Mac' button gated on store != nil; nil sheet content is unreachable in practice.
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift Threads optional store down to MobileSettingsView; change is mechanical and correct.
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift Passes store: store through to WorkspaceListView; minimal, correct change.
ios/cmux/Resources/Localizable.xcstrings All new keys (mobile.hostPicker.*, mobile.settings.switchMac) have en + ja translations; mobile.common.done already existed with ja coverage.

Sequence Diagram

sequenceDiagram
    participant User
    participant HostPickerView as MobileHostPickerView
    participant Store as MobileShellComposite
    participant PairedMacStore as MobilePairedMacStore
    participant Network

    User->>HostPickerView: Opens picker (.task)
    HostPickerView->>Store: loadPairedMacs()
    Store->>PairedMacStore: loadAll(stackUserID:)
    PairedMacStore-->>Store: [MobilePairedMac]
    Store-->>HostPickerView: pairedMacs updated

    User->>HostPickerView: Tap row → switchToMac
    HostPickerView->>Store: "Task { switchToMac(macDeviceID:) }"
    Store->>Network: connectManualHost (destroys existing session)
    alt connect succeeds
        Network-->>Store: connected
        Store->>PairedMacStore: setActive(macDeviceID:)
        Store->>Store: loadPairedMacs()
    else connect fails
        Network-->>Store: error / clearRemoteConnectionContext
        Store->>Store: reconnectActiveMacIfAvailable (fallback)
        Store->>Store: loadPairedMacs()
    end

    User->>HostPickerView: Swipe → forgetMac
    HostPickerView->>Store: "Task { forgetMac(macDeviceID:) }"
    alt "isActive && .connected"
        Store->>Store: disconnectLiveConnection()
    end
    Store->>PairedMacStore: remove(macDeviceID:)
    Store->>Store: loadPairedMacs()

    User->>HostPickerView: Pair Another Mac → QR scan
    HostPickerView->>Store: connectPairingURL(code)
    Store->>Network: beginPairingAttempt + connect
    alt pairing succeeds
        Network-->>Store: connected
        Store-->>HostPickerView: return true → dismiss()
    else pairing fails
        Network-->>Store: error → clearRemoteConnectionContext (drops Mac A)
        Store-->>HostPickerView: return false → dismiss() anyway (user stranded)
    end
Loading

Reviews (6): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment on lines +550 to +561
public func forgetMac(macDeviceID: String) async {
if macDeviceID == activeMacDeviceID {
disconnectAndForgetActiveMac()
} else {
do {
try await pairedMacStore?.remove(macDeviceID: macDeviceID)
} catch {
mobileShellLog.error("paired mac store remove failed mac=\(macDeviceID, privacy: .public) error=\(String(describing: error), privacy: .public)")
}
}
await loadPairedMacs()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale list after forgetting the active Mac

When macDeviceID == activeMacDeviceID, disconnectAndForgetActiveMac() is synchronous and fires an unstructured Task internally to call pairedMacStore.remove(...). Control returns to forgetMac immediately, which then awaits loadPairedMacs(). Because the removal and the reload are now two independent tasks on the MainActor executor, loadPairedMacs() can read the store before the remove task has written its deletion — the forgotten Mac stays in pairedMacs with no subsequent refresh scheduled. The non-active path correctly awaits the removal before reloading, so that branch is clean.

The fix is to await the removal explicitly in the active-Mac path rather than relying on disconnectAndForgetActiveMac's internal fire-and-forget Task, or to call loadPairedMacs() from inside disconnectAndForgetActiveMac's remove-completion handler.

Comment on lines +77 to +108
Button {
Task { await store.switchToMac(macDeviceID: mac.macDeviceID) }
} label: {
HStack(spacing: 12) {
Image(systemName: "desktopcomputer")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(mac.displayName ?? mac.macDeviceID)
.foregroundStyle(.primary)
Text(mac.lastSeenAt, format: .relative(presentation: .named))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
if isActive {
Image(systemName: "checkmark")
.foregroundStyle(Color.accentColor)
.accessibilityLabel(L10n.string("mobile.hostPicker.active", defaultValue: "Active"))
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("MobileHostPickerRow-\(mac.macDeviceID)")
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
Task { await store.forgetMac(macDeviceID: mac.macDeviceID) }
} label: {
Label(L10n.string("mobile.hostPicker.forget", defaultValue: "Forget"), systemImage: "trash")
}
.accessibilityIdentifier("MobileHostPickerForget-\(mac.macDeviceID)")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unlifecycled fire-and-forget Tasks for switch and forget actions

Both Task { await store.switchToMac(...) } (tap) and Task { await store.forgetMac(...) } (swipe) are unstructured and untracked. A rapid double-tap or swipe queues a second operation before the first completes: switchToMac's guard (macDeviceID != activeMacDeviceID) won't catch the duplicate because activeMacDeviceID still reflects the old value during the first task's await. forgetMac has no guard at all. For actions that trigger reconnection or deletion side-effects, these should be tracked tasks (stored on the store or on a @State binding) so that in-flight operations can be cancelled or gated before a second one starts.

Rule Used: Flag new legacy async patterns in cmux-owned Swift... (source)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 525-529: When pairedMacStore.loadAll throws in
MobileShellComposite.swift, the old in-memory pairedMacs remains and can show
stale/cross-account data; in the catch block for the try await
pairedMacStore.loadAll(stackUserID: identityProvider?.currentUserID) set
pairedMacs to an empty collection (or nil if pairedMacs is optional) before
calling mobileShellLog.error to ensure the UI no longer shows previous pairings
after a failed reload.
- Around line 550-561: forgetMac races with the detached removal inside
disconnectAndForgetActiveMac causing loadPairedMac to run before deletion
completes; make the removal synchronous-from-caller by changing
disconnectAndForgetActiveMac to an async function (or provide an async wrapper)
that performs/awaits the pairedMacStore?.remove call (or awaits the internal
Task) and only return when the removal is finished, then call await
disconnectAndForgetActiveMac() from forgetMac before calling await
loadPairedMac(); alternatively, have disconnectAndForgetActiveMac return the
Task/continuation and await that in forgetMac so loadPairedMac runs after the
removal completes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3af4e98b-a6f9-45e5-8b12-0e0dbaf3469d

📥 Commits

Reviewing files that changed from the base of the PR and between b2f0ce0 and 0ad3b78.

📒 Files selected for processing (7)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShellUI/Package.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift
  • ios/cmux/Resources/Localizable.xcstrings

Comment on lines +525 to +529
do {
pairedMacs = try await pairedMacStore.loadAll(stackUserID: identityProvider?.currentUserID)
} catch {
mobileShellLog.error("paired mac store loadAll failed: \(String(describing: error), privacy: .public)")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear pairedMacs when reload fails to avoid stale/cross-account host exposure.

If loadAll throws, the previous in-memory list is kept. After account/scope changes, the UI can continue showing old pairings until a later successful reload.

Suggested fix
public func loadPairedMacs() async {
    guard let pairedMacStore else {
        pairedMacs = []
        return
    }
    do {
        pairedMacs = try await pairedMacStore.loadAll(stackUserID: identityProvider?.currentUserID)
    } catch {
+       pairedMacs = []
        mobileShellLog.error("paired mac store loadAll failed: \(String(describing: error), privacy: .public)")
    }
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
do {
pairedMacs = try await pairedMacStore.loadAll(stackUserID: identityProvider?.currentUserID)
} catch {
mobileShellLog.error("paired mac store loadAll failed: \(String(describing: error), privacy: .public)")
}
do {
pairedMacs = try await pairedMacStore.loadAll(stackUserID: identityProvider?.currentUserID)
} catch {
pairedMacs = []
mobileShellLog.error("paired mac store loadAll failed: \(String(describing: error), privacy: .public)")
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 525 - 529, When pairedMacStore.loadAll throws in
MobileShellComposite.swift, the old in-memory pairedMacs remains and can show
stale/cross-account data; in the catch block for the try await
pairedMacStore.loadAll(stackUserID: identityProvider?.currentUserID) set
pairedMacs to an empty collection (or nil if pairedMacs is optional) before
calling mobileShellLog.error to ensure the UI no longer shows previous pairings
after a failed reload.

Autoreview P1: switchToMac called the unscoped setActive, which cleared
is_active across every row. On a shared device, switching hosts for one signed-in
user wiped another user's active pairing, so they failed to auto-reconnect after
signing back in. Scope the clear to the target Mac's own stack_user_id via a
null-safe subquery (mirroring upsert's scoped clear). Add a store regression
test proving a second Stack user's active pairing survives the switch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift (1)

196-216: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Non-existent macDeviceID clears all NULL-scoped active pairings.

When the target Mac doesn't exist, the subquery returns an empty result set (scalar NULL). stack_user_id IS NULL then matches all NULL-scoped rows, incorrectly clearing their is_active flags while setting nothing active.

Guard with EXISTS or verify the Mac exists before clearing:

Proposed fix
     public func setActive(macDeviceID: String) throws {
         try ensureReady()
         try transaction {
+            // Guard: no-op if the target Mac doesn't exist.
+            let existing = try fetchMacRow(macDeviceID: macDeviceID)
+            guard existing != nil else { return }
             // Clear the active flag only within the target Mac's own Stack-user
             // scope ...
             try exec("""
                 UPDATE paired_macs SET is_active = 0
                 WHERE stack_user_id IS (
                     SELECT stack_user_id FROM paired_macs WHERE mac_device_id = ?
                 );
                 """,
                 binding: [.text(macDeviceID)])
             try exec("UPDATE paired_macs SET is_active = 1 WHERE mac_device_id = ?;",
                      binding: [.text(macDeviceID)])
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift`
around lines 196 - 216, The UPDATE in setActive currently uses a scalar subquery
that yields NULL when mac_device_id doesn't exist, causing "stack_user_id IS
NULL" to clear all NULL-scoped active rows; fix by first verifying the target
row exists (e.g., SELECT 1 FROM paired_macs WHERE mac_device_id = ? and
throw/return if not found) or change the clearing UPDATE to use an EXISTS-based
predicate (UPDATE paired_macs SET is_active = 0 WHERE EXISTS (SELECT 1 FROM
paired_macs p2 WHERE p2.mac_device_id = ? AND paired_macs.stack_user_id IS
p2.stack_user_id)), and apply these changes inside the existing transaction in
setActive so the subsequent UPDATE paired_macs SET is_active = 1 WHERE
mac_device_id = ? only runs when the target exists; reference
functions/identifiers: setActive, transaction, exec, paired_macs, mac_device_id,
stack_user_id.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@Packages/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift`:
- Around line 196-216: The UPDATE in setActive currently uses a scalar subquery
that yields NULL when mac_device_id doesn't exist, causing "stack_user_id IS
NULL" to clear all NULL-scoped active rows; fix by first verifying the target
row exists (e.g., SELECT 1 FROM paired_macs WHERE mac_device_id = ? and
throw/return if not found) or change the clearing UPDATE to use an EXISTS-based
predicate (UPDATE paired_macs SET is_active = 0 WHERE EXISTS (SELECT 1 FROM
paired_macs p2 WHERE p2.mac_device_id = ? AND paired_macs.stack_user_id IS
p2.stack_user_id)), and apply these changes inside the existing transaction in
setActive so the subsequent UPDATE paired_macs SET is_active = 1 WHERE
mac_device_id = ? only runs when the target exists; reference
functions/identifiers: setActive, transaction, exec, paired_macs, mac_device_id,
stack_user_id.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 11789a56-ba20-4e26-a7bf-e147a8630414

📥 Commits

Reviewing files that changed from the base of the PR and between 0ad3b78 and 853b512.

📒 Files selected for processing (2)
  • Packages/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift
  • Packages/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacStoreTests.swift

Comment on lines 198 to 215
try transaction {
try exec("UPDATE paired_macs SET is_active = 0;")
// Clear the active flag only within the target Mac's own Stack-user
// scope, mirroring the scoped clear in `upsert`. On a shared device
// (more than one Stack user has pairings), switching hosts for one
// signed-in user must not wipe another user's active Mac, or that
// user fails to auto-reconnect after signing back in. `IS` is
// SQLite's null-safe equality, so a NULL-scoped target clears only
// other NULL-scoped rows.
try exec("""
UPDATE paired_macs SET is_active = 0
WHERE stack_user_id IS (
SELECT stack_user_id FROM paired_macs WHERE mac_device_id = ?
);
""",
binding: [.text(macDeviceID)])
try exec("UPDATE paired_macs SET is_active = 1 WHERE mac_device_id = ?;",
binding: [.text(macDeviceID)])
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Silent active-flag corruption when macDeviceID is not in the table. The inner SELECT is a scalar subquery; when no row matches, SQLite produces NULL, so WHERE stack_user_id IS NULL fires and clears every null-scoped row's active flag — then the second UPDATE matches nothing, leaving no Mac active for that scope. The untracked Tasks already flagged on MobileHostPickerView make this reachable via a race between a swipe-to-forget and a tap-to-switch on the same non-active row. Adding a WHERE EXISTS guard or checking the row count before committing prevents silent corruption.

Suggested change
try transaction {
try exec("UPDATE paired_macs SET is_active = 0;")
// Clear the active flag only within the target Mac's own Stack-user
// scope, mirroring the scoped clear in `upsert`. On a shared device
// (more than one Stack user has pairings), switching hosts for one
// signed-in user must not wipe another user's active Mac, or that
// user fails to auto-reconnect after signing back in. `IS` is
// SQLite's null-safe equality, so a NULL-scoped target clears only
// other NULL-scoped rows.
try exec("""
UPDATE paired_macs SET is_active = 0
WHERE stack_user_id IS (
SELECT stack_user_id FROM paired_macs WHERE mac_device_id = ?
);
""",
binding: [.text(macDeviceID)])
try exec("UPDATE paired_macs SET is_active = 1 WHERE mac_device_id = ?;",
binding: [.text(macDeviceID)])
}
try transaction {
// Verify the target mac exists before touching any active flags.
// If the subquery in the scoped-clear returns no rows (mac deleted
// in a concurrent operation) it becomes NULL, which would silently
// clear every null-scoped row's active flag without setting any row
// active. Failing early keeps the store in a consistent state.
let exists = try fetchMacRow(macDeviceID: macDeviceID) != nil
guard exists else {
throw MobilePairedMacStoreError.stepFailed(SQLITE_NOTFOUND, "mac_device_id not found: \(macDeviceID)")
}
// Clear the active flag only within the target Mac's own Stack-user
// scope, mirroring the scoped clear in `upsert`. On a shared device
// (more than one Stack user has pairings), switching hosts for one
// signed-in user must not wipe another user's active Mac, or that
// user fails to auto-reconnect after signing back in. `IS` is
// SQLite's null-safe equality, so a NULL-scoped target clears only
// other NULL-scoped rows.
try exec("""
UPDATE paired_macs SET is_active = 0
WHERE stack_user_id IS (
SELECT stack_user_id FROM paired_macs WHERE mac_device_id = ?
);
""",
binding: [.text(macDeviceID)])
try exec("UPDATE paired_macs SET is_active = 1 WHERE mac_device_id = ?;",
binding: [.text(macDeviceID)])
}

Comment on lines +519 to +530
/// Reload ``pairedMacs`` from the store, scoped to the signed-in user.
public func loadPairedMacs() async {
guard let pairedMacStore else {
pairedMacs = []
return
}
do {
pairedMacs = try await pairedMacStore.loadAll(stackUserID: identityProvider?.currentUserID)
} catch {
mobileShellLog.error("paired mac store loadAll failed: \(String(describing: error), privacy: .public)")
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 loadPairedMacs() passes identityProvider?.currentUserID directly to loadAll. When currentUserID is nil — for example right after sign-out while the picker sheet is still on screen — loadAll(stackUserID: nil) omits the WHERE stack_user_id IS ? clause and returns every row for every user stored on the device. On a shared device this surfaces another user's paired Macs in the switcher. A defensive guard that short-circuits with an empty list when there is no known user keeps the picker's scope correct in all lifecycle states.

Suggested change
/// Reload ``pairedMacs`` from the store, scoped to the signed-in user.
public func loadPairedMacs() async {
guard let pairedMacStore else {
pairedMacs = []
return
}
do {
pairedMacs = try await pairedMacStore.loadAll(stackUserID: identityProvider?.currentUserID)
} catch {
mobileShellLog.error("paired mac store loadAll failed: \(String(describing: error), privacy: .public)")
}
}
/// Reload ``pairedMacs`` from the store, scoped to the signed-in user.
public func loadPairedMacs() async {
guard let pairedMacStore else {
pairedMacs = []
return
}
guard let userID = identityProvider?.currentUserID else {
// No signed-in user — show nothing rather than returning every
// user's rows from a shared-device store.
pairedMacs = []
return
}
do {
pairedMacs = try await pairedMacStore.loadAll(stackUserID: userID)
} catch {
mobileShellLog.error("paired mac store loadAll failed: \(String(describing: error), privacy: .public)")
}
}

…-out

Two review findings:
- P1: switchToMac persisted the new active row before the reconnect, so
  switching to an offline/stale-route Mac stranded the user on an unreachable
  host that recovery kept retrying, with no way back to the switcher. Now it
  connects to the target's route first and persists setActive only on a
  successful connect, so a failed switch leaves the previously-working Mac
  active and reachable.
- P2: the cached pairedMacs list could leak across signed-in users on a shared
  device. Clear it on sign-out and gate loadPairedMacs on isSignedIn.

Also drop the unreliable activeMacDeviceID (the live attach ticket carries a
transient manual id after a reconnect, not the stored Mac's real id); the
switcher now marks the active row by the store's isActive flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines +543 to +563
public func switchToMac(macDeviceID: String) async {
guard let pairedMacStore,
let target = pairedMacs.first(where: { $0.macDeviceID == macDeviceID }) else { return }
if target.isActive, connectionState == .connected { return }
let supportedKinds = runtime?.supportedRouteKinds ?? []
guard let (host, port) = Self.firstReconnectHostPortRoute(
target.routes,
supportedKinds: supportedKinds
) else {
mobileShellLog.error("switchToMac: no reconnectable route mac=\(macDeviceID, privacy: .public)")
return
}
await connectManualHost(name: target.displayName ?? host, host: host, port: port)
if connectionState == .connected {
do {
try await pairedMacStore.setActive(macDeviceID: macDeviceID)
} catch {
mobileShellLog.error("paired mac store setActive failed mac=\(macDeviceID, privacy: .public) error=\(String(describing: error), privacy: .public)")
}
}
await loadPairedMacs()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 switchToMac drops the live connection before the new one is established

connectManualHost calls beginPairingAttempt() (which cancels pending remote operation tasks) then awaits manualHostTicket(...) over the network. If that server round-trip fails — Mac B offline, auth-server hiccup, or a timeout — the catch block calls clearRemoteConnectionContext() → replaceRemoteClient(with: nil), which disconnects the current Mac A RPC connection. The user ends up disconnected from both Macs.

The doc comment directly above promises the opposite: "a failed switch…leaves the previously-working Mac active and reachable." That is true of the SQLite record (is_active is not updated on failure), but false of the live socket connection, which is severed during the failed attempt. Users switching to an offline Mac lose their working session unexpectedly. The fix would be to either snapshot and restore the existing remoteClient on failure, or at minimum correct the doc comment to match actual behavior.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (1)

523-533: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard pairedMacs against stale post-await publication.

loadPairedMacs() only checks isSignedIn before the await. If the user signs out or switches Stack accounts while loadAll(...) is in flight, Line 529 writes the old result back into pairedMacs, undoing the sign-out clear on Lines 243-245 and leaking the previous user's Macs back into the picker. Capture the requested stackUserID and re-check isSignedIn/currentUserID before assigning the loaded rows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 523 - 533, loadPairedMacs currently checks isSignedIn before
awaiting pairedMacStore.loadAll, so a sign-out or account switch in-flight can
cause stale data to be written back into pairedMacs; fix by capturing the
requested stackUserID (e.g. let requestedID = identityProvider?.currentUserID)
before calling pairedMacStore.loadAll, then after the await re-check that
isSignedIn is still true and identityProvider?.currentUserID == requestedID
before assigning pairedMacs; if the check fails, discard the loaded result (and
still handle errors via mobileShellLog.error) to avoid leaking the previous
user’s Macs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 556-560: The current block calls
pairedMacStore.setActive(macDeviceID: macDeviceID) after a successful
connectManualHost but may set the wrong row; instead persist the Mac that
actually answered by using activeTicket?.macDeviceID (or verify it matches
macDeviceID) before calling setActive. Update the logic in the section that
checks connectionState == .connected (around the connectManualHost /
persistPairedMacFromTicket flow) to prefer activeTicket?.macDeviceID when
present, or guard that activeTicket?.macDeviceID == macDeviceID before calling
pairedMacStore.setActive(macDeviceID:), so the store is updated to the
real-attached device rather than the originally requested ID.
- Around line 543-563: The switchToMac flow currently replaces the active
session too early; change it to stage the new connection before swapping out the
old remoteClient by having connectManualHost/connect(ticket:) return or expose a
provisional client/connection result (or perform the workspace.list handshake)
without mutating shared state, validate the new connection (e.g., successful
workspace.list/handshake and connectionState == .connected), then inside
switchToMac atomically replace remoteClient and related context and call
pairedMacStore.setActive; on failure keep the existing remoteClient unchanged
and log the error—update functions involved (switchToMac, connectManualHost,
connect(ticket:), and any place that clears remoteClient) to support this
swap-after-success semantics.

---

Outside diff comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 523-533: loadPairedMacs currently checks isSignedIn before
awaiting pairedMacStore.loadAll, so a sign-out or account switch in-flight can
cause stale data to be written back into pairedMacs; fix by capturing the
requested stackUserID (e.g. let requestedID = identityProvider?.currentUserID)
before calling pairedMacStore.loadAll, then after the await re-check that
isSignedIn is still true and identityProvider?.currentUserID == requestedID
before assigning pairedMacs; if the check fails, discard the loaded result (and
still handle errors via mobileShellLog.error) to avoid leaking the previous
user’s Macs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2ed894a8-bdad-4f16-923e-a7a97edb1fb4

📥 Commits

Reviewing files that changed from the base of the PR and between 853b512 and 4a3441d.

📒 Files selected for processing (2)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift

Comment thread Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Outdated
Third review pass, three findings:
- P1: a switch superseded mid-connect could persist the wrong active Mac
  (post-hoc connectionState check wasn't tied to this connect). Persist setActive
  only when the live route matches this Mac's normalized host:port.
- P1: loadPairedMacs passed a nil Stack user id straight to loadAll, which
  returns every user's pairings. Treat a missing current user as no pairings.
- P2: forgetting the active row deleted by the live ticket's transient manual id,
  which may not be the stored row, leaving it behind. Always remove the selected
  real id, and tear down the live connection via the new disconnectLiveConnection
  helper when that row is active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
coderabbitai[bot]
coderabbitai Bot previously requested changes Jun 6, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (2)

711-719: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't remove by activeTicket here.

activeTicket?.macDeviceID can be a synthetic manual-* id (Lines 753-763), and persistPairedMacFromTicket(_:) explicitly does not store those ids at Lines 617-618. In that state remove(macDeviceID:) deletes nothing, so disconnectAndForgetActiveMac() leaves the persisted paired row and active flag behind.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 711 - 719, disconnectAndForgetActiveMac() currently reads the mac
ID from activeTicket (activeTicket?.macDeviceID) which can be a synthetic
"manual-*" id that was never persisted (see persistPairedMacFromTicket(_:)), so
remove(macDeviceID:) is a no-op and leaves the persisted paired row and active
flag behind; change the logic to retrieve the actual persisted paired mac ID
from pairedMacStore (e.g. query the stored/active persisted pair record or a
pairedMacStore.pairedMacID / pairedMacStore.currentPairedMac method) and only
call pairedMacStore.remove(macDeviceID:) with that persisted ID (guarding if
nil) instead of using activeTicket?.macDeviceID, leaving the
disconnectLiveConnection() flow unchanged.

522-724: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Extract paired-Mac orchestration out of MobileShellComposite.

This file is already far past the repo's 800-line ceiling for production Swift files, and the new load/switch/forget workflow adds another responsibility to a type that already owns pairing, recovery, workspaces, terminal transport, and UI-facing state. Pulling paired-Mac state/actions behind a small coordinator or service would keep this facade moving toward the decomposition described in its own type docs.

As per coding guidelines, {Sources,CLI,Packages,cmuxTests,cmuxUITests}/**/*.swift: "Flag Swift production files that exceed 400 lines without a clear single responsibility, or exceed 800 lines even with mostly coherent responsibility."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 522 - 724, MobileShellComposite is too large and its paired-Mac
responsibilities should be moved to a dedicated coordinator/service: extract
paired-Mac state and methods (pairedMacs, pairedMacStore usage, loadPairedMacs,
switchToMac, forgetMac, persistPairedMacFromTicket, firstReconnectHostPortRoute,
manualHostRoute and any helpers) into a new PairedMacsCoordinator (or service)
that owns persistence and orchestration; have MobileShellComposite keep only
UI/connection state and delegate calls (e.g., call coordinator.loadPairedMacs(),
coordinator.switchToMac(macDeviceID:), coordinator.forgetMac(macDeviceID:), and
persistPairedMacFromTicket(ticket) from connect flows). Ensure the coordinator
is initialized with required collaborators (identityProvider, runtime, a
reference to
connectManualHost/beginPairingAttempt/connectionState/activeRoute/activeTicket/connectionError/macConnectionStatus/clearRemoteConnectionContext/disconnectLiveConnection
or expose callbacks) so all former internal logic (including route selection via
firstReconnectHostPortRoute and manualHostRoute) moves without changing
behavior, and update MobileShellComposite to delegate and remove the moved
methods and state.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 702-709: disconnectLiveConnection() currently resets UI state but
doesn't stop any ongoing reconnection work; cancel any in-progress recovery when
the user explicitly disconnects by checking and cancelling recoveryTask (and
setting it to nil) and clearing recoveryInFlight (set to false) inside
disconnectLiveConnection(), ensuring any async Task is cancelled/awaited as
appropriate so a retry or network-triggered reconnect cannot revive the
connection after explicit disconnect.

---

Outside diff comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 711-719: disconnectAndForgetActiveMac() currently reads the mac ID
from activeTicket (activeTicket?.macDeviceID) which can be a synthetic
"manual-*" id that was never persisted (see persistPairedMacFromTicket(_:)), so
remove(macDeviceID:) is a no-op and leaves the persisted paired row and active
flag behind; change the logic to retrieve the actual persisted paired mac ID
from pairedMacStore (e.g. query the stored/active persisted pair record or a
pairedMacStore.pairedMacID / pairedMacStore.currentPairedMac method) and only
call pairedMacStore.remove(macDeviceID:) with that persisted ID (guarding if
nil) instead of using activeTicket?.macDeviceID, leaving the
disconnectLiveConnection() flow unchanged.
- Around line 522-724: MobileShellComposite is too large and its paired-Mac
responsibilities should be moved to a dedicated coordinator/service: extract
paired-Mac state and methods (pairedMacs, pairedMacStore usage, loadPairedMacs,
switchToMac, forgetMac, persistPairedMacFromTicket, firstReconnectHostPortRoute,
manualHostRoute and any helpers) into a new PairedMacsCoordinator (or service)
that owns persistence and orchestration; have MobileShellComposite keep only
UI/connection state and delegate calls (e.g., call coordinator.loadPairedMacs(),
coordinator.switchToMac(macDeviceID:), coordinator.forgetMac(macDeviceID:), and
persistPairedMacFromTicket(ticket) from connect flows). Ensure the coordinator
is initialized with required collaborators (identityProvider, runtime, a
reference to
connectManualHost/beginPairingAttempt/connectionState/activeRoute/activeTicket/connectionError/macConnectionStatus/clearRemoteConnectionContext/disconnectLiveConnection
or expose callbacks) so all former internal logic (including route selection via
firstReconnectHostPortRoute and manualHostRoute) moves without changing
behavior, and update MobileShellComposite to delegate and remove the moved
methods and state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 643f337c-25c9-453c-a998-a4c37373706a

📥 Commits

Reviewing files that changed from the base of the PR and between 4a3441d and 662ec39.

📒 Files selected for processing (1)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift

Comment on lines +702 to +709
private func disconnectLiveConnection() {
pairingAttemptID = UUID()
connectionError = nil
connectionRequiresReauth = false
connectionState = .disconnected
macConnectionStatus = .unavailable
clearRemoteConnectionContext()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Cancel recovery when the user explicitly disconnects.

disconnectLiveConnection() clears the UI state, but it leaves recoveryTask / recoveryInFlight alive. If a retry or network-triggered reconnect is already running, it can reconnect the just-forgotten Mac after the user chose to disconnect.

Suggested fix
 private func disconnectLiveConnection() {
+    recoveryTask?.cancel()
+    recoveryTask = nil
+    recoveryInFlight = false
+    isRecoveringConnection = false
+    connectionRecoveryFailed = false
     pairingAttemptID = UUID()
     connectionError = nil
     connectionRequiresReauth = false
     connectionState = .disconnected
     macConnectionStatus = .unavailable
     clearRemoteConnectionContext()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 702 - 709, disconnectLiveConnection() currently resets UI state but
doesn't stop any ongoing reconnection work; cancel any in-progress recovery when
the user explicitly disconnects by checking and cancelling recoveryTask (and
setting it to nil) and clearing recoveryInFlight (set to false) inside
disconnectLiveConnection(), ensuring any async Task is cancelled/awaited as
appropriate so a retry or network-triggered reconnect cannot revive the
connection after explicit disconnect.

… load

Fourth review pass:
- P1: connectManualHost is destructive (replaces the live client before the new
  route proves usable), so a failed switch to an offline/stale Mac dropped the
  working session. Capture the previously-active Mac and, when the switch does
  not connect, reconnect to it (it remains the store's active row since setActive
  only runs on success), so a failed switch self-restores instead of stranding.
- P2: loadPairedMacs assigned results after an await without rechecking the user;
  a slow load could repopulate another user's hosts after sign-out. Re-check
  isSignedIn and the current Stack user after the await and discard on mismatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
loaded = try await pairedMacStore.loadAll(stackUserID: stackUserID)
} catch {
mobileShellLog.error("paired mac store loadAll failed: \(String(describing: error), privacy: .public)")
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed reload keeps stale Mac list

Low Severity

When pairedMacStore.loadAll throws, loadPairedMacs logs the error and returns without updating pairedMacs. The host picker can keep showing an outdated list (including Macs already forgotten elsewhere) until another successful reload runs.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 861ac47. Configure here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines +603 to +605
if isActiveMac, connectionState == .connected {
disconnectLiveConnection()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 forgetMac only calls disconnectLiveConnection() when connectionState == .connected, but this misses the case where a reconnection attempt is already in-flight (started by reconnectActiveMacIfAvailable on network change). In that state, connectionState is still .disconnected (no .connecting intermediate), so the guard is false, the live-connection teardown is skipped, and beginPairingAttempt's attempt ID is never invalidated. The await pairedMacStore?.remove(...) suspends the MainActor, the in-flight connectManualHost resumes past its next guard isCurrentPairingAttempt checkpoint, succeeds, and sets connectionState = .connected — leaving the device live-connected to a Mac that was just deleted from the store. Calling disconnectLiveConnection() unconditionally for the active Mac (when not connected it is a no-op for state, but it does rotate pairingAttemptID) aborts the pending attempt.

Suggested change
if isActiveMac, connectionState == .connected {
disconnectLiveConnection()
}
if isActiveMac {
disconnectLiveConnection()
}

@lawrencecchen
lawrencecchen dismissed coderabbitai[bot]’s stale review June 6, 2026 11:31

Dismissed: CodeRabbit now posts non-blocking comment reviews (request_changes_workflow=false, #5538).

…itcher

# Conflicts:
#	Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift
#	Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift
@lawrencecchen
lawrencecchen merged commit db6f798 into main Jun 6, 2026
20 of 25 checks passed

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 27b7020. Configure here.

let isActiveMac = pairedMacs.first(where: { $0.macDeviceID == macDeviceID })?.isActive ?? false
if isActiveMac, connectionState == .connected {
disconnectLiveConnection()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forget skips live host disconnect

Medium Severity

forgetMac only calls disconnectLiveConnection when the removed row has isActive in the store. A live session can target a paired Mac whose isActive flag is stale, so forgetting that Mac deletes it from SQLite while the RPC client keeps running.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 27b7020. Configure here.

Comment on lines +61 to +68
MobilePairingScannerSheet { code in
showingScanner = false
Task {
_ = await store.connectPairingURL(code)
await store.loadPairedMacs()
dismiss()
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 QR scan severs existing session on failure, then unconditionally dismisses the picker

connectPairingURL calls beginPairingAttempt() (which rotates pairingAttemptID) and then on any failure — bad QR, decode error, network timeout — calls clearRemoteConnectionContext() before returning .failed. That tears down the live Mac A connection. After that, dismiss() fires regardless of the return value, so the user is ejected from the picker and returned to the workspace list with no active connection.

switchToMac's corresponding failure path at least attempts a fallback via reconnectActiveMacIfAvailable; this path has no fallback and no guard. The user opens "Pair Another Mac", scans an expired or wrong QR, and silently loses their working session.

The fix is to check the return value of connectPairingURL and only call dismiss() on success, and/or use store.reconnectActiveMacIfAvailable as a fallback when the pairing fails.

mattpetters added a commit to mattpetters/cmux that referenced this pull request Jun 12, 2026
…low-ai#5851) (#7)

* ci: publish iOS TestFlight (beta) on iOS-affecting merges to main (#5453)

Add a push trigger so every merge to main that changes the iOS app publishes
to the beta lane (dev.cmux.app.beta) immediately, instead of waiting up to a
day for the nightly. Path-filtered to inputs that actually rebuild the iOS app
(ios/, the linked Swift packages, GhosttyKit + its fetch scripts, and this
workflow); macOS-only Sources/ changes don't change the iOS app so they don't
trigger an upload. The nightly + manual dispatch stay as-is. Push runs always
build (the dedup SHA gate is schedule-only); each merge is a distinct commit,
so no duplicate uploads.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Restore menu bar icon dropdown menu on click (#5451)

PR #3908 changed the cmux menu bar status item so a left-click opened the
720x460 global search popover, and only a right/control-click showed the
dropdown menu. This reverts the status item to its standard behavior: any
click shows the dropdown menu again.

Global search stays reachable via the "Search All Windows…" menu item and
its keyboard shortcut, so nothing is lost. The change just restores
statusItem.menu = menu and removes the custom button click routing added
by #3908.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Mobile workspace list: propagate renames + match bonsplit terminal order (#5446)

* test: add failing mobile workspace-list fidelity tests

Adds MobileWorkspaceListFidelityTests (behavioral) covering the two bugs:
a pure terminal reorder must wake the observer and change the mobile
summary hash, and a terminal rename (which writes panelCustomTitles) must
change the hash. These fail against current behavior:

- mobileTerminalPanels orders focused-first/UUID, not spatial order
- the observer never subscribes to reorder/custom-title changes and hashes
  the sorted panel-id set + raw panelTitles, so reorders and custom renames
  don't re-emit to the phone

Only the structural seam the tests need to compile is added here:
Workspace.orderedPanelIds (spatial order from bonsplit) and the
Workspace.paneLayoutVersion counter. The behavioral wiring lands in the
next commit, so CI goes red here and green there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: mobile workspace list reflects renames + spatial terminal order

Serialize the phone's terminal list in the on-screen bonsplit spatial
order (left-to-right, top-to-bottom) and re-emit workspace.updated on
terminal renames and pure drag-reorders, fixing the two regressions the
prior commit's tests exercise.

- mobileTerminalPanels(in:) now routes through orderedPanels(in:), which
  delegates to Workspace.orderedPanelIds (bonsplit allTabIds order), instead
  of focused-first/UUID sort. The payload still carries is_focused.
- MobileWorkspaceListObserver subscribes to $panelCustomTitles (terminal
  rename writes panelCustomTitles, not panelTitles) and $paneLayoutVersion
  (reorder wakeup), and hashes the ordered panel-id sequence + custom-aware
  panelTitle() instead of the sorted id set + raw panelTitles.
- Workspace.didChangeGeometry bumps paneLayoutVersion only when orderedPanelIds
  actually changed, so divider drags and selection-only events stay quiet.

Workspace rename already propagated (setCustomTitle sets title; observer
watches $title; response sends workspace.title) and needs no change.
Host-only serialization change; no iOS app rebuild required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: move fidelity tests to a dedicated Swift Testing file (cmux policy)

cmux test-framework policy: new non-UI tests use Swift Testing, not XCTest.
The fidelity tests were appended to the XCTest file WorkspaceUnitTests.swift;
move them to a dedicated cmuxTests/MobileWorkspaceListFidelityTests.swift
written in Swift Testing (#expect/#require/@Test/@Suite(.serialized)), wired
into the cmux-unit target via project.pbxproj. WorkspaceUnitTests.swift is
restored to base (its XCTest import stays only for its pre-existing suites,
which we must not bulk-rewrite). Same behavioral assertions; the prior two
commits keep the XCTest red/green proof in history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: keep orphan panelDirectories in mobile summary hash

The prior hash change dropped the loop that hashes every panelDirectories
entry (including ids not yet in `panels`), which broke the pre-existing
testMobileWorkspaceListHashIncludesDisplayedDirectories (it sets a directory
for an orphan UUID and expects the hash to change). Restore that loop; the
bug-fix changes (ordered panel ids + custom-aware panelTitle) stay. Keeps the
existing behavior where a directory update is detected before its panel
registers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Add VS Code-style context keys and comparison operators to shortcut `when` clauses

Generalize the keyboard-shortcut `when` engine (#5189) from four boolean focus
atoms to a typed, extensible context system, closing most of the gap with VS
Code's `when`-clause vocabulary while staying fully backwards compatible.

- Operators: add ==, !=, =~ (regex), <, <=, >, >=, and `in [a, b]`, layered into
  the grammar with VS Code precedence. Existing boolean clauses parse identically.
- Context keys (typed registry ShortcutContextKnownKey): commandPaletteVisible,
  terminalFindVisible (bool), sidebarMode (string), paneCount, workspaceCount (int),
  wired from synchronous window state in KeyboardShortcutContext.
- Typed model: ShortcutContext / ShortcutContextValue / ShortcutContextOperand /
  ShortcutRegex value types; the app populates a Sendable snapshot so the package
  never imports app types.
- canCoexist (conflict detection) generalized to a sound free-variable enumeration:
  byte-identical for focus-only clauses, conservative for typed comparisons.
- ShortcutWhenClause stays additive (.key/.compare added; .atom and the
  evaluate(ShortcutFocusState) overload unchanged). One intentional change: an
  unknown bare key now parses to .key (always-false), matching VS Code.
- Treat an empty/whitespace `shortcuts.when` clause as non-restricting so it no
  longer suppresses an action's menu equivalent.
- Package Swift Testing suite + app integration test; docs updated in
  cmux.schema.json and the en/ja message catalogs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Browser omnibar: first focus-gaining click selects whole URL (Chrome parity)

Match the Chrome/Safari/Arc omnibar click model exactly:
1. The first click on an unfocused omnibar showing a URL selects the
   entire contents, so the next keystroke replaces the URL.
2. A subsequent click while the omnibar is already first responder places
   the caret at the click point (preserves issue #5268).
3. A double-click selects the word under the cursor (unchanged
   field-editor behavior).

The mouse-gesture state machine in OmnibarNativeTextField already owns the
mouse selection decision, so the change stays there rather than routing a
mouse click through the async, notification-based requestAddressBarFocus
selection-intent path (which is for keyboard/programmatic focus like
Cmd+L). MouseSelectionState now records whether the click gained focus and
whether Shift was held; mouseUp consults the pure, testable decision
function browserOmnibarFocusGainingClickShouldSelectAll to select all only
on an undragged, unmodified focus-gaining click. Drags and Shift-clicks
keep their explicit range; double-clicks never reach this path.

Closes #5459

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: keep restored hidden webview attached during capture

* test: cover OMO tmux respawn panes

* Omnibar: count UTF-16 length without NSString bridge

Address Greptile P2: use editor.string.utf16.count for the select-all
range length instead of bridging to NSString just to read .length. Same
value, no Obj-C bridge cast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Scale browser chrome with the tab bar font size

The browser omnibar text and toolbar icon buttons (back/forward/reload,
lock, screenshot, cursor grab, profile, theme, dev tools) were a fixed
size and ignored the user's font settings, so the top chrome looked
inconsistent once the tab bar font size was changed.

Derive every omnibar/toolbar size from the existing `surfaceTabBarFontSize`
setting via a new pure `BrowserChromeMetrics` value type: a scale anchored
to the shipped default (11pt) so the default appearance is byte-identical,
clamped to a sane range so a malformed config can't blow up the toolbar.
BrowserPanelView seeds the size from the cached config and refreshes it
live on `.ghosttyConfigDidReload` — the same observation path the tab strip
and terminal panels already use — so the chrome re-lays-out the instant the
tab bar font size changes. No new setting is introduced.

Closes #5463

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: respawn OMO subagent panes

* fix(tests): pass fontSize to OmnibarTextFieldRepresentable test constructions

The new required fontSize parameter on OmnibarTextFieldRepresentable broke
two existing test-target call sites, failing the tests job to compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: align tmux respawn pane semantics

* fix: retain browser screenshot URL waiter

* test: cover -infinity and clarify min-font comment (Greptile)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: expose terminal wait-after debug state

* Add failing main window min-size regression test

* Clamp main window AppKit fitting size to policy floor

* fix: keep respawned tmux panes attached

* fix: tighten tmux respawn semantics

* test: isolate omo respawn assertions

* fix: clear respawn warning budget

* fix: preserve respawn startup environment

* Fix SIGTRAP when ASWebAuthenticationSession completes off the main thread

The completion closure handed to ASWebAuthenticationSession was formed
inside the @MainActor factory, so under the package's Swift 6 language
mode it inherited main-actor isolation and the compiler emitted a
dynamic isolation assertion at the ObjC boundary. macOS 26 delivers the
session's cancel-path completion on the SafariLaunchAgent XPC queue (the
deleted AuthManager's Swift 5 app-target code documented this off-main
behavior but emitted no check), so dismissing the sign-in popup trapped
in dispatch_assert_queue.

The completion is now built by a nonisolated @Sendable bridge that
carries no isolation assumption and hops to the main actor itself. A
true red/green regression commit is not practical here: the trapping
closure was only reachable through a live ASWebAuthenticationSession
callback, and the trap is a compiler-inserted assertion, not logic the
old shape exposed to tests. The new tests pin the bridge's contract by
delivering the completion from non-main queues.

Part of https://github.com/manaflow-ai/cmux/issues/5375

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Extract SocketControlServer into CmuxControlSocket (stage 2) (#5432)

* Extract SocketControlServer into CmuxControlSocket (stage 2)

Move the control-socket listener out of TerminalController into the
CmuxControlSocket package: startup path reservation, bind/listen
lifecycle, the generation-counted accept source with failure backoff
and rearm, the socket-path monitor, and synchronous state reads. The
former nonisolated(unsafe) field block and NSLock collapse into one
OSAllocatedUnfairLock-guarded state machine (documented carve-out: all
drivers are synchronous - DispatchSource handlers, client reader
threads, and app-termination teardown that must finish before exit).

App-shaped concerns cross a closure seam (SocketControlServerEvents):
telemetry breadcrumbs/failures with the existing capture cooldown,
the .socketListenerDidStart notification + PortScanner wiring at the
same point in start, accepted-client hand-off to the existing
thread-per-client handler, and the path-missing/rearm restart
triggers, which keep their main-thread scheduling in the app.

Also moved: SocketFastPathState (DispatchQueue-as-lock -> lock-guarded
package type keyed on raw state strings; dead shouldPublishDirectory
dropped) and the peer PID/UID/ancestry checks (SocketTransport+Peer).

TerminalController keeps a thin facade with unchanged signatures, the
client read loop, auth, and command dispatch (those move in stage 3).
All telemetry stage strings unchanged. -855 net lines.

20 new package tests (real-socket lifecycle, crash-reclaim stale
socket replacement, reservation consumption, path-monitor delete
detection, per-mode permissions, dedupe cache, peer verification);
65 total green.

* review: rename print prefixes, asyncAfter justification, DocC examples

- print() prefixes in SocketControlServer+Startup say SocketControlServer
  instead of the legacy TerminalController name (stdout prints kept for
  launch-time visibility parity with the legacy listener).
- One-line justification comment on the accept-source resume asyncAfter
  (bounded backoff deadline in a non-async type; stale fires are no-ops
  via the generation/identity/suspended guards).
- DocC usage examples + cross-references on SocketTransport peer APIs
  and SocketFastPathState.

* Make the session completion bridge an instance method

nonisolated on the instance method escapes the factory's @MainActor
isolation just as well as static did, and the bridge can use the
instance's own log instead of taking it as a parameter.

Part of https://github.com/manaflow-ai/cmux/issues/5375

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: harden iOS TestFlight publish (main-only guard + CODEOWNERS) (#5476)

Defense-in-depth for the publish pipeline:
- Upload job gains `github.ref == 'refs/heads/main'`, so a publish can only run
  from main. push/schedule already run on main; this blocks shipping arbitrary
  code by dispatching the workflow against a feature branch.
- Add .github/CODEOWNERS for secret-touching paths (all workflows, the
  upload-testflight.sh publish script, ios/Config) so changes there require an
  owner's review.

NOTE: CODEOWNERS only enforces once branch protection on main sets
require_code_owner_review=true (+ required_approving_review_count>=1). That
branch-protection change is the actual gate and is an admin action; these two
changes make it effective and add depth, they are not the gate themselves.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix: address omo respawn review feedback

* Fold AuthErrorMapper into AuthError

The mapper was a stateless value whose whole job was translating raw
backend errors into the AuthError vocabulary; that conversion now lives
on the type itself as AuthError(displaySafe:) (failable: nil means the
original Stack error is already display-safe and the sign-in UI renders
it unchanged), with the cached-session recovery decision as a property.
The StackAuth-dependent conversion sits in AuthError+DisplaySafe.swift
so AuthError.swift stays Foundation-only.

Part of https://github.com/manaflow-ai/cmux/issues/5375

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Organize CMUXAuthCore: one type per file, DocC, no namespace-enums

Every public symbol now carries DocC, matching CmuxAuthRuntime, and
every type has its own file (CMUXAuthEnvironment, the key-value store
protocol, and the launch-input types move out of shared files). The two
namespace-enums become real shapes the conventions allow:

- CMUXAuthLaunchConfig's parsers are now failable initializers on the
  values they construct: CMUXAuthAutoLoginCredentials(environment:...)
  and CMUXAuthUser(uiTestFixtureEnvironment:...).
- CMUXAuthMagicLinkCode is a value (code + nonce) with a composed
  property instead of a caseless enum with a static compose.
- CMUXAuthConfig.resolve and AuthConfig.resolve become initializers.

Part of https://github.com/manaflow-ai/cmux/issues/5375

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios: name the TestFlight beta "cmux BETA" on device (#5485)

Release builds ship the beta lane (dev.cmux.app.beta) but displayed as plain
"cmux", indistinguishable from a future App Store "cmux". Override
PRODUCT_DISPLAY_NAME = "cmux BETA" in Release.xcconfig (after the Shared.xcconfig
include, so it wins). Debug builds (dev.cmux.ios) stay "cmux".

Verified deterministically: Release config's base is Release.xcconfig (pbxproj
baseConfigurationReference), there is no direct PRODUCT_DISPLAY_NAME in the
pbxproj, and INFOPLIST_KEY_CFBundleDisplayName = $(PRODUCT_DISPLAY_NAME), so the
Release app's CFBundleDisplayName resolves to "cmux BETA". Matches the macOS
"cmux NIGHTLY" / "cmux DEV" variant-naming convention.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix: address main window sizing review feedback

* test: address main window sizing review comments

* Remove mobile host legacy defaults (#5484)

* Fix notification sound selection playback (#5480)

* Fix notification sound selection playback

* Address notification sound staging review

* Fix sidebar close button hidden under wrapped workspace titles (#5488)

The workspace row's close (x) button was a floating
overlay(alignment: .topTrailing) that reserved no layout space, while
the title used frame(maxWidth: .infinity) with no trailing inset. A
title long enough to wrap (or any long single-line title) therefore
filled the top-right corner, and the semi-transparent x rendered on top
of the title glyphs with no background, so it read as missing. Short
titles left that corner empty, which is why single-line names looked
fine.

Move the close button into the title HStack as a trailing sibling that
always reserves its width when the workspace is closable, toggling
visibility via opacity (so hover never re-lays-out the row). The title
now wraps/truncates before the button's corner, leaving a clear area
where the x always shows. This matches the existing group-header
plus-button pattern.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(ios): archive unsigned, sign only at export (fix TestFlight cert-cap failure) (#5496)

* ci(ios): archive unsigned, sign only at export (fix dev-cert churn)

Automatic signing during `xcodebuild archive` (-allowProvisioningUpdates +
CODE_SIGN_STYLE=Automatic) makes each ephemeral CI runner mint a new Apple
Development certificate, which exhausted the account's certificate cap and
broke every on-merge TestFlight publish ("maximum number of certificates" /
"no profiles for dev.cmux.app.beta"). Archive without signing; the export step
applies the (reused, cloud-managed) distribution cert, which does not churn.

Includes a TEMP push trigger + ref-guard relaxation for this branch to
validate a real upload before merge; both reverted before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: remove temp branch validation hooks (archive-no-sign verified)

Validated on-branch: archive unsigned + cloud-distribution export uploaded
cleanly (UPLOAD SUCCEEDED, Delivery UUID 2f8bd406..., build 202606060130),
no new dev cert minted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* ci(ios): 14-digit build number so TestFlight offers updates (#5499)

CFBundleVersion regressed from 14-digit (yyyyMMddHHmmss, e.g. 20260520031606)
to 12-digit (yyyyMMddHHmm, e.g. 202606060220). Numerically the 12-digit values
(~2.0e11) are LOWER than the legacy 14-digit ones (~2.0e13), so every recent
build sorted BELOW the May builds and TestFlight never offered an Update (it
picks the highest CFBundleVersion as "latest"). Restore seconds so build
numbers exceed the legacy max and increase monotonically.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* ci(ios): 14-digit build number in the workflow fallback too (#5503)

PR #5499 fixed the script default, but the workflow's "Resolve build number"
step ALWAYS passes --build-number explicitly, and its no-input fallback was
still 12-digit (date -u +%Y%m%d%H%M). So every automatic push/schedule build
overrode the script's 14-digit default with a 12-digit value, leaving the
CFBundleVersion below the legacy max (20260520031606) and TestFlight never
offered it as an update. Match the script: yyyyMMddHHmmss.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* ci(ios): enforce monotonic TestFlight build numbers against App Store Connect (#5504)

* ci(ios): enforce monotonic TestFlight build numbers against App Store Connect

The build-number scheme regressing from 14 to 12 digits silently shipped builds
with a CFBundleVersion below the existing max, so TestFlight never offered them
as an update (it ranks the highest build number as "latest"). Restoring the
scheme (#5499, #5503) fixed the symptom but nothing enforced the actual
invariant, and a bad manual --build-number would reproduce it.

Add a behavioral guard: before archiving, asc_max_build.py asks App Store
Connect for the current max integer CFBundleVersion (mints an ES256 JWT, resolves
the app by bundle id, pages builds and maxes as int because ASC sort=-version is
a string sort). upload-testflight.sh self-heals BUILD_NUMBER up to max+1 when it
would not be the highest, with a loud warning.

Fail-open by design: any ASC/network/JWT error (or missing `cryptography`) logs a
warning and keeps the timestamp build number, so a transient API hiccup never
blocks a publish. The workflow best-effort installs `cryptography` for the guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(ios): address autoreview on build-number guard

- Reused archives (--archive-path): the embedded CFBundleVersion ships, not the
  shell BUILD_NUMBER, so the guard now reads the archive's CFBundleVersion and
  fails (re-archive needed) instead of self-healing a value that won't apply.
  Skipped for --export-only (no upload).
- Supply chain: install `cryptography` BEFORE the App Store Connect private key
  is written to disk, and pin to a wheel-satisfiable range, so a compromised
  install can't read the signing credential.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(ios): never return a partial App Store Connect build max

Page until ASC stops returning a `next` link instead of capping at 20 pages and
returning whatever was seen so far. A truncated read could be below the true max,
letting the caller self-heal to a number still <= the real max (the exact
non-updatable build this guard prevents). MAX_PAGES is now only a runaway
backstop; hitting it with more pages pending raises, so the caller fails open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(ios): address PR-bot findings on the build-number guard

- Invoke asc_max_build.py via explicit `python3` (not a bare path), so a lost
  exec bit can't silently make the guard always fail open. (cursor)
- Reused --archive-path: require a NUMERIC embedded CFBundleVersion; if it can't
  be read, skip the guard with a warning instead of falsely "bumping" a value
  that never applies to the archive. (cursor)
- asc_max_build.py: raise on a `next` URL that doesn't start with API_BASE, so a
  malformed pagination link can't silently truncate to a partial max. (CodeRabbit, cursor)
- asc_max_build.py: emit only HTTP status + ASC error code, never the raw
  response body, to keep upstream payloads out of logs. (CodeRabbit)
- asc_max_build.py: drop the ambiguous saw_any flag; return highest (0 = no
  floor). (greptile)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(ios): report the post-guard CFBundleVersion in the workflow summary

After the monotonic guard self-heals BUILD_NUMBER, the workflow summary was still
printing the pre-guard value, so a release audit could show a CFBundleVersion
that doesn't match the uploaded IPA. The script now writes the shipped build
number to CMUX_BUILD_NUMBER_OUT_FILE (the archive's embedded version for reused
archives), and the workflow reads it into a step output the summary consumes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(ios): remove PyPI dep from signing job; fail closed on reused archives

Addresses two autoreview findings on the build-number guard:

- Supply chain: drop the `cryptography` dependency entirely. asc_max_build.py now
  mints the ES256 JWT via `openssl` (preinstalled) + stdlib, so the TestFlight
  job that holds the signing/upload credential never `pip install`s third-party
  code that could persist in site-packages and run once the key is on disk. The
  workflow's "Ensure cryptography" install step is removed.
- Reused --archive-path can't be renumbered, so it must be verifiable before an
  upload: fail CLOSED when its embedded CFBundleVersion is unreadable or when App
  Store Connect can't be reached, instead of fail-open. Fresh builds keep
  fail-open (the timestamp scheme is already correct). --export-only never
  uploads, so it only warns.

Verified: openssl-signed JWT authenticates to ASC (both key-path and base64-key
paths); all guard branches (fresh fail-open, reused fail-closed x3, export-only
skip) behave correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(ios): close reused-archive verification hole on the Apple ID upload path

The reused-archive fail-closed guarantee only held when ASC API creds gated the
guard block. A reused --archive-path uploaded via the Apple ID/app-specific-
password path (no ASC creds) skipped the block and shipped unverified. Track
REUSED_ARCHIVE_VERIFIED (set only after a real ASC max comparison) and refuse the
upload for any reused archive that reaches it unverified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(ios): fail closed for explicit build numbers when ASC can't be verified

Fail-open is only safe for the generated UTC timestamp (monotonic by
construction). An explicit --build-number could be stale, so if App Store Connect
can't be reached the guard now fails closed for explicit values while keeping
fail-open for the generated default.

To make the distinction real, the workflow no longer passes --build-number for
push/schedule runs: the script generates the timestamp itself (single source of
the numbering scheme, removing the workflow/script duplication that caused the
12-vs-14-digit regression). --build-number is passed only for a manual
workflow_dispatch build_number input, which is exactly the explicit case that now
fails closed when unverifiable. The summary reads the shipped number back from
CMUX_BUILD_NUMBER_OUT_FILE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(ios): close explicit-build-number bypass on the Apple ID upload path

Symmetric to the reused-archive fix: an explicit --build-number uploaded via the
Apple ID path (no ASC API creds) skipped the guard and could ship a stale build.
Generalize the verification flag (REUSED_ARCHIVE_VERIFIED -> GUARD_VERIFIED, set
only after a real ASC max comparison) and the final pre-upload gate now refuses
BOTH an unverified reused archive AND an unverified explicit --build-number. The
generated UTC timestamp stays exempt (monotonic by construction, fail-open).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(ios): only mark a build number verified after a real numeric comparison

GUARD_VERIFIED was set as soon as asc_max_build.py succeeded, before checking
that both values are numeric. A non-numeric explicit --build-number then fell to
the "keep" branch already marked verified, bypassing the fail-closed gate.

Restructure: set GUARD_VERIFIED only inside the numeric branch, after a real
comparison. Non-numeric or unreadable ASC max leaves it unset. Consolidate the
fail-closed check into one gate run BEFORE the archive (fail fast): an unverified
reused archive or explicit --build-number is refused; the generated UTC timestamp
stays exempt (fail-open). Verified-but-stale explicit values now also fail with a
clear message instead of being silently bumped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* iOS pairing onboarding: clearer auth error + Download via TestFlight link (#5506)

* iOS pairing: honest copy for generic auth failure

The Mac collapses every non-account-mismatch authorization failure (invalid
token, wrong Stack project/environment, missing local user, timeout) into a
single `unauthorized` code, which the phone maps to `.authorizationFailed`. Its
copy asserted "Sign in on your computer with the same account…", which is a
specific (often wrong) cause. The most common trigger in practice is a
dev-vs-prod Stack project mismatch (same email, different per-project user ID),
where the token simply can't be verified against the Mac's project.

Reword to state the actual condition without blaming the Mac's account, and hint
at the build/environment cause. The distinct `account_mismatch` path keeps its
accurate "different cmux account" message.

en + ja updated in ios Localizable.xcstrings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* iOS pairing onboarding: "Download via TestFlight" link

Add a "Download via TestFlight" link to both iOS onboarding surfaces — the
"No devices" empty state (DisconnectedWorkspaceShellView) and the Add device
screen (PairingView) — pointing at the Founders Edition page
(https://github.com/manaflow-ai/cmux#founders-edition) since TestFlight is
invite-only for now. The Founders Edition page covers both TestFlight
enrollment and the Mac download.

en + ja added in ios Localizable.xcstrings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add macOS iOS pairing/onboarding window (#5493)

* Add macOS iOS pairing/onboarding window

Adds a dedicated Mac-side window for pairing an iPhone: a scannable QR code
(with a host:port fallback), step-by-step instructions, and live pairing-host
state. Opening it auto-enables the iOS pairing listener, mints a short-lived
attach ticket, and renders the code. Entry point is a "Pair a Device" button in
Settings → Mobile.

- Sources/Mobile/Pairing/: MobilePairingWindowController, MobilePairingView,
  MobilePairingModel, MobilePairingQRImageView.
- MobileHostService.ensureListeningAndReady(): one async entry that starts the
  listener and resolves on readiness via a continuation drained from the
  existing listener-state handlers (no polling; no changes to the accept path).
- SettingsHostActions.openMobilePairingWindow() seam + host implementation;
  MobileSection gains the Pair a Device row.
- 17 strings localized in en + ja.

Revoke and a connected-device list are deferred to a follow-up: there is no
per-device identity on the Mac today to revoke against. Design in
cmuxterm-hq plans/feat-ios-device-revoke/DESIGN.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Pairing window: sign-in gate + Tailscale guidance

Restructure the pairing window into a requirements flow (sign in → Tailscale →
QR), since both gate whether a phone can actually pair:

- Sign-in gate: check AuthManager first. When signed out, show a Sign In button
  (beginSignInAndAwait) and don't enable the listener or show a code. When
  signed in, show "Signed in to cmux" with the account email, then prepare a
  code. Authorization is a Stack same-account check, so the Mac must be signed
  in for any phone to pair.
- Tailscale guidance: a requirements row driven by real reachability. The route
  resolver only publishes Tailscale routes (plus DEBUG loopback), so a real
  iPhone needs Tailscale. When no Tailscale route resolves, show a warning + a
  "Get Tailscale" link and note both devices need it on the same account; when
  it resolves, show "Reachable over Tailscale" and the host:port.
- 10 new strings localized in en + ja.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Adapt pairing window to AuthCoordinator; add TestFlight link

Rebased onto main; #5387 replaced AuthManager with an injected @Observable
AuthCoordinator + HostBrowserSignInFlow. Migrate the pairing model/view:

- Read isAuthenticated / currentUser / awaitBootstrapped from
  AppDelegate.shared.auth.coordinator; trigger sign-in via
  browserSignIn.beginSignIn() (fire-and-forget). The view re-runs refresh() on
  the coordinator's isAuthenticated and the browser flow's isSigningIn settling
  (handles cancel without spinning).
- Resolve rebase conflicts in MobileHostService (keep the new auth property +
  the readiness continuation) and SettingsHostActions (keep both
  openMobilePairingWindow and the new previewNotificationSound signature).

Also add a "Download via TestFlight" link under the install step pointing at the
Founders Edition page (TestFlight is invite-only for now). en + ja added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Pairing window: handle no-Tailscale-route as guided state

Autoreview caught that on a release Mac with no Tailscale address,
createAttachTicket throws noRoutes (release has no debug loopback route) and the
catch showed the raw enum text. Add a dedicated `needsTailscale` state: guard
before minting when status.routes is empty, map noRoutes/routeUnavailable in the
catch, and replace the raw String(describing:) fallback with localized copy. The
state renders "no Tailscale address… install Tailscale, then refresh" with a Get
Tailscale button, and the Tailscale checklist row shows the warning. en + ja.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Pairing window: auto-refresh QR before ticket expiry

Autoreview caught that the 600s attach ticket could expire while the window
still showed the QR with a perpetual "Waiting…", so a delayed scan would fail.
Schedule a bounded, cancellable re-mint ~30s before TTL elapses (cancelled on
each refresh and on window close via onDisappear), keeping the displayed code
always valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Settings search: index the Pair a Device row

Autoreview noted the new Settings → Mobile "Pair a Device" row wasn't reachable
from Settings search (search indexes only curated entries). Add a curated entry
(id pairDevice) with pairing/QR/scan/iPhone/iPad/Tailscale/onboarding synonyms,
matching the row's setting:mobile:pairDevice anchor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Tests: add pairDevice to settings reachability contract

The new curated pairDevice search entry is an action row (no cmux.json path), so
SettingsRowAnchorResolutionTests.everyCuratedSettingEntryIsReachable would flag
it unreachable. Add setting:mobile:pairDevice to explicitlyAnchoredEntryIDs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Pairing window: address review findings + fix warning budget

- Fix tests-build-and-lag warning budget: MobileHostService.shared was read from
  a nonisolated default-arg context; resolve `.shared` in the @MainActor init body
  (host: MobileHostService? = nil).
- Serialize refresh() with a generation guard so a slower in-flight run can't
  overwrite a newer ticket (race flagged by Cursor + CodeRabbit).
- Stop rendering the raw NWListener error string; show localized listener-offline
  copy (CodeRabbit).
- Deminiaturize a reused pairing window before bringing it front (CodeRabbit).
- Accessibility label on the QR placeholder (CodeRabbit).
- Drain readiness waiters if the ephemeral-fallback bind fails synchronously, so
  ensureListeningAndReady() doesn't wait the full deadline (CodeRabbit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: cover OSC 11 socket input preservation

* fix: preserve OSC terminal control sends

* iOS: remove dead TerminalArrowNubView.Direction.escapeSequence (#5505)

The arrow nub drives repeats through TerminalArrowRepeatService ->
TerminalKeyEncoder; the hardcoded escapeSequence property duplicated those
bytes and was never referenced (grep-confirmed zero call sites). Drop it so
TerminalKeyEncoder stays the single source of truth for arrow encoding.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Make iOS pairing port configurable with live bound-port status (#5489)

* Make iOS pairing port configurable with live bound-port status

Settings > Mobile gains a configurable pairing-listener port
(mobile.iOSPairingHost.port, default 58465). The port is a
preference: the listener still falls back to an OS-assigned
ephemeral port when it's in use, and the iOS pairing payload uses
the actual bound port, so pairing survives a fallback. A live
bound-port indicator shows the real port and warns when it differs
from the configured one, so a configured port can't silently fail.

Also adds a Mac display-name override (mobile.iOSPairingHost.
displayName) and read-only diagnostics (connected-device count +
reachable routes) while pairing is enabled.

The listener reconciles on settings change through a pure,
unit-tested syncDecision (start/stop/restart only when the enabled
state or port actually changes), so unrelated UserDefaults writes
never drop active iOS connections. Live status reaches the
Foundation-only settings package via new SettingsHostActions seams
backed by a mobileHostStatusDidChange notification.

Adds curated search entries + aliases for the new settings and
en/ja localization for all new strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Mobile settings: surface out-of-range port + show resolved name placeholder

An out-of-range port (e.g. 99999) clamps to the default internally, so
without this it would render a reassuring green "Listening on <default>"
with no hint the typed value was ignored. Show an explicit orange
"Port must be between 1 and 65535." instead (even while pairing is off).

Use the resolved system name as the Display Name field placeholder when
no override is set, so the user sees the actual default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix Swift warning budget: bridge pairing-status notification via Void signal

The MainActor for-await over NotificationCenter.notifications(named:)
tripped the warning budget (non-Sendable Notification crossing isolation
in next()). Mirror UserDefaultsSettingsStore.values(for:): a block
observer yields to a Sendable AsyncStream<Void>, and the MainActor drain
task reads the snapshot, so Notification never crosses. Behavior is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix stale connected-device count: notify on registry mutation, not accept

beginConnection() fires at accept time, before the connection is inserted
into MobileHostConnectionRegistry, but the status snapshot's
activeConnectionCount reads that registry. The status stream could yield
the old count and then never update after the insert. Post
mobileHostStatusDidChange from the registry's insert/remove/removeAll
(where the authoritative count changes) instead.

Addresses autoreview finding on the live connection-count path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Don't rebind pairing listener for invalid port input

Typing an out-of-range port (e.g. 70000) persists the raw value and
syncToSettings mapped it to the default via configuredPort(), so a
listener running on a custom valid port would restart and move to the
default (dropping devices) while the UI only showed an "invalid" warning.

Add resolvedDesiredPort() which returns nil for an out-of-range stored
value; syncToSettings then reuses the applied port (no restart) until a
valid port is entered. A fresh start still binds the default.

Addresses autoreview finding on invalid-port handling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Source display-name placeholder from static system name, not live status

The status stream only fires on listener/route/connection events, so the
snapshot's displayName went stale when the user edited or cleared the
override (the display-name write is a plain UserDefaults change that posts
no status notification). Drop displayName from the snapshot and add
SettingsHostActions.mobilePairingDefaultDisplayName() returning the Mac's
system name (Host.current().localizedName), which the placeholder uses.
That name is stable, so the placeholder never goes stale. The override
itself still drives the real pairing name via MobileHostIdentity.

Addresses autoreview finding on the display-name placeholder. (The curated
search-entry title finding is the existing package convention — all 103
entries hard-code English titles; the row labels and search synonyms are
localized, so this is left consistent with the rest of the catalog.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Doc the SettingsHostActions mobile default implementations

Add Swift-DocC one-liners to the new mobilePairingStatus/
mobilePairingStatusUpdates/mobilePairingDefaultDisplayName default
implementations to satisfy the package documentation policy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add explicit Apply button with port availability check

Editing the port no longer rebinds the listener. The field is a local
draft; an Apply button (enabled when the draft is valid and differs from
the port in effect) checks the port is free before doing anything:

- free  -> persist + rebind (devices reconnect on the new port)
- in use -> leave the running listener untouched, show "Port X is in use,
  still listening on Y"
- pairing off -> save for when pairing is enabled

Availability is a synchronous one-shot bind probe (INADDR_ANY, no
SO_REUSEADDR, matching NWListener's default); the live bound-port status
stays authoritative so any rare dual-stack disagreement self-corrects.
Decision logic is the pure, unit-tested portApplyDecision.

Also fix a latent gap: a preferred port held by another process can
surface as .waiting(.posix(.EADDRINUSE)) rather than .failed, where the
listener would wait forever; treat address-unavailable .waiting the same
as a failure so the ephemeral fallback fires. Shared via
handleListenerBindFailure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix port draft sync + IPv6-accurate availability probe (autoreview)

Two autoreview findings on the Apply flow:

- P1: the field seeded its draft from DefaultsValueModel's initial *default*
  (it yields the saved value asynchronously), so a user with a saved port
  saw the default and could overwrite it. The field now tracks port.current
  via an optional editedPort (nil = follow persisted, set = user edit), so it
  reflects the saved port once loaded and never clobbers it.

- P2: the IPv4-only bind probe missed an IPv6-only conflict, so apply could
  restart and drop connections despite the "untouched on conflict" contract.
  Probe with a throwaway NWListener using the same NWParameters as the real
  bind (dual-stack), one-shot continuation under the lock carve-out.
  applyConfiguredPort is now async and probes only when a running listener
  would move to a different in-range port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Force rebind when applying a freed port after ephemeral fallback

After an ephemeral fallback, appliedPreferredPort holds the configured
port while the listener is on an ephemeral one. Re-applying the (now-freed)
configured port persisted the same value, so the settings observer's
syncToSettings saw no change and the listener stayed on the ephemeral port
even though apply reported success. applyConfiguredPort now restarts
directly whenever the listener is not bound to the requested port, instead
of relying on a persisted-value change to drive the rebind.

Addresses autoreview finding on the apply state machine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Don't show stale Apply feedback after the pairing toggle changes

The Apply message was cleared only on a port edit, so toggling pairing
could leave a contradictory note: "Will use port X when iOS Pairing is on"
after enabling, or "Still listening on Y" after disabling. Gate the
saved-for-later note to pairing-off and the in-use note to pairing-on, so
the live indicator takes over the moment the toggle flips (these read the
@Observable toggle state, so they re-evaluate reactively).

Addresses autoreview finding on stale Apply feedback. (The curated
search-title localization finding is the catalog's documented English-only
design — "English-only until the package ships an xcstrings catalog" — so
localizing only the new entries would contradict it and split the table;
the row labels and synonyms are localized.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address CodeRabbit: IPv6 endpoint brackets, accurate fallback flag, honest defaults

- MobilePairingRoute.endpoint wraps IPv6 literals in brackets ([host]:port)
  per RFC 3986 so the port colon isn't ambiguous.
- makeStatus reports usesEphemeralFallback from the stored bind outcome
  (listenerUsesEphemeralFallback) instead of recomputing listenerPort vs the
  current configured port, which could flip during an edit/restart window.
- syncToSettings() / applyConfiguredPort() drop their UserDefaults parameter:
  they drive the live singleton listener, which always binds against
  UserDefaults.standard (start/restart read it too), so a caller-supplied
  store was never honored for the actual bind. The pure read-only statics keep
  their defaults parameter for unit testing.

CodeRabbit's in-field port-validation nitpick is already handled: an
out-of-range value disables Apply and shows the range warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix port-availability probe hang: NWListener needs a newConnectionHandler

NWListener does not transition to .ready unless newConnectionHandler is set
before start(), so the probe never resumed its continuation on a *free*
port — hanging applyConfiguredPort (and the Apply button) on the common
success path. Set a reject-everything newConnectionHandler on the probe.

Caught by Greptile (P1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Harden port probe against hangs + localize new search titles

- isPortAvailable now races the NWListener probe against a bounded 2s
  deadline via a task group; cancellation tears down the probe listener
  through a cancellation handler (.cancelled resolves as unavailable), so an
  unclassified/stuck listener state can never hang Apply. On timeout the port
  is reported unavailable (safe: leaves the running listener untouched).
- Curated search-entry titles for the new mobile rows are now localized
  (reuse the row-label keys), so JP search results don't show English.

Addresses autoreview (P2 hang, P3 localization) and Cursor/Greptile probe
findings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Make Apply atomic: make-before-break instead of probe-then-restart

The probe-then-restart path was not atomic: the requested port could be
taken (or the probe's own socket not yet released) between the availability
probe and the real bind, by which point the old listener had already been
stopped — dropping connections and landing on an ephemeral port despite the
"in-use port leaves the running listener untouched" contract.

applyConfiguredPort now binds a *candidate* listener on the requested port
while the current one keeps running, and only tears down the old listener
and adopts the candidate once it actually reaches .ready. If the candidate
can't bind (in use), it's discarded and the live listener is untouched
(portInUse). A bounded, cancellable 2s deadline guarantees Apply can't
hang. The separate availability probe is removed; the candidate bind is the
real bind. portApplyDecision becomes the pure pre-bind classifier
portApplyPreBindOutcome (nil = a real bind is needed).

Addresses autoreview P1 (non-atomic apply).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: document OSC background routing

* Add Mobile Connect to the command palette (#5518)

* Add Mobile Connect to the command palette

New "Mobile Connect" entry in the Cmd+Shift+P command palette that opens
the iOS/iPadOS pairing window (same shared MobilePairingWindowController
path as Settings → Mobile → Pair a Device). Searchable by ios, ipados,
iphone, ipad, pair, pairing, mobile, connect, device, phone, tablet, qr.

Keywords live in one place (ContentView.commandPaletteMobileConnectKeywords)
so the contribution and its behavioral test can't drift. Test runs the real
fuzzy search engine and asserts the command is the top result for "ios" and
"ipados" (plus iphone/ipad/pair/mobile connect) against a dense decoy corpus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Rename palette command to "Connect iPhone/iPad" + localize all languages

Verb-first name matching the palette's house style; the visible label is
"Connect iPhone/iPad" while keywords still match mobile, phone, ios, ipados,
iphone, ipad, pair, connect, device, tablet, qr. Title and subtitle are now
translated for all 20 locales in Localizable.xcstrings (was en + ja).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Localize numbered shortcut digit validation

* fix: measure visible sidebar rows

* Add auth commands to command palette (#5529)

* Bump version to 0.64.14

* Pair onboarding: drop leading row icons, keep text (#5520)

* Pair onboarding: drop leading row icons, keep text

The "Pair your iPhone" requirements checklist showed a leading SF Symbol
per row (person/checkmark for sign-in, globe/checkmark/warning for
Tailscale). Remove the glyph so each row is just title + subtitle text;
the "Get Tailscale" trailing link and all state-driven subtitles stay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Pair onboarding: also drop header computer/iPhone icon

"Get rid of icons, just leave the text" covers the whole card. Removing
the header glyph too makes the heading, sign-in row, and Tailscale row
all text-only and flush-left at the same inset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix React script warning in web layout (#5525)

* fix: avoid React script warning in web layout

* fix: preserve theme bootstrap behavior

* fix: keep initial theme color media-safe

* fix: insert theme bootstrap outside hydration

* fix: share theme color constants

* CodeRabbit: stop blocking merges (request_changes_workflow=false) (#5538)

CodeRabbit was submitting reviews in the Request Changes state, which
shows as a blocker in the PR merge box. It is not a required status
check, so flipping request_changes_workflow to false makes it post the
same findings as plain Comment reviews that never block a merge.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Match iOS viewport border to pane divider color (#5530)

* Match iOS viewport border to pane divider color

When an iOS device is connected, macOS draws a border marking the area
visible to the phone. It hardcoded NSColor.separatorColor at 0.95 alpha,
which renders as a bright near-white line in dark mode and doesn't match
any other border in the app.

Stroke the resolved split-divider color instead (the single source of
truth pane dividers already use via GhosttyConfig.resolvedSplitDividerColor),
so the viewport border matches every other border and the color lives in
one place. Repaint the overlay on background changes so it tracks theme
switches while connected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Match iOS viewport border to window-chrome separator color

Ground-truth pixel sampling of the rendered borders showed the
viewport border (right/bottom of the visible area) did not match the
pane outline, sidebar trailing edge, and tab-bar separators: those use
WindowChromeSeparatorColor (~rgb(54,55,49) over the default dark bg),
while the split-divider color is darker and the old separatorColor@0.95
was much brighter (~rgb(74,76,71)).

Stroke WindowChromeSeparatorColor.current() so the viewport border is
pixel-identical to every other chrome border, using the same single
source of truth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* iOS: redesign the default terminal toolbar layout (#5532)

Reorder the iOS terminal accessory bar so the high-traffic keys sit up
front: after the modifier keys come Tab, ^C/^D, the Claude/Codex
launchers, the arrow keys, then a Clear button (^L, relabeled "Clear").
The zoom controls move from the leading pinned region to a new trailing
pinned region at the end of the bar. The remaining punctuation and
navigation keys keep their slots, with the pipe positioned after @.

The curated default arrangement lives in
TerminalInputAccessoryAction.defaultConfigurableOrder, separate from the
enum rawValue order, so persisted display-order identifiers are
untouched. TerminalAccessoryLayoutReducer takes the default order and
defensively appends any omitted configurable id, so a gap in the curated
list can never drop an action from the bar.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* iOS: rename and pin workspaces from the phone (#5512)

* iOS: rename and pin workspaces from the phone

Adds a per-workspace context menu (Rename, Pin/Unpin) to the iOS workspace
list, sorts pinned workspaces to the top with a pin glyph, and a rename
sheet. The phone drives the Mac's existing workspace-scoped `workspace.action`
RPC (pin/unpin/rename).

Security: `MobileHostService` only newly authorizes `workspace.action`, and
only its pin/unpin/rename sub-actions. The action param is normalized exactly
as the handler's `v2ActionKey` (lowercase, '-'->'_') so the gate and handler
can never disagree on which action runs, and workspace scope is enforced with
the same check used for terminal input: a workspace-scoped ticket may only act
on its own workspace, a Mac-wide pairing on any, a terminal-scoped ticket on
none. The destructive/global sub-actions (move_*, close_*, set_color, …) and
the global methods (reorder_many, group.*, the dedicated workspace.rename) stay
Mac-only. New XCTest cases in MobileHostAuthorizationTests lock this down.

The Mac now emits `is_pinned` in the mobile workspace-list payload, and the
workspace-list observer watches `$isPinned` (and hashes it) so a pure pin
toggle pushes to the phone. iOS decodes it backward-compatibly (nil on older
Macs), updates the list optimistically with rollback on RPC failure, and the
authoritative `workspace.updated` push reconciles. en+ja localized.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix PR2 mobile gate: route workspace.action through the real mobile dispatch

The live mobile data plane authorizes identity via same-Stack-account
verification and gates method exposure with an explicit allowlist in
TerminalController.mobileHostHandleRPC (default -> method_not_found), not via
MobileHostService.ticketAuthorizationError (which is only reached by the test
hook). The earlier allowlist edit + tests targeted that test-only function, so
workspace.action would have returned method_not_found at runtime and rename/pin
would silently fail.

Revert the ineffective MobileHostService change and its tests. Add a gated
case "workspace.action" to mobileHostHandleRPC via v2MobileWorkspaceAction,
which rejects every sub-action except pin/unpin/rename (normalized exactly as
v2ActionKey) before calling v2WorkspaceAction, so move_*/close_*/set_color/etc.
stay Mac-only. Cover the gate with a pure mobileAllowsWorkspaceAction test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR2 review: roll back only the targeted workspace field, match-guarded

Autoreview P1: the optimistic rename/pin restored the whole `workspaces`
snapshot on RPC failure, which could clobber newer authoritative state (a
reconnect or a workspace.updated refresh landing while the request was in
flight). Roll back only the single workspace's name/isPinned, and only when our
optimistic value is still present, so a concurrent refresh is never reverted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR2 review: drop optimistic rename/pin, rely on authoritative push

Second review iteration still found an optimistic-rollback race (overlapping
pin then unpin, both failing, could leave stale local state). Stop patching the
rollback and remove the optimistic mutation entirely: the Mac applies the
rename/pin and its workspace-list observer pushes workspace.updated (now also on
$isPinned), which refreshes the list. Fire-and-forget RPC, no local mutation, so
no rollback and no overlap race. (The review's "removes terminal OSC/background
handling" note is incorrect; this branch touches no terminal-rendering code.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR2 review: gate rename/pin UI on a host capability

Review P2: the rename/pin affordances were shown on every connected Mac, but an
older Mac lacking the new mobile workspace.action handler returns
method_not_found, so the actions silently no-op. Advertise a workspace.actions.v1
capability in mobile.host.status, capture it on the client when reading host
status, and only pass the rename/pin closures when the connected Mac supports it
(reusing the existing nil-closure hiding). Reset on disconnect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR2: build the capability-gated closures as literals

The previous commit's `store.supportsWorkspaceActions ? method : nil` ternary
tripped a Swift type-checker bug ("failed to produce diagnostic") inside the
large WorkspaceListView initializer. Build the optional rename/pin closures as
explicit closure literals capturing the store instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR2 review: advertise workspace.actions.v1 on the live status paths

Review P1: the mobile listener intercepts mobile.host.status and returns the
public-status cache / publicHostStatusResult before TerminalController runs, so
adding workspace.actions.v1 only to TerminalController's status left it invisible
to the iOS client. supportsWorkspaceActions stayed false and rename/pin were
hidden even on a supporting Mac.

Consolidate all three capability lists into one source of truth
(MobileHostService.mobileHostCapabilities), advertised on every status path, so
the lists cannot drift again. Add a contract test asserting the shared list
advertises workspace.actions.v1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR2 review: require an explicit valid workspace_id for mobile actions

Review P2: v2MobileWorkspaceAction forwarded to v2WorkspaceAction, which falls
back to the Mac's selected workspace when workspace_id is missing. A malformed or
omitted workspace_id from the mobile plane could therefore pin/rename the wrong
workspace. Validate like the other mobile handlers and additionally require the
id to be present and resolvable before dispatching this mutating action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* iOS: multi-Mac host switcher (#5513)

* iOS: multi-Mac host switcher

Adds a Settings -> Connection -> "Switch Mac" picker that lists every Mac
paired with this device, marks the active one, switches the live connection on
tap, forgets on swipe, and pairs another Mac by scanning its QR without
dropping the others.

The on-device SQLite store already persisted N paired Macs; only the UI was
missing (loadAll was test-only). MobileShellComposite gains pairedMacs,
activeMacDeviceID, loadPairedMacs(), switchToMac(macDeviceID:) (setActive then
reconnect via the existing launch-time reconnect path), and
forgetMac(macDeviceID:). MobileHostPickerView drives them, reached from
MobileSettingsView; the store is threaded as an optional through
WorkspaceShellView -> WorkspaceListView -> MobileSettingsView so workspace rows
stay value-only.

Scope: switches among distinct Macs (each QR pairing stores a real
macDeviceID). Multiple cmux instances on one Mac share a macDeviceID and need a
schema change to distinguish, so that remains a deliberate follow-up. en+ja
localized.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR3 review: scope setActive's clear to the target Mac's Stack user

Autoreview P1: switchToMac called the unscoped setActive, which cleared
is_active across every row. On a shared device, switching hosts for one signed-in
user wiped another user's active pairing, so they failed to auto-reconnect after
signing back in. Scope the clear to the target Mac's own stack_user_id via a
null-safe subquery (mirroring upsert's scoped clear). Add a store regression
test proving a second Stack user's active pairing survives the switch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR3 review: connect before persisting active Mac; clear cache on sign-out

Two review findings:
- P1: switchToMac persisted the new active row before the reconnect, so
  switching to an offline/stale-route Mac stranded the user on an unreachable
  host that recovery kept retrying, with no way back to the switcher. Now it
  connects to the target's route first and persists setActive only on a
  successful connect, so a failed switch leaves the previously-working Mac
  active and reachable.
- P2: the cached pairedMacs list could leak across signed-in users on a shared
  device. Clear it on sign-out and gate loadPairedMacs on isSignedIn.

Also drop the unreliable activeMacDeviceID (the live attach ticket carries a
transient manual id after a reconnect, not the stored Mac's real id); the
switcher now marks the active row by the store's isActive flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR3 review: fix switch race, cross-user load, and active-row forget

Third review pass, three findings:
- P1: a switch superseded mid-connect could persist the wrong active Mac
  (post-hoc connectionState check wasn't tied to this connect). Persist setActive
  only when the live route matches this Mac's normalized host:port.
- P1: loadPairedMacs passed a nil Stack user id straight to loadAll, which
  returns every user's pairings. Treat a missing current user as no pairings.
- P2: forgetting the active row deleted by the live ticket's transient manual id,
  which may not be the stored row, leaving it behind. Always remove the selected
  real id, and tear down the live connection via the new disconnectLiveConnection
  helper when that row is active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR3 review: restore previous connection on failed switch; guard stale load

Fourth review pass:
- P1: connectManualHost is destructive (replaces the live client before the new
  route proves usable), so a failed switch to an offline/stale Mac dropped the
  working session. Capture the previously-active Mac and, when the switch does
  not connect, reconnect to it (it remains the store's active row since setActive
  only runs on success), so a failed switch self-restores instead of stranding.
- P2: loadPairedMacs assigned results after an await without rechecking the user;
  a slow load could repopulate another user's hosts after sign-out. Re-check
  isSignedIn and the current Stack user after the await and discard on mismatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR3: document disconnectAndForgetActiveMac (Aziz doc policy)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add React and Solid agent session panels (#4429)

* Escape inlined agent session bundles

* Use file URL for agent session shells

* Avoid stale agent session web cache

* Use persistent agent session web store

* Add CLI path for agent session surfaces

* Avoid focus reads during PR refresh scheduling

* Load agent session shell from HTML string

* Fill agent session web panels

* Fix agent session web view hosting

* Flush agent session page paint after load

* Flush agent session page after render frames

* Flush agent session paint when visible

* Address agent session review findings

* Polish transparent agent session UI

* Make agent session panel background transparent

* Speak Codex app-server JSON-RPC

* Avoid blanking retained agent webviews

* Auto-start themed agent sessions

* Cover agent GUI auto-start po…
@coderabbitai coderabbitai Bot mentioned this pull request Jun 30, 2026
3 tasks done

This branch was successfully deployed

1 active deployment
Preview – cmux — 27b7020b Deployed Jun 6, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant