Skip to content

Nightly: build app with Xcode 26 SDK so Tahoe gets Liquid Glass - #5077

Merged
azooz2003-bit merged 2 commits into
mainfrom
feat-nightly-macos26-liquid-glass
Jun 1, 2026
Merged

azooz2003-bit merged 2 commits into
mainfrom
feat-nightly-macos26-liquid-glass

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Jun 1, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Nightly settings (and the whole app) render with the legacy, non-Liquid-Glass appearance on macOS 26 Tahoe. This regressed in #5022 (2026-05-30), which moved the nightly build off the macOS 26 runner onto macOS 15 to fix the universal Ghostty CLI helper link.

Root cause is the linked SDK, not any opt-out key. The macOS 15 runner's default Xcode is 16.4 (MacOSX15.5 SDK), confirmed from the latest nightly run log. A binary linked against a pre-26 SDK is automatically placed in the legacy compatibility appearance on Tahoe, the same effect as UIDesignRequiresCompatibility=YES. Before #5022 nightly built on the Tahoe runner (#2231, 2026-03-26) and got Liquid Glass.

Fix

Keep the macOS 15 host, because zig 0.15.2 can cross-link the x86_64 helper slice only against a pre-26 SDK, but build the Swift app with the macOS 26 Xcode that already ships on the runner image (matching the .xcode-version 26 pin). The universal Ghostty helper is built in a separate step with the older Xcode and injected into the bundle before signing.

  • Select Xcode now picks the newest macOS 26+ Xcode for the app build (DEVELOPER_DIR) and the newest pre-26 Xcode for the helper (HELPER_DEVELOPER_DIR), failing loudly if either is missing so a legacy-SDK build can't silently ship again.
  • New Build universal Ghostty CLI helper step builds the arm64+x86_64 helper with the older SDK and verifies both slices.
  • The app build runs with CMUX_SKIP_ZIG_BUILD=1 (the in-Xcode helper build would fail to cross-link x86_64 against the 26 SDK), then the real helper is injected at Contents/Resources/bin/ghostty before the theme-picker regression, arch verification, and signing.

The existing Verify nightly binary architectures step still gates universality of the app, CLI, and helper.

Validation

Triggering a branch run of nightly.yml (should_publish=false, so it builds + signs + notarizes a branch DMG without moving the nightly tag or publishing) to confirm the universal arch checks pass and the produced app adopts Liquid Glass on Tahoe.

Note

release.yml has the identical Select Xcode (Xcode 16.4) pattern, so stable releases also ship the legacy appearance on Tahoe. The same fix should be applied there; not included here to keep this change focused.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled.


Summary by cubic

Build the nightly app with the macOS 26 SDK so Tahoe renders with Liquid Glass. The universal Ghostty CLI helper is built against a pre-26 SDK and injected to keep a universal binary.

  • Bug Fixes
    • Select Xcode 26+ for the app (DEVELOPER_DIR) and pre-26 for the helper (HELPER_DEVELOPER_DIR) using numeric SDK ranking; fail if either is missing.
    • Add a step to build the universal Ghostty CLI helper with zig 0.15.2 and verify arm64/x86_64.
    • Build the app with CMUX_SKIP_ZIG_BUILD=1 and inject the verified helper before signing; use mkdir/install to make the inject step robust.
    • Keep the macOS 15 runner and raise the timeout to 30 minutes; existing arch verification remains.

Written for commit 6ed048f. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Chores
    • Improved nightly macOS workflow with better Xcode/SDK discovery and increased timeout.
    • Builds a universal helper binary (arm64 + x86_64) and injects it into the app bundle during the nightly build.
    • Prevents a failing in-project helper build by skipping it when incompatible SDK cross-linking is detected, improving build reliability.

Nightly was building the whole app with the runner's default Xcode 16.4
(macOS 15.5 SDK). On macOS 26 a binary linked against a pre-26 SDK is
forced into the legacy non-Liquid-Glass appearance, so the Settings
window (and everything else) rendered with old chrome. This regressed in
#5022, which moved nightly off the macOS 26 runner to fix the universal
Ghostty helper link.

Keep the macOS 15 host (zig 0.15.2 can only cross-link the x86_64 helper
slice against a pre-26 SDK) but build the Swift app with the macOS 26
Xcode that already ships on the runner image, matching the team's
.xcode-version pin. The universal helper is built separately with the
older Xcode and injected into the bundle before signing; the existing
architecture-verification step still guards universality.

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

vercel Bot commented Jun 1, 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 1, 2026 2:22am
cmux-staging Building Building Preview, Comment Jun 1, 2026 2:22am

@coderabbitai

coderabbitai Bot commented Jun 1, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The nightly macOS workflow now implements a split Xcode toolchain strategy: selecting an SDK 26+ Xcode for the main app and a pre-26 Xcode for the CLI helper, building the universal helper separately with validation, skipping the in-Xcode helper build, and injecting the prebuilt universal binary into the final app bundle.

Changes

Nightly macOS Workflow Update

Layer / File(s) Summary
Host config and Xcode toolchain selection
.github/workflows/nightly.yml
Updated macOS 15 host timeout and rewrote the "Select Xcode" step to scan /Applications/Xcode*.app, determine each Xcode's SDK major version via xcrun, select one with SDK ≥ 26 for the app build and one with SDK < 26 for the helper build, validate both exist, and export both developer directories to GITHUB_ENV.
Build and validate universal helper
.github/workflows/nightly.yml
Added a step that builds a universal Ghostty CLI helper using the selected helper DEVELOPER_DIR, writes the artifact to a temporary path, and validates both arm64 and x86_64 architecture slices are present using lipo -archs.
App build skip and helper injection
.github/workflows/nightly.yml
Set CMUX_SKIP_ZIG_BUILD="1" to disable the in-Xcode Zig helper build that cannot cross-link against macOS 26 SDK, then added an injection step that verifies the cmux.app bundle exists, installs the prebuilt universal helper into Contents/Resources/bin/ghostty with executable permissions, and reports the injected binary’s architectures using lipo.

Possibly related PRs

  • manaflow-ai/cmux#5022: Both PRs modify .github/workflows/nightly.yml to run the macOS 15-series build/signing lane and adjust runner/toolchain selection.
  • manaflow-ai/cmux#4922: Also updates Xcode discovery/selection logic in the workflow and adds CI guard checks around toolchain choices.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 In CI’s moonlight I hop and pick,

Two Xcodes found, both clever and quick,
I build a helper, universal and neat,
Slice checks pass—arm64 and x86 meet,
I tuck it in the app, snug and complete.


Caution

Pre-merge checks failed

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

  • Ignore

❌ Failed checks (1 error)

Check name Status Explanation Resolution
Cmux Swift Actor Isolation ❌ Error Two file-scoped Logger constants lack nonisolated marking in production Swift (ClosedItemHistory.swift, TabManager.swift), violating swift-actor-isolation.md rule. Mark closedItemHistoryLogger and tabManagerLogger as nonisolated private let to avoid unnecessary main actor coupling.
✅ Passed checks (17 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 Blocking Runtime ✅ Passed PR adds only GitHub Actions workflow config, not production Swift code. The check applies to "production Swift changes" and does not apply to workflow files.
Cmux No Hacky Sleeps ✅ Passed GitHub Actions workflow YAML is explicitly out of scope per runtime-no-hacky-sleeps.md. The new build-ghostty-cli-helper.sh script contains no sleep, delay, polling, or timing constructs.
Cmux Algorithmic Complexity ✅ Passed PR modifies CI/CD workflow (not production code), iterates over tiny fixed-size collection (2-3 Xcode installations on GitHub runner), no scalable user data collections involved.
Cmux Swift Concurrency ✅ Passed PR only modifies .github/workflows/nightly.yml (GitHub Actions workflow), not cmux-owned Swift code. The check for Swift concurrency patterns does not apply.
Cmux Swift @Concurrent ✅ Passed PR contains no modifications to existing Swift code; changes are only to workflow and config files, making the @concurrent check inapplicable.
Cmux Swift File And Package Boundaries ✅ Passed PR modifies only .github/workflows/nightly.yml (workflow file), not production Swift code. No Swift files with functional changes that would require boundary review.
Cmux Swift Logging ✅ Passed PR modifies only .github/workflows/nightly.yml, a CI/CD configuration file, not Swift source code. The custom check applies to production Swift changes only.
Cmux User-Facing Error Privacy ✅ Passed PR modifies only .github/workflows/nightly.yml (CI/CD operational runbook), not user-facing product code. Build error messages are internal developer/CI logs, not shown to end users.
Cmux Full Internationalization ✅ Passed PR is a CI/CD workflow change (nightly.yml) with no user-facing Swift text, localization keys, or internationalization changes; echo statements are operational logs, not user-facing copy.
Cmux Swiftui State Layout ✅ Passed No SwiftUI code changes in this PR. All modifications are to workflow files and configuration only, with no Observable, @Published, GeometryReader, or state mutation changes.
Cmux Architecture Rethink ✅ Passed Swift changes are platform bridge code for macOS 26 AppExtensionPoint.Monitor API with clear ownership, documented reason, and no timing symptoms papering over architectural debt.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed All new Swift windows have stable cmux.* identifiers; auxiliary windows registered in cmuxAuxiliaryWindowIdentifiers; main workspace window properly excluded per rule.
Title check ✅ Passed The title accurately summarizes the main change: building the nightly app with Xcode 26 SDK to restore Liquid Glass appearance on macOS Tahoe.
Description check ✅ Passed The PR description provides comprehensive context covering the problem, root cause, solution approach, and validation steps, though it deviates from the template structure.
✨ 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-nightly-macos26-liquid-glass

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 1, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the regression introduced in #5022 where the nightly build switched to a macOS 15 runner and lost Liquid Glass on Tahoe because the app was linked against the macOS 15 SDK. The fix keeps the macOS 15 host but builds the Swift app with the macOS 26 Xcode (and SDK) already present on the runner, while building the Ghostty CLI helper with the pre-26 Xcode (needed because zig 0.15.2 cannot cross-link x86_64 against the macOS 26 SDK).

  • Dual Xcode discovery: A new Select Xcode implementation scans /Applications for all Xcode installs, uses a numeric sdk_rank() function to pick the newest macOS 26+ Xcode for the app (DEVELOPER_DIR) and the newest pre-26 Xcode for the helper (HELPER_DEVELOPER_DIR), and fails loudly if either is missing — preventing a silent legacy-SDK ship.
  • Separate helper build and injection: A new Build universal Ghostty CLI helper step uses HELPER_DEVELOPER_DIR to build and lipo-verify the arm64+x86_64 helper, which is then injected into the app bundle via a new Inject universal Ghostty CLI helper step before signing; the main app build uses CMUX_SKIP_ZIG_BUILD=1 to avoid an in-Xcode cross-link failure against the 26 SDK.
  • Timeout bump: Job timeout raised from 20 → 30 minutes to account for the additional helper build step.

Confidence Score: 5/5

Safe to merge — purely a CI workflow change with no production Swift or runtime code touched, all failure modes fail loudly, and injection happens before signing.

The dual-Xcode discovery uses numeric version ranking (correctly handling multi-digit minor versions), env-var propagation from GITHUB_ENV is standard GitHub Actions behavior, and all three verification gates (lipo at build time, lipo at inject time, and the existing arch-verification step) remain active.

No files require special attention.

Important Files Changed

Filename Overview
.github/workflows/nightly.yml Rewrites Xcode discovery to select the newest macOS 26+ Xcode for the app and the newest pre-26 Xcode for the helper, adds a separate helper build/inject pipeline, and enables CMUX_SKIP_ZIG_BUILD for the main build. Step ordering is correct (helper built before app, injected before signing), env-var propagation via $GITHUB_ENV is standard GHA practice, and all error paths fail loudly.

Sequence Diagram

sequenceDiagram
    participant R as macOS 15 Runner
    participant X26 as Xcode 26+ (macOS 26 SDK)
    participant X16 as Xcode 16.x (pre-26 SDK)
    participant Zig as zig 0.15.2
    participant Bundle as App Bundle

    R->>R: Select Xcode (scan /Applications, numeric sdk_rank)
    R-->>R: "DEVELOPER_DIR = Xcode 26+ path"
    R-->>R: "HELPER_DEVELOPER_DIR = pre-26 path"

    R->>X16: Build universal Ghostty CLI helper
    X16->>Zig: cross-link x86_64 slice (pre-26 SDK)
    Zig-->>X16: arm64 + x86_64 slices
    X16-->>R: /tmp/cmux-ghostty-helper-universal
    R->>R: lipo verify arm64 + x86_64

    R->>X26: "Build nightly app (CMUX_SKIP_ZIG_BUILD=1)"
    X26-->>Bundle: cmux.app (macOS 26 SDK linked)

    R->>Bundle: Inject helper at Contents/Resources/bin/ghostty
    R->>R: Theme picker regression
    R->>R: Verify binary architectures (app + CLI + helper)
    R->>R: Inject nightly identities and sign
    R->>R: Notarize and publish DMG
Loading

Reviews (2): Last reviewed commit: "Address review: numeric SDK comparison +..." | Re-trigger Greptile

Comment thread .github/workflows/nightly.yml Outdated
Comment on lines +153 to +168
while IFS= read -r app; do
[ -n "$app" ] || continue
dev="$app/Contents/Developer"
[ -d "$dev" ] || continue
sdk_ver="$(DEVELOPER_DIR="$dev" xcrun --sdk macosx --show-sdk-version 2>/dev/null || true)"
[ -n "$sdk_ver" ] || continue
major="${sdk_ver%%.*}"
echo "Found $app -> macOS SDK $sdk_ver"
if [ "$major" -ge 26 ]; then
APP_DEVELOPER_DIR="$dev"
APP_SDK_MAJOR="$major"
else
echo "No Xcode.app found under /Applications" >&2
exit 1
HELPER_DEVELOPER_DIR="$dev"
HELPER_SDK_MAJOR="$major"
fi
done < <(find /Applications -maxdepth 1 -name 'Xcode*.app' -print 2>/dev/null | sort)

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 Lexicographic sort misorders double-digit Xcode minor versions

The sort pipe produces ASCII-lexicographic order, so Xcode_16.10.app < Xcode_16.9.app (character '1' < '9'). When multiple Xcodes exist in the same tier, the loop overwrites on each iteration and the last-sorted one wins — meaning Xcode_16.9.app would be chosen over Xcode_16.10.app even though 16.10 is newer. Apple hasn't shipped Xcode 16.10 yet, but Xcode 26 could reach that threshold quickly given its beta cadence, and the same flaw applies to the 26+ tier. A sort -V (version sort, available on macOS via GNU coreutils or via sort -t. -k1,1n -k2,2n) or an explicit xcrun --find xcodebuild pass would give a reliable selection.

coderabbitai[bot]
coderabbitai Bot previously requested changes Jun 1, 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: 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 @.github/workflows/nightly.yml:
- Around line 153-183: The current selection loop relies on lexicographic sort
which can pick the wrong Xcode; change the logic in the loop that reads app
paths to track and compare SDK versions numerically instead of relying on sort
order: keep a stored best SDK string/number for the APP slot
(APP_DEVELOPER_DIR/APP_SDK_MAJOR) and one for the HELPER slot
(HELPER_DEVELOPER_DIR/HELPER_SDK_MAJOR), parse sdk_ver (and major/minor as
needed) and update the corresponding slot only when the newly found sdk_ver is
strictly greater than the stored one (for APP pick only sdk_ver >=26, for HELPER
pick only sdk_ver <26), or alternatively replace the pipeline sort with
version-aware sort (sort -V) before the loop; ensure the rest of the script
continues to export APP_DEVELOPER_DIR/HELPER_DEVELOPER_DIR and their SDK majors
as before.
- Around line 252-263: Remove the explicit pre-existence check for DEST and
instead ensure the parent directory exists before installing the helper: drop
the `[ ! -e "$DEST" ]` guard in the GitHub Actions step, call mkdir -p
"$(dirname "$DEST")" to create the parent bin directory, then run install -m 755
/tmp/cmux-ghostty-helper-universal "$DEST" and report architectures (retain the
lipo check); reference the DEST variable, the install invocation, and
scripts/build-ghostty-cli-helper.sh which already creates the stub when
CMUX_SKIP_ZIG_BUILD=1.
🪄 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: 0f227e82-71ee-4b62-968a-75c4cf9e4838

📥 Commits

Reviewing files that changed from the base of the PR and between 280e0b4 and 1174349.

📒 Files selected for processing (1)
  • .github/workflows/nightly.yml

Comment thread .github/workflows/nightly.yml
Comment thread .github/workflows/nightly.yml

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 1 file

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread .github/workflows/nightly.yml Outdated
- Select Xcode by numeric SDK rank instead of lexicographic sort, so
  26.10 ranks above 26.3 and the newest SDK in each slot (>=26 for the
  app, <26 for the zig helper) is chosen deterministically.
- Inject step now mkdir -p's the bin directory and installs, instead of
  erroring when the slot is absent; the build phase still writes the stub
  when CMUX_SKIP_ZIG_BUILD=1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@azooz2003-bit
azooz2003-bit dismissed coderabbitai[bot]’s stale review June 1, 2026 02:17

Dismissing per maintainer instruction; both comments addressed in 6ed048f.

@azooz2003-bit
azooz2003-bit merged commit 4fdaa0a into main Jun 1, 2026
18 of 20 checks passed
@azooz2003-bit
azooz2003-bit deleted the feat-nightly-macos26-liquid-glass branch June 1, 2026 02:17
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…

This branch was successfully deployed

1 active deployment
Preview – cmux — 6ed048f0 Deployed Jun 1, 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