Repository navigation
c11mux fork: harden release pipeline on Stage-11-Agentics infra - #1
Merged
Merged
Conversation
Attestations require GitHub Team/Enterprise billing on private repos. The feature isn't available on Stage-11-Agentics' current plan, so the step fails and skips the Upload release asset step — blocking DMG publication and the downstream Homebrew cask update. Remove the step from release.yml and nightly.yml and drop the now-unused attestations / id-token permissions. Easy to restore if we upgrade the GitHub plan later; this is internal tooling, not public distribution. Also bump MARKETING_VERSION to 0.62.5 / CURRENT_PROJECT_VERSION to 82.
macos-15-large is x86_64 Intel, not Apple Silicon. The zig install script hardcoded zig-aarch64-macos tarball, producing "Bad CPU type in executable" on Intel runners (discovered on v0.62.6 release run). Two fixes: 1. Detect runner arch via uname -m and pick the matching zig tarball (aarch64 or x86_64). Applied to all 7 workflows that install zig. Portable now regardless of runner arch. 2. Swap macos-15-large -> macos-15-xlarge across release/nightly/ci. -xlarge is the Apple Silicon 12-core variant. Priced higher (~$0.16/min) but matches our target hardware and is faster for ARM64 Xcode builds. Bump MARKETING_VERSION to 0.62.7 / CURRENT_PROJECT_VERSION to 84.
- Introduce docs/c11mux-module-2-metadata-spec.md: the canonical
per-surface JSON metadata transport. Defines the surface.{set,get,clear}_metadata
socket methods (new primitives, not yet implemented), canonical-key
table (role/status/task/model/progress plus terminal_type, title,
description), the metadata_sources sidecar and precedence chain
(explicit > declare > osc > heuristic), 64 KiB cap, error codes,
and the test surface agents must satisfy.
- Charter: seven modules -> eight. Add Module 8 (cmux tree overhaul)
for spatial layout. Normalize socket method names from pane.*-metadata
to surface.*_metadata. Align declaration CLI with sugar-over-metadata
approach (cmux set-agent, no new socket method).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Upstream manaflow-ai/cmux uses warp-macos-15-arm64-6x (WarpBuild paid runner). For the Stage-11-Agentics/c11mux fork, the equivalent paid runner is macos-15-xlarge. Update the guard so the fork-guard check remains enforced but against the right runner name.
BenevolentFutures
added a commit
that referenced
this pull request
Apr 18, 2026
Re-targets the gitlink from the pane-chrome-toolbar feature branch SHA to the new main SHA (ee7c0fd) now that bonsplit PR #1 is merged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
BenevolentFutures
added a commit
that referenced
this pull request
Apr 18, 2026
…rk (#16) * panels: markdown supports unbound filePath with empty-state UI MarkdownPanel's filePath is now Optional. When nil, the panel renders an empty-state view with a dashed drop zone, an "Open Markdown File" button backed by NSOpenPanel, and light Stage-11 copy. Dropping a file URL or picking one via the open panel calls bindFilePath, which loads content and starts the DispatchSource file watcher. SessionMarkdownPanelSnapshot.filePath is also Optional so unbound panels survive restart; they re-hydrate in the empty state rather than being dropped entirely. Motivation: the upcoming panel toolbar's Markdown button creates panels without a pre-selected file, and users pick inside the panel. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * workspace: route markdown and newTab toolbar buttons Bonsplit's splitButtons HStack now emits two new kinds: - "markdown" -> newMarkdownSurface(inPane:) with nil filePath, opening the panel in its empty state. - "newTab" -> createNewTabOfFocusedKind, which inspects the pane's currently-selected panel and creates another of the same kind (terminal/browser/markdown), falling back to terminal when the pane has no selection. newMarkdownSplit and newMarkdownSurface now accept an Optional filePath so the delegate can pass nil through. Session restore routes through the same path, so an unbound panel rehydrates to its empty state. currentSplitButtonTooltips adds localized strings for the Markdown and NewTab buttons; other tooltip strings are moved from inline English literals to the existing workspace.tooltip.* keys. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * localize: toolbar tooltip + markdown empty-state strings (en, ja) New keys: - workspace.tooltip.newMarkdown / workspace.tooltip.newTab - markdown.empty.title / subtitle / openButton / openPrompt / openMessage / spikePropaganda - markdown.untitled English + Japanese only in this PR to match the existing minimum for recently-added markdown.* keys. Other supported languages can follow in a separate localization pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * vendor: bump bonsplit for pane toolbar buttons Picks up the two bonsplit commits that add the Markdown and NewTab buttons with their group separator. The bonsplit branch still needs to be pushed to manaflow-ai/bonsplit before this parent-repo commit is reachable from other clones. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * submodule: retarget bonsplit to Stage-11-Agentics fork C11 MUX now consumes bonsplit from Stage-11-Agentics/bonsplit (private fork) instead of manaflow-ai/bonsplit. The fork's main currently tracks manaflow's main; divergence starts with the pane-chrome-toolbar branch. Rationale: c11mux-specific UI changes (tab-bar toolbar buttons, etc.) live in bonsplit, and we don't want every extension to require an upstream PR dance. The manaflow remote stays available as 'upstream' inside the submodule for cherry-picking their fixes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * vendor: bump bonsplit for toolbar hover + polish Picks up: - per-button hover highlight - taller group separator (18pt) between surface-type and layout groups - simpler doc.text icon for the Markdown button Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * vendor: bump bonsplit to squash-merged main Re-targets the gitlink from the pane-chrome-toolbar feature branch SHA to the new main SHA (ee7c0fd) now that bonsplit PR #1 is merged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
BenevolentFutures
added a commit
that referenced
this pull request
Apr 18, 2026
…ocker #1) Three failing tests pin the invariant that the timeout branch of v2PaneConfirm reads holder.value instead of hard-coding "dismissed". Tests reference TerminalController.v2PaneConfirmResolveOutcomeForTesting which does not yet exist — CI will fail to compile without the fix commit that introduces the helper. 1. testV2PaneConfirmTimeoutRaceHonorsLateCompletion: simulates the race window where `onTimeout` runs after a late completion populated the holder with .confirmed. Expected: response "ok". 2. testV2PaneConfirmTimeoutNonRacyReturnsDismissed: non-racy path. Holder stays .dismissed, response "dismissed". Preserves today's behavior for the common case. 3. testV2PaneConfirmSuccessBranchDoesNotFireTimeout: signal arrives first; timeout branch must not run. Fix lands in next commit.
BenevolentFutures
added a commit
that referenced
this pull request
Apr 18, 2026
…cker #1) Factor the post-wait logic into a testable static helper `v2PaneConfirmResolveOutcomeForTesting(wait:onTimeout:readOutcome:)` and make `v2PaneConfirm` route through it. The helper runs the supplied wait, fires onTimeout if it returned .timedOut, then ALWAYS reads the outcome via the accessor — no more hard-coded "dismissed" in the timeout branch. This closes the race Claude's critical review flagged: 1. wait(timeout:) returns .timedOut on the socket thread. 2. Before the timeout branch's v2MainSync runs cancelInteraction, the user clicks Confirm. Main fires the completion: holder.value = .confirmed; semaphore.signal() (no-op). 3. Socket thread's v2MainSync now runs; cancelInteraction finds no active interaction and returns false. 4. Previously: return .ok(["result": "dismissed"]) — silently discards the user's real answer while the .confirmed side-effect has already run. Protocol-level wrong-answer bug. 5. Now: read holder.value — if a late completion populated it, return "ok" (or "cancel"); otherwise, return "dismissed". Behavior in the non-racy path is unchanged. Also updated the v2PaneConfirm docstring to list `invalid_context` and the flag-disabled `unavailable` branch (Trident nit §5). The three failing tests from the previous commit (T-B1) now compile and pass. Fixes Blocker #1.
BenevolentFutures
added a commit
that referenced
this pull request
Apr 18, 2026
…mportant #1) Two failing tests pin the multi-window dispatcher's routing. Both reference AppDelegate.debugResolveActiveTabManagerForShortcut which does not yet exist — CI fails to compile without the paired fix. 1. testShortcutActiveTabManagerWalksSheetParentToSecondaryWindow: Creates two main windows, stales app.tabManager to primary, attaches a sheet over secondary, and asserts the resolver walks sheetParent to land on secondary's TabManager instead of the primary fallback. 2. testShortcutActiveTabManagerReturnsNilForNonTerminalKeyWindow: Uses a floating NSPanel as keyWindow and asserts the resolver returns nil — the previous fallback to self.tabManager is the exact anti-pattern all three Trident critical reviewers flagged. Fix lands in the next commit.
BenevolentFutures
added a commit
that referenced
this pull request
Apr 18, 2026
…y (Important #1) Replace the inline IIFE at handleCustomShortcut:9046-9052 with a dedicated resolver `resolveActiveTabManagerForShortcut(event:keyWindow:)`. Resolution order: 1. Event-window routing via the existing `mainWindowContext(forShortcutEvent:)` helper (Codex-flagged as already built for exactly this pattern). 2. SheetParent walk on keyWindow — covers "sheet over secondary window" which was the canonical sheet-over-secondary repro. 3. Return nil on miss. Drop the old `self.tabManager` fallback — that fallback silently routed Cmd+D to the primary window's TabManager whenever keyWindow was non-terminal (command palette, inspector, sheet on secondary, floating NSPanel). All three Trident critical-lens reviewers flagged it as an anti-pattern; Gemini rated it Blocker. The `debugResolveActiveTabManagerForShortcut` hook lets tests pass a keyWindow directly, since unit-test focus behavior (NSApp.keyWindow reflecting a programmatic beginSheet) is runner-dependent. The two failing tests from the previous commit (T-I1) now compile and assert the invariant. Closes Important #1.
BenevolentFutures
added a commit
that referenced
this pull request
Apr 18, 2026
…mportant #1) Two failing tests pin the multi-window dispatcher's routing. Both reference AppDelegate.debugResolveActiveTabManagerForShortcut which does not yet exist — CI fails to compile without the paired fix. 1. testShortcutActiveTabManagerWalksSheetParentToSecondaryWindow: Creates two main windows, stales app.tabManager to primary, attaches a sheet over secondary, and asserts the resolver walks sheetParent to land on secondary's TabManager instead of the primary fallback. 2. testShortcutActiveTabManagerReturnsNilForNonTerminalKeyWindow: Uses a floating NSPanel as keyWindow and asserts the resolver returns nil — the previous fallback to self.tabManager is the exact anti-pattern all three Trident critical reviewers flagged. Fix lands in the next commit.
BenevolentFutures
added a commit
that referenced
this pull request
Apr 18, 2026
…y (Important #1) Replace the inline IIFE at handleCustomShortcut:9046-9052 with a dedicated resolver `resolveActiveTabManagerForShortcut(event:keyWindow:)`. Resolution order: 1. Event-window routing via the existing `mainWindowContext(forShortcutEvent:)` helper (Codex-flagged as already built for exactly this pattern). 2. SheetParent walk on keyWindow — covers "sheet over secondary window" which was the canonical sheet-over-secondary repro. 3. Return nil on miss. Drop the old `self.tabManager` fallback — that fallback silently routed Cmd+D to the primary window's TabManager whenever keyWindow was non-terminal (command palette, inspector, sheet on secondary, floating NSPanel). All three Trident critical-lens reviewers flagged it as an anti-pattern; Gemini rated it Blocker. The `debugResolveActiveTabManagerForShortcut` hook lets tests pass a keyWindow directly, since unit-test focus behavior (NSApp.keyWindow reflecting a programmatic beginSheet) is runner-dependent. The two failing tests from the previous commit (T-I1) now compile and assert the invariant. Closes Important #1.
BenevolentFutures
added a commit
that referenced
this pull request
Apr 18, 2026
* m10/fixes: T-I1 — regression tests for shortcut dispatcher routing (Important #1) Two failing tests pin the multi-window dispatcher's routing. Both reference AppDelegate.debugResolveActiveTabManagerForShortcut which does not yet exist — CI fails to compile without the paired fix. 1. testShortcutActiveTabManagerWalksSheetParentToSecondaryWindow: Creates two main windows, stales app.tabManager to primary, attaches a sheet over secondary, and asserts the resolver walks sheetParent to land on secondary's TabManager instead of the primary fallback. 2. testShortcutActiveTabManagerReturnsNilForNonTerminalKeyWindow: Uses a floating NSPanel as keyWindow and asserts the resolver returns nil — the previous fallback to self.tabManager is the exact anti-pattern all three Trident critical reviewers flagged. Fix lands in the next commit. * m10/fixes: R-I1 — dispatcher walks sheetParent, no fallback to primary (Important #1) Replace the inline IIFE at handleCustomShortcut:9046-9052 with a dedicated resolver `resolveActiveTabManagerForShortcut(event:keyWindow:)`. Resolution order: 1. Event-window routing via the existing `mainWindowContext(forShortcutEvent:)` helper (Codex-flagged as already built for exactly this pattern). 2. SheetParent walk on keyWindow — covers "sheet over secondary window" which was the canonical sheet-over-secondary repro. 3. Return nil on miss. Drop the old `self.tabManager` fallback — that fallback silently routed Cmd+D to the primary window's TabManager whenever keyWindow was non-terminal (command palette, inspector, sheet on secondary, floating NSPanel). All three Trident critical-lens reviewers flagged it as an anti-pattern; Gemini rated it Blocker. The `debugResolveActiveTabManagerForShortcut` hook lets tests pass a keyWindow directly, since unit-test focus behavior (NSApp.keyWindow reflecting a programmatic beginSheet) is runner-dependent. The two failing tests from the previous commit (T-I1) now compile and assert the invariant. Closes Important #1. * m10/fixes: T-I2 — regression test for hidden-tab dialog accept (Important #2) New test file cmuxTests/PaneInteractionAcceptTests.swift (wired into project.pbxproj alongside PaneInteractionRuntimeTests). Two tests pin the selected-tab preference in acceptActivePaneInteractionInKeyWorkspace: 1. testAcceptActiveRejectsDialogOnHiddenTab: creates a workspace with two tabs sharing one pane, dialog on the NON-selected tab, no dialog on the focused/selected tab. Current code walks `tabs(inPane:)` and accepts whichever active interaction comes first — silently resolving the hidden dialog. Test asserts accept returns false, the hidden dialog's completion does not fire, and the dialog remains active until the user surfaces the tab. 2. testAcceptActiveOnVisibleTabStillWorks: the happy path — dialog on the focused panel still accepts. Ensures the fix doesn't break the common case. The first test is expected to fail against current TabManager.swift. Fix lands in the next commit. * m10/fixes: R-I2 — prefer selectedTab in acceptActive fallback (Important #2) `bonsplitController.tabs(inPane:)` returns every tab in a pane, selected or not. The previous R6 tiebreaker iterated all tabs and accepted whichever first-in-order active interaction it found. A dialog anchored on a tab the user had focused — then switched away from — would be silently accepted by Cmd+D pressed on the visible tab. For destructive dialogs ("close without saving"), that's invisible data loss. Restrict the fallback to `selectedTab(inPane:)` — only the tab the user is currently looking at in each pane is eligible. The focusedPanelId fast-path is unchanged; only the fallback iteration tightens. The T-I2 test from the previous commit (testAcceptActiveRejectsDialog- OnHiddenTab) now passes. testAcceptActiveOnVisibleTabStillWorks pins that the happy path is preserved. Closes Important #2.
2 tasks
BenevolentFutures
added a commit
that referenced
this pull request
Apr 24, 2026
- Sources/Mailbox/MailboxEnvelope.swift: struct MailboxEnvelope { raw: [String: Any] } backed by JSONSerialization so encoding uses .sortedKeys for byte-parity and nil-valued fields are omitted naturally. validate(data:) checks unknown keys first, then required fields, then per-field type/shape, then cross-field rules (at-least-one-of to/topic, body-vs-body_ref). build(from:to:topic:body:...) auto-fills version/id/ts and re-runs validate() so CLI and raw-file senders share one source of truth (drift rule #1).
- Error enum spans every rule exercised by the Step 1 fixtures: missingField, unknownTopLevelKey, wrongFieldType (including Bool-rejection via NSNumber objCType "c"), invalidULID, invalidTimestamp, bodyTooLarge, noRecipient, bodyAndBodyRefConflict, etc.
- c11Tests/MailboxEnvelopeValidationTests.swift: parameterised over all 5 valid + 8 invalid fixtures (located via #filePath → repo root → spec/fixtures/envelopes/), plus build() round-trip tests and a sorted-keys encoding test to pin the byte shape for the Step 12 parity test.
BenevolentFutures
added a commit
that referenced
this pull request
Apr 24, 2026
JSONSerialization defaults escape `/` as `\/`; Python's json.dumps does not. The parity test (P0 #1) will fail on body_ref payloads the moment it asserts real byte equality. Add .withoutEscapingSlashes to the two sites that serialize an envelope: - MailboxEnvelope.encode() — the byte form both CLI and raw-file senders must agree on. - MailboxEnvelope.build() re-validate path — matches the encode behavior for consistency even though it round-trips through the validator. Pinned with a new regression test (testEncodeDoesNotEscapeForwardSlashes) that asserts `/tmp/...` body_ref round-trips as literal slashes. Refs trident critical-synthesis item #1 (consensus risk: JSONSerialization slash escape), notes/trident-review-C11-13-pack-20260424-0403/.
BenevolentFutures
added a commit
that referenced
this pull request
Apr 24, 2026
Previous test normalized both inbox files through json.loads / json.dumps with sort_keys before comparing. That masks every axis drift-enforcement is meant to catch: key order, whitespace, escape policy, Unicode form. The design doc §3 rule #6 names this test the drift-enforcement lock — it was not actually locking. New shape: - Two isolated workspaces: one for the CLI sender path, one for the raw-file sender path. Isolating the outbox lets both paths pin the SAME envelope id + ts without colliding in _outbox or tripping the dispatcher's per-workspace recentlySeen cache. - Per iteration: pin a single ULID and a single RFC3339 ts, use them on both sender paths, and assert cli_inbox_bytes == raw_inbox_bytes directly. No json.loads, no re-encoding. - Topic-only is reclassified as a CLI-rejection case (see P0 #3 in the same rework): the test asserts non-zero exit instead of byte parity, since Stage 2 does not deliver topic-only envelopes. The lock now catches anything paired with P0 #2: the slash-escape fix is required for body-ref parity to pass the new assertion. Local socket has been unavailable since ~03:07 UTC per the rework prompt; the "perturb encoder to prove the lock fails" verification will happen in CI once P0 #8 lands the push-trigger workflow. Refs trident critical-synthesis item #1 (consensus, all three reviewers), notes/trident-review-C11-13-pack-20260424-0403/.
BenevolentFutures
added a commit
that referenced
this pull request
Apr 24, 2026
* C11-13: Step 1 — Schema + fixtures
- Lock spec/mailbox-envelope.v1.schema.json as Draft 2020-12 JSON Schema; integer version=1, ULID pattern for id/in_reply_to, RFC3339 Z-suffixed ts, maxLength 4096 body, anyOf to/topic, if body_ref then body="", additionalProperties:false.
- Land 5 valid fixtures (minimal, topic-only, with-ext, reply-chain, body-ref) and 8 invalid fixtures (missing-version, wrong-version-type, no-recipient, unknown-top-level-key, oversize-body, body-and-body-ref, bad-ts, bad-ulid) under spec/fixtures/envelopes/.
- Verified locally with jsonschema Draft202012Validator: all 5 valid pass, all 8 invalid reject. Schema passes meta-schema. These fixtures drive the Step 5 Swift validator and the Step 12 parity test.
* C11-13: Step 2 — MailboxLayout + path helpers
- Sources/Mailbox/MailboxLayout.swift: pure path builders for <state>/workspaces/<ws>/mailboxes/{_outbox,_processing,_rejected,blobs,<surface>} + _dispatch.log, plus surface-name validation (empty/slash/null/..// leading-dot/>64 bytes).
- c11Tests/MailboxLayoutTests.swift: path shape tests, filename tests, and full coverage of the surface-name rejection reasons (incl. unicode + space acceptance at the byte cap).
- GhosttyTabs.xcodeproj: register both files in PBXBuildFile, PBXFileReference, the Sources and c11Tests groups, and both Sources build phases. No runtime I/O in this step.
* C11-13: Step 4 — Atomic write + ULID
- Sources/Mailbox/MailboxIO.swift: atomicWrite(data:, to:) — writes a dot-prefixed .tmp sibling, then FileManager.moveItem (POSIX rename) onto the target. Parent-dir guard; best-effort temp cleanup on rename failure.
- Sources/Mailbox/MailboxULID.swift: Crockford base32 ULID (26 chars), 48-bit ms timestamp + 80-bit randomness (SecRandom), monotonic-within-ms via NSLock-guarded counter increment.
- c11Tests: happy path + missing-parent + crash-mid-write coverage for IO; length + charset + 10k sort-stability + 10k uniqueness + all-ones encoding + carry-increment coverage for ULID.
- Step 3 intentionally skipped (descoped to C11-14 per plan — no shell-env-var changes in this ticket).
* C11-13: Step 5 — Envelope validator
- Sources/Mailbox/MailboxEnvelope.swift: struct MailboxEnvelope { raw: [String: Any] } backed by JSONSerialization so encoding uses .sortedKeys for byte-parity and nil-valued fields are omitted naturally. validate(data:) checks unknown keys first, then required fields, then per-field type/shape, then cross-field rules (at-least-one-of to/topic, body-vs-body_ref). build(from:to:topic:body:...) auto-fills version/id/ts and re-runs validate() so CLI and raw-file senders share one source of truth (drift rule #1).
- Error enum spans every rule exercised by the Step 1 fixtures: missingField, unknownTopLevelKey, wrongFieldType (including Bool-rejection via NSNumber objCType "c"), invalidULID, invalidTimestamp, bodyTooLarge, noRecipient, bodyAndBodyRefConflict, etc.
- c11Tests/MailboxEnvelopeValidationTests.swift: parameterised over all 5 valid + 8 invalid fixtures (located via #filePath → repo root → spec/fixtures/envelopes/), plus build() round-trip tests and a sorted-keys encoding test to pin the byte shape for the Step 12 parity test.
* C11-13: Step 6 — Dispatch log NDJSON
- Sources/Mailbox/MailboxDispatchLog.swift: append-only NDJSON writer serialised through a dedicated com.stage11.c11.mailbox.log utility queue. Events match design doc §6: received, resolved, copied, handler, rejected, cleaned, replayed, gc. Keys sort-encoded, nil fields omitted.
- append(event:) is fire-and-forget for the dispatcher hot path; flush() blocks for tests and shutdown. Open-on-demand FileHandle seeks to end so prior content survives dispatcher restarts.
- c11Tests: single-line schema check, every-variant round-trip, prior-content-preservation across two handles, and a 16-producer concurrent append that must produce 16 unique ids.
* C11-13: Step 7 — Surface-name resolver
- Sources/Mailbox/MailboxSurfaceResolver.swift: workspace-scoped resolver that reads from SurfaceMetadataStore on every call (no caching — tens-per-minute dispatch volume makes re-reads cheaper than observer plumbing per plan §7). Live-surface enumeration injected as a closure so the dispatcher can bind to Workspace.orderedPanels while tests inject a fixed list.
- SurfaceMetadata view helper splits comma-separated mailbox.delivery / mailbox.subscribe / mailbox.retention_days per alignment doc §3 (v1 values are strings).
- c11Tests: title-match lookup (incl. duplicate tolerance), unnamed-surface filtering, mailbox.* key enumeration, and separation from non-mailbox keys. Each test uses a fresh workspace UUID against the shared SurfaceMetadataStore.
* C11-13: Step 8 — fsevent watcher
- Sources/Mailbox/MailboxOutboxWatcher.swift: FSEventStreamRef + 5s DispatchSourceTimer sweep (per plan + design §1 review item #4). Modelled on ThemeDirectoryWatcher with two mailbox-specific tweaks — filter to `.msg` files (ignores dot-tmp and any other extension) and a tracked snapshot so the same envelope isn't reported twice across sweeps.
- triggerImmediateScan() gives the dispatcher a deterministic startup scan and lets tests bypass debounce.
- c11Tests: periodic-sweep detection, .tmp/.txt filtering, no-double-report across sweeps, and manual-trigger synchronicity. Fast intervals (50 ms debounce, 100 ms poll) keep CI under a second per case.
* C11-13: Step 9 — Dispatcher orchestrator
- Sources/Mailbox/MailboxDispatcher.swift: per-workspace orchestrator on a dedicated com.stage11.c11.mailbox.dispatcher utility queue. Per-envelope dispatch loop: dedupe (1024-entry recently-seen set) → atomic move outbox→processing → validate → resolve recipients (`to` only; topic fan-out is Stage 3) → atomic-copy into each inbox → invoke handlers with 2 s safety-net wait → cleanup. Bad envelopes quarantine to `_rejected/<id>.{msg,err}` with a `rejected` log event.
- Handler registry is a string→async-function map; registerHandler(name:_:) wires it. Stage 2 registers a silent no-op so topic/silent flows behave before Step 10 lands stdin.
- Sources/Workspace.swift: new mailboxDispatcher property + startMailboxDispatcher() method + deinit stop. Live-surface enumeration reads `panels.keys` from main (bounded hop, low volume).
- Sources/TabManager.swift: startMailboxDispatcher() invoked after each Workspace construction site (addWorkspace, restore path, fallback).
- c11Tests/MailboxDispatcherTests.swift: end-to-end dispatch (outbox drained, inbox byte-matches sender encoding, log sequence is received/resolved/copied/handler/cleaned), rejection quarantine with .err sidecar, empty-recipient resolution for unknown `to`, dedupe via second dispatch of same id.
* C11-13: Step 10 — stdin handler
- Sources/Mailbox/MailboxHandler.swift: formal protocol per plan — deliver(envelope:to:surfaceName:) async → HandlerInvocationResult. asDispatcherFunction() adapts to the dispatcher's closure registry without duplicating call sites.
- Sources/Mailbox/StdinMailboxHandler.swift: formats a <c11-msg> block with XML-escaped attributes + body (defends against forged closing tags), hops to @mainactor, writes via an injected writer closure. Bounded by a 500 ms Duration-based timeout using Swift task-group race (sleep task vs write task).
- Sources/Workspace.swift: startMailboxDispatcher() now wires a StdinMailboxHandler whose writer resolves TerminalPanel via panels[surfaceId] and calls sendText(). silent stays as the no-op it was in Step 9.
- c11Tests/StdinHandlerFormattingTests.swift: exact byte-shape for a canonical envelope, attribute and body XML-escaping (<, >, &, "), forged-close defence (body </c11-msg> must be escaped), optional attribute inclusion/omission, writer-outcome mapping (ok/closed/timeout via an injected slow writer).
* C11-13: Step 11 — CLI commands (c11 mailbox send/recv/trace/tail/helpers)
- CLI/c11.swift: new extension on CMUXCLI with `case "mailbox":` dispatching to send/recv/trace/tail/outbox-dir/inbox-dir/surface-name/new-id subcommands. Send stays under 30 lines of logic per drift rule #7 — build() + atomicWrite() do the work.
- Caller resolution: CMUX_WORKSPACE_ID env (mirrored from C11_WORKSPACE_ID) → workspace UUID; one `surface.get_metadata` socket call → `title`. No new env vars added (D1 resolution).
- Helper subcommands (outbox-dir, inbox-dir, surface-name, new-id) let raw-bash writers avoid C11_* env dependencies entirely.
- Sources/Mailbox/MailboxLayout.swift: defaultStateURL() inlined — dropped SocketControlSettings dependency so CLI target can compile without the main app's socket classes. stateDirectoryName ("c11mux") is the durable constant.
- GhosttyTabs.xcodeproj: added the 4 pure-Foundation Mailbox files (Layout, IO, ULID, Envelope) to the CLI target's Sources build phase — no new file refs, just new PBXBuildFile entries reusing the existing refs.
- Resources/bin/c11-mailbox-send-bash-example.sh: four-line raw-file-write example using the helper subcommands. Referenced by the skill update in Step 14.
- docs/c11-messaging-primitive-design.md §3: rewrote the raw-bash send/recv snippets to use `c11 mailbox outbox-dir|inbox-dir|surface-name|new-id` per the plan. The old $C11_STATE/$C11_WORKSPACE_ID/$C11_SURFACE_NAME references were never implemented and are out of scope (C11-14).
* C11-13: Step 12 — Parity test (tests_v2/test_mailbox_parity.py)
- tests_v2/test_mailbox_parity.py: 8-payload drift-lock — for each variation, a CLI send and a raw-file send produce byte-identical envelope bytes in the receiver's inbox (after stripping id/ts differences). Covers minimal, urgent, topic+to, reply-chain, content-type, body-ref, ttl, topic-only.
- Uses the tests_v2/cmux.py Python client to spawn a fresh workspace + two surfaces (sender, receiver with mailbox.delivery=silent) so the real dispatcher runs the envelopes end-to-end and nothing is hand-simulated.
- Raw-file sender pipes json.dumps(..., sort_keys=True, separators=(",",":"), ensure_ascii=False) — the byte-for-byte equivalent of the Swift encoder's JSONSerialization.sortedKeys output. This mirror is what makes "same dict → same bytes" hold.
- Manual run today (`CMUX_SOCKET=<path> python3 tests_v2/test_mailbox_parity.py`) since tests_v2/ isn't wired into test-e2e.yml's xcodebuild flow — known CI gap per plan risks note; follow-up can add a push trigger.
* C11-13: Step 13 — Stale-tmp GC sweep
- Sources/Mailbox/MailboxDispatcher.swift: 60 s DispatchSourceTimer (on the dispatcher queue) runs runGCSweep() which removes dot-prefixed `.tmp` siblings in `_outbox/` older than 5 min. Writes a single `gc` NDJSON line when anything is removed. start() owns the timer; stop() cancels it.
- Scope guards: only dot-prefixed `.tmp` files authored by MailboxIO.atomicWrite (or the shipped raw-bash example). Anything else in the outbox is out of scope.
- runGCSweep(now:) accepts an injected clock so the test suite fast-forwards deterministically instead of depending on wall-clock age.
- c11Tests/MailboxDispatcherGCTests.swift: removes-stale / keeps-fresh / ignores-msg / ignores-non-dotted-tmp / mocked-clock threshold / dispatch-log gc event / no-event-when-nothing-removed.
* C11-13: Step 14 — Skill section (mailbox)
- skills/c11/SKILL.md: new "Inter-agent messaging (mailbox)" section above References. Teaches the grammar (framed <c11-msg> block, XML-escape injection defense), the default receive protocol (finish tool call → treat as system message → dedupe by id → acknowledge inline), both send paths (CLI + raw-bash) side-by-side, the receive CLI, and the observability commands. Points at the bash example and parity test.
- "Stage 2 limitations" block explicitly flags what's deferred (topic fan-out, watch handler, configure CLI, body_ref read-through, per-surface caps, _processing/ crash recovery) so future agents don't lean on primitives that don't ship yet.
- Honors CLAUDE.md's skill-contract rule: every Stage 2 CLI flag exposed in Step 11 is documented here.
* C11-13 rework: P0 #2 — slash escaping in envelope encoder
JSONSerialization defaults escape `/` as `\/`; Python's json.dumps does
not. The parity test (P0 #1) will fail on body_ref payloads the moment
it asserts real byte equality. Add .withoutEscapingSlashes to the two
sites that serialize an envelope:
- MailboxEnvelope.encode() — the byte form both CLI and raw-file
senders must agree on.
- MailboxEnvelope.build() re-validate path — matches the encode
behavior for consistency even though it round-trips through the
validator.
Pinned with a new regression test
(testEncodeDoesNotEscapeForwardSlashes) that asserts `/tmp/...` body_ref
round-trips as literal slashes.
Refs trident critical-synthesis item #1 (consensus risk: JSONSerialization
slash escape), notes/trident-review-C11-13-pack-20260424-0403/.
* C11-13 rework: P0 #1 — real byte parity in mailbox parity test
Previous test normalized both inbox files through json.loads / json.dumps
with sort_keys before comparing. That masks every axis drift-enforcement
is meant to catch: key order, whitespace, escape policy, Unicode form.
The design doc §3 rule #6 names this test the drift-enforcement lock —
it was not actually locking.
New shape:
- Two isolated workspaces: one for the CLI sender path, one for the
raw-file sender path. Isolating the outbox lets both paths pin the
SAME envelope id + ts without colliding in _outbox or tripping the
dispatcher's per-workspace recentlySeen cache.
- Per iteration: pin a single ULID and a single RFC3339 ts, use them
on both sender paths, and assert cli_inbox_bytes == raw_inbox_bytes
directly. No json.loads, no re-encoding.
- Topic-only is reclassified as a CLI-rejection case (see P0 #3 in
the same rework): the test asserts non-zero exit instead of byte
parity, since Stage 2 does not deliver topic-only envelopes.
The lock now catches anything paired with P0 #2: the slash-escape fix
is required for body-ref parity to pass the new assertion.
Local socket has been unavailable since ~03:07 UTC per the rework
prompt; the "perturb encoder to prove the lock fails" verification will
happen in CI once P0 #8 lands the push-trigger workflow.
Refs trident critical-synthesis item #1 (consensus, all three reviewers),
notes/trident-review-C11-13-pack-20260424-0403/.
* C11-13 rework: P0 #3 — reject topic-only sends at CLI
Stage 2 does not implement topic subscribe / fan-out. Previously the
dispatcher resolved topic-only envelopes to [] recipients, the envelope
was cleaned, and the sender got a ULID on success — a silent drop. The
skill even framed this as benign ("resolve to empty recipient list"),
teaching agents to write senders that lose messages.
Fix at the CLI boundary (lean option, per review P0 #3): when
`--topic` is set without `--to`, `c11 mailbox send` exits non-zero with
`topics_not_implemented` guidance. The dispatcher still sees `topic`
as valid when paired with `to` (the topic-and-to parity case) because
that path does deliver.
Skill edits:
- Stage 2 limitations: document topic-only as rejected, not "resolved
to empty recipient list".
- Sending section: add the "Stage 2 requires --to" note so agents see
the rule before copying the example.
Refs trident critical-synthesis consensus risk #2, notes/trident-review-
C11-13-pack-20260424-0403/.
* C11-13 rework: P0 #4 — pre-existing _outbox/*.msg files dispatch on start
MailboxOutboxWatcher.start() was snapshotting the current directory
contents into knownFiles, then reporting only current.subtracting(
knownFiles) on each scan. Any envelope sitting in _outbox/ at startup
was pre-marked "already handled" and never surfaced to the dispatcher
— a durability-contract violation on the happy path (not even a crash
scenario). Codex flagged this; Claude's #3 and Gemini's crash-recovery
observation are adjacent.
Fix:
- MailboxOutboxWatcher.start() initializes knownFiles = [] so the first
scan reports every pre-existing .msg. Idempotency stays with the
atomic move-to-_processing/ step in the dispatcher.
- MailboxDispatcher.start() calls watcher.triggerImmediateScan() right
after the watcher's own start() so pre-existing envelopes surface
immediately instead of waiting for the 5 s sweep. A TODO comment
points at the deferred _processing/ crash-recovery (Stage 3).
Regression pinned by testPreExistingEnvelopesDispatchOnStart: writes
an envelope before start(), then asserts the handler fires once start
+ triggerImmediateScan run.
Refs trident critical-synthesis consensus risk #3 (Codex half),
notes/trident-review-C11-13-pack-20260424-0403/.
* C11-13 rework: P0 #5 — honest main-thread bound (docs + slow-writer test)
The 500 ms timeout in StdinMailboxHandler.deliver and the 2 s
semaphore wait in MailboxDispatcher.invokeHandlerSynchronously were
documented as "bounds on main-thread work". They are reporting
bounds, not runtime bounds: MainActor.run is synchronous and Swift
task cancellation is cooperative, so a slow writer closure can keep
main busy even after the dispatcher has logged .timeout and moved
on. Two reviewers independently flagged this as overclaimed hot-path
compliance.
This cycle takes path (b) from the plan — tell the truth now, defer
genuine async-cancellable PTY writes to a follow-up ticket:
- StdinMailboxHandler.swift: header comment is rewritten to call out
the reporting-bound semantics explicitly. The production writer is
microseconds in practice, but that is a property of the closure,
not of this deadline.
- MailboxDispatcher.swift: invokeHandlerSynchronously comment
clarifies the 2 s ceiling is about dispatcher-liveness, not
handler-thread occupancy.
- Plan "Risks and unknowns": Typing-latency section rewritten with
the honest claim — dispatcher liveness bounded, main-thread
occupancy not.
- c11Tests/StdinHandlerFormattingTests.swift: new test
testDeliverReturnsTimeoutEvenWhenWriterBlocksMultipleSeconds
simulates a 5 s writer block with a 100 ms timeout and asserts
deliver() returns inside ~2 s (with slack) and reports .timeout.
This pins the *reporting* behavior; the existing 600 ms/50 ms test
covers the same shape at shorter durations.
Refs trident critical-synthesis consensus risk #3,
notes/trident-review-C11-13-pack-20260424-0403/.
* C11-13 rework: P0 #6 — delete unreachable EIO/EPIPE/closed variants
The EIO/EPIPE "graceful failure" contract in the design doc claimed the
stdin handler propagates real Ghostty/PTY write errors through the
dispatch log. Tracing the production writer (Workspace.swift ->
TerminalPanel.sendText -> GhosttyTerminalView.sendText) shows the call
returns .ok unconditionally; no path surfaces errno from the PTY side.
The outcome variants that modeled that contract were dead:
- WriteOutcome.eio, WriteOutcome.closed — never returned by the
production writer. Deleted.
- HandlerOutcome.epipe — never written to the log from any code path.
Deleted.
Kept, because they *are* produced:
- HandlerOutcome.eio — emitted from MailboxDispatcher when the inbox
write fails or the handler name is unknown. Not a PTY errno; the
enum doc comment now makes that distinction explicit.
- HandlerOutcome.closed — emitted by the stdin handler when the
recipient surface lookup returns surfaceNotFound /
surfaceNotTerminal. Surface gone, not socket gone.
Claims narrowed:
- docs/c11-messaging-primitive-design.md §Handlers (row for `stdin`)
and §8 ("inbox is the queue") now say Stage 2 does NOT claim real
PTY errno propagation; that is Stage 3 scope alongside the
async-cancellable writer.
- StdinMailboxHandler.swift Writer typealias has a doc block
explaining the narrow outcome set and pointing at the follow-up.
- Plan Step 10 HandlerOutcome enum listing now reflects the four
real variants.
Refs trident critical-synthesis consensus risk #5,
notes/trident-review-C11-13-pack-20260424-0403/.
* C11-13 rework: P0 #7 — scrub \$C11_* env-var examples from design doc §12
Step 11 in the original Stage 2 impl rewrote the raw-bash snippets in
the skill and the helper reference files to use CLI helpers
(c11 mailbox outbox-dir / surface-name / inbox-dir / new-id). §12 of
the design doc (the skill-teaches-grammar excerpt) was missed. Agents
following the embedded prompt would write snippets that reference
C11_STATE, C11_WORKSPACE_ID, and C11_SURFACE_NAME — env vars that do
not exist in the c11 shell environment, so the snippets would fail.
Edits (both inside the §12 quoted block):
- "Direct file write" paragraph: resolve paths via \$(c11 mailbox
outbox-dir) / \$(c11 mailbox surface-name) / \$(c11 mailbox new-id);
also drops the "at least one of to/topic" line in favor of "plus to"
since topic-only sends are rejected in Stage 2 (see P0 #3).
- Receiving section: the ls fallback now uses \$(c11 mailbox inbox-dir).
Remaining \$C11_STATE references in §1 / §3 / §8.1 etc. are
descriptive prose about the filesystem layout, not copy-paste-ready
snippets — left alone so the layout can still be named when needed.
Refs trident critical-synthesis consensus risk #4,
notes/trident-review-C11-13-pack-20260424-0403/.
* C11-13 rework: P0 #8 — CI push-trigger for mailbox parity test
Stage 2 called the parity test "the drift-enforcement lock" but
shipped with no automatic run on the merge path — test-e2e.yml is
workflow_dispatch only. All three reviewers flagged this as a
must-fix.
New workflow .github/workflows/mailbox-parity.yml fires on push and
pull_request when any of:
- tests_v2/test_mailbox_parity.py
- Sources/Mailbox/**
- CLI/c11.swift
- spec/**
- c11Tests/Mailbox*.swift
- c11Tests/StdinHandler*.swift
- .github/workflows/mailbox-parity.yml
changes. Two jobs:
1. python-syntax (ubuntu, <5 min): py_compile the parity test so it
never rots while the full end-to-end run remains workflow_dispatch
only.
2. mailbox-unit (macos-15-xlarge): xcodebuild test against the
c11-unit scheme (safe per CLAUDE.md — no app launch), filtered via
-only-testing to the Mailbox* + StdinHandler* test classes. These
tests assert the byte-level invariants the Python parity test
depends on — testEncodeDoesNotEscapeForwardSlashes, the envelope
validation fixtures, the watcher's pre-existing-envelope regression
(P0 #4), the handler reporting-bound proof (P0 #5), and so on.
Fork-PR guard reuses the ci.yml pattern so untrusted forks do not
spend paid-runner time.
Out of scope: running the full tests_v2/test_mailbox_parity.py
against a live socket. That requires launching c11 on a virtual
display and the TCC / ffmpeg setup from test-e2e.yml, which is too
heavy for a push-path lock. Dedicated mailbox-parity end-to-end
wiring is a follow-up ticket (P1).
Refs trident critical-synthesis consensus risk #6,
notes/trident-review-C11-13-pack-20260424-0403/.
* C11-13 rework: tighten at-least-once claim for deferred _processing/ recovery
The _processing/ crash-recovery sweep is deferred to Stage 3 (see
the plan task note "Out of scope (Stage 2)" line). Review cycle 1
correctly flagged that the at-least-once delivery claim is
load-bearing on this recovery — so rather than pulling the work
into Stage 2, tighten the claim so callers know what they are
getting.
- docs/c11-messaging-primitive-design.md §5 and the top-of-file
bullet now distinguish "steady-state at-least-once" (held —
pre-existing _outbox files dispatch on start, P0 #4) from "full
at-least-once under dispatcher-crash-while-processing" (NOT held
in Stage 2; Stage 3 scope). Callers that care about the crash
window are pointed at reply-chain retries until Stage 3.
- skills/c11/SKILL.md Stage 2 limitations section: the combined
"No _processing/ crash recovery" bullet is split so the
at-least-once caveat is its own line, with the honest framing.
- Sources/Mailbox/MailboxDispatcher.swift start() header: explicit
TODO(stage-3) comment at the start site pointing at the sweep
that would close the gap.
Refs Review Cycle 1 explicitly-deferred item,
.lattice/plans/task_01KPYFX4PV4QQQYHCPE0R02GEZ.md:466-472.
* C11-13 rework: rename @testable import cmux → c11 across c11Tests
The c11Tests target shipped ~69 files carrying stale @testable import
cmux / @testable import cmux_DEV from a partial cmux→c11 rename. The
canImport(cmux_DEV) / canImport(cmux) guard pattern silently skipped
both arms because no such modules exist today (PRODUCT_MODULE_NAME is
c11 / c11_DEV / c11_cli). Tests resolved type references through Xcode
target membership rather than the broken @testable imports.
ci.yml only built the app scheme, so this rot stayed latent until P0 #8
added a test action via the Mailbox parity workflow. This commit makes
@testable actually pull symbols from the right module.
Mechanical sed:
canImport(cmux_DEV) → canImport(c11_DEV)
@testable import cmux_DEV → @testable import c11_DEV
canImport(cmux) → canImport(c11)
@testable import cmux → @testable import c11
Strings, comments, and cmux*-prefixed symbol references (which still
exist in Sources/ under those names) are intentionally left alone.
BenevolentFutures
added a commit
that referenced
this pull request
Apr 25, 2026
Three more concrete bugs called out by the review pack that I missed in the first follow-up commit. These are real round-trip / output bugs, not polish. - Empty snapshot_id for `.json` files: a file literally named `.json` produced an unreadable row with `snapshotId = ""` because `deletingPathExtension().lastPathComponent` collapses to the empty string. Fall back to the full filename (capped at 128 chars) so the row is identifiable in the plain-table column. Test added in WorkspaceSnapshotCaptureTests covers the degenerate filename. Sources/WorkspaceSnapshotStore.swift, c11Tests/WorkspaceSnapshotCaptureTests.swift. (Crit Gemini #3, Std Codex #5, Crit Codex nit on filename stem.) - `snapshot.list` JSON timestamp precision: the v2 handler encoded `WorkspaceSnapshotIndex.createdAt` with `.iso8601`, which drops subsecond precision. The store now writes fractional seconds via `workspaceSnapshotDateFormatter`, so the list response was silently truncating timestamps that the store had just written. Switched the encoder to a custom strategy that uses the shared formatter, matching the round-trip the rest of the store path already provides. Sources/TerminalController.swift. (Crit Codex nit on `v2SnapshotList`.) - Honest `readSummary` doc comment: the prior comment claimed nested `plan.layout` and `plan.surfaces[].metadata` "stay in the JSON blob untouched". `JSONSerialization.jsonObject` actually materialises the full object graph into NSDictionary/NSArray; the win is skipping the `WorkspaceApplyPlan` Codable decode, not avoiding the parse. Comment reworded to describe the real cost shape so future readers don't trust the false claim. Sources/WorkspaceSnapshotStore.swift. (Crit Gemini #1, Crit Codex nit on memory shape.) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
BenevolentFutures
added a commit
that referenced
this pull request
Apr 26, 2026
* C11-14: P9 P8 P10 trivial nits
- P8: single clock read in LiveWorkspaceSnapshotSource.capture so the ULID
time prefix in snapshotId and the envelope's createdAt share one tick.
- P9: drop the displayTitle fallback claim from WorkspaceSnapshotCapture doc
comment; the walker only reads panelCustomTitles[panelId] and accepts nil.
- P10: trim terminalType in AgentRestartRegistry.init(rows:) for symmetry
with the trim already in resolveCommand; avoids an asymmetric-trim footgun.
Tests: add ULID-prefix-decodes-to-clock-millis invariant in
WorkspaceSnapshotCaptureTests; add trim-on-insert check in
AgentRestartRegistryTests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* C11-14: P3 registry identity by name
AgentRestartRegistry carries closures and is not Equatable. Identity now
lives on a required `name: String` minted at construction (`"phase1"` for
the canonical singleton, test-chosen names for fixtures). ApplyOptions.==
compares restartRegistry by name: both-nil is equal, both-non-nil-with-same-
name is equal, every other pair is unequal.
- Sources/AgentRestartRegistry.swift: add `name` stored property; require
it as an init parameter; mint `phase1` with `name: "phase1"`.
- Sources/WorkspaceApplyPlan.swift: replace the either-non-nil-is-unequal
branch with a name-match branch.
- c11Tests/AgentRestartRegistryTests.swift: pass `name:` to the two
custom-registry constructors already in the file.
- c11Tests/WorkspaceApplyPlanCodableTests.swift: add three equality
invariants (same name → equal, different names → unequal, nil vs non-nil
→ unequal).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* C11-14: I6 shared claudeSessionId constant across app and CLI
Sources/WorkspaceMetadataKeys.swift is already pure Foundation and already
declares `SurfaceMetadataKeyName.claudeSessionId`. Adding it to the c11-cli
target's Sources build phase lets the claude-hook session-start path
reference the enum directly instead of duplicating the literal
"claude.session_id".
- GhosttyTabs.xcodeproj/project.pbxproj: new PBXBuildFile entry (c11-cli
build action) pointing at the existing fileRef; add to c11-cli Sources
list next to the other shared files (Mailbox*.swift, SkillInstaller.swift).
- CLI/c11.swift: replace the literal in the claude-hook session-start
metadata-write with SurfaceMetadataKeyName.claudeSessionId; drop the
"kept in lockstep by reader convention" commentary now that the link is
real.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* C11-14: P2 warn on unknown restart_registry wire names
Before: `AgentRestartRegistry.named("phase99")` silently returned nil, and
the v2 snapshot.restore handler fell back to Phase 0 (no cc --resume
synthesis) with no telemetry. A typo in the restart_registry param was
undetectable.
After: the handler detects the named-registry-resolves-to-nil case, captures
a pre-apply warning, and splices it into ApplyResult.warnings before
encoding. Operators see the warning in both JSON output and the CLI's
`warnings:` block. Restore still succeeds — forward-compat with Phase 5
rows is preserved, because an older binary should still land the layout
even if a future registry name is unrecognised.
- Sources/TerminalController.swift: capture preApplyWarnings when
AgentRestartRegistry.named returns nil for a non-nil wire name; prepend
to applyResult.warnings before JSON encode.
Note: a focused unit test for this warning would land cleanly in a
tests_v2/ subprocess test (live snapshot.restore call with an unknown
restart_registry). The tests_v2 handler test scaffold is out of scope for
this commit; noting as a follow-up under F1 in the plan.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* C11-14: P6 orphan-socket / wrong-owner advisory errors
c11 is single-user: the claude-hook writer treats three additional error
shapes as "no live c11 app on this machine for me" (advisory) instead of
`failed`:
- "Permission denied" — EPERM/EACCES from connecting to a socket whose
owner is another uid, or from unix(7) permission checks on the socket
directory.
- "Operation not permitted" — EPERM on stat/unlink of the socket path.
- "Resource temporarily unavailable" — EAGAIN variant when an orphaned
socket file exists but no listener accepts.
Extract the string-shape predicate into Sources/CLIAdvisoryConnectivity.swift
so c11Tests can unit-test the matcher directly. CLI/c11.swift's
isAdvisoryHookConnectivityError now delegates to the shared predicate after
unwrapping the CLIError message. Both the c11 app and c11-cli targets link
the shared file; no behavioural change for app code that never looked at
CLIError.
- Sources/CLIAdvisoryConnectivity.swift: new file with the string predicate.
- CLI/c11.swift: one-line delegator; new matches reach the CLI through it.
- c11Tests/CLIAdvisoryConnectivityTests.swift: three new-shape advisory
cases plus regression guards for the existing shapes and two negative
controls.
- GhosttyTabs.xcodeproj/project.pbxproj: wire the new source into c11,
c11-cli, and c11Tests Sources phases; register the fileRefs.
If multi-user c11 ever lands, revisit this call — wrong-owner should
stop being advisory there.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* C11-14: P1 header-only snapshot list summary
snapshot.list previously decoded the full WorkspaceApplyPlan per file just
to read envelope-level metadata + surface count. That gets slower as
snapshots accumulate in ~/.c11-snapshots/ and scales with embedded-plan
size (layout tree, surface metadata) rather than envelope size.
After: WorkspaceSnapshotStore.enumerate uses a JSONSerialization shallow
decoder that reads only snapshot_id, created_at, c11_version, origin,
surface_count, and probes plan.workspace.title + plan.surfaces.count
without instantiating the Codable tree. An explicit surface_count field
on the envelope caches the count so the fallback shallow probe only fires
on legacy files written before this change.
- Sources/WorkspaceSnapshot.swift: add optional `surfaceCount: Int?`
(wire: `surface_count`) to WorkspaceSnapshotFile. Optional preserves
backwards compat: pre-P1 files decode with nil, new files carry the
count.
- Sources/WorkspaceSnapshotCapture.swift: populate surfaceCount from
plan.surfaces.count in LiveWorkspaceSnapshotSource.capture.
- Sources/WorkspaceSnapshotStore.swift: new private SnapshotSummary type
+ readSummary(from:) that does the shallow decode. enumerate() now
emits rows from the summary, falling back to plan.surfaces.count when
the envelope's surface_count is absent.
Tests (WorkspaceSnapshotCaptureTests):
- testStoreListReadsExplicitSurfaceCountFromEnvelope — verifies the new
file path (envelope count wins, even when it disagrees with the plan).
- testStoreListFallsBackToPlanSurfacesCountWhenEnvelopeLacksKey — verifies
the legacy path (shallow probe into plan.surfaces.count).
Gates I8 (next commit): the same readSummary throw path will catch
unreadable rows and emit them with a `.unreadable` marker.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* C11-14: I8 surface unreadable snapshot.list rows
Before: snapshot.list silently dropped malformed envelope files (truncated
JSON, not-a-snapshot files, etc.), leaving operators unable to see or
delete them. After: unreadable entries surface as rows with a best-effort
id (filename stem), `createdAt = .distantPast` (sorts to the bottom),
empty title, surface count 0, and a machine-readable `readability`
discriminator carrying the truncated parse error.
- Sources/WorkspaceSnapshot.swift: add `readability: Readability` field
(`.ok` / `.unreadable(String)`) to WorkspaceSnapshotIndex. Tagged
Codable: `{"status": "ok"}` or `{"status": "unreadable", "reason": ...}`.
Forward-compat: older clients that omit `readability` decode as `.ok`.
- Sources/WorkspaceSnapshotStore.swift: replace the silent `continue` with
a `catch` branch that synthesises an unreadable row. Reason truncated to
160 chars to keep the terminal table legible.
- CLI/c11.swift: runListSnapshots emits `UNREADABLE` in the title column
and `?` in the surfaces column for unreadable rows. Full reason prints
after the table under `unreadable: <id> [<path>] — <reason>`. Two new
localized strings: cli.listSnapshots.column.unreadable and
cli.listSnapshots.unreadable.prefix.
Tests (WorkspaceSnapshotCaptureTests):
- testStoreListSurfacesMalformedJSONAsUnreadableRow — verifies the
unreadable row is emitted with the right shape.
- testStoreListSortsHealthyBeforeUnreadable — sort invariant.
- testReadabilityRoundTripsThroughCodable — Codable tagged encoding.
- Retires the "silently skips malformed json" test; new behavior is
opposite by design.
The JSON `readability` field is surfaced by v2SnapshotList verbatim —
no handler change required, since WorkspaceSnapshotIndex is encoded
through its Codable conformance.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* C11-14: I2 restore --in-place
c11 restore --in-place replaces the current workspace's content with the
restored plan instead of creating a fresh workspace. Without the flag,
running c11 restore twice produces two duplicate workspaces with no
warning.
Implementation is deliberately minimum-viable: applyToExistingWorkspace
resolves the target, closes it via TabManager.closeWorkspace, and calls
the existing apply(_:options:dependencies:) path. The target workspace's
panels and splits are closed first; the new workspace inherits the plan.
Caveat: the workspace UUID changes (a fresh workspace is minted and the
prior one is closed). Preserving the original UUID + tab position is
follow-up F4 on the plan, not in scope for C11-14.
- Sources/WorkspaceLayoutExecutor.swift: replace the Phase-0 doc comment
that promised a non-existent applyToExistingWorkspace method with a
real implementation. invalid_params failure surfaces when the target
UUID does not resolve; the v2 handler maps that through ApplyResult.
- Sources/TerminalController.swift: v2SnapshotRestore accepts optional
in_place: Bool and target_workspace_id: UUID string. Branches to the
new executor method when both are supplied; synthesises an
invalid_params failure when in_place is set without a resolvable target.
- CLI/c11.swift: --in-place / --replace flag alias. The CLI resolves the
current workspace id via v2 workspace.current and threads it as
target_workspace_id. Help text (subcommandUsage("restore")) now lists
the flag and the behavioural note. Three localized English strings
(cli.restore.usage.inPlace, cli.restore.usage.inPlace.note).
- skills/c11/references/claude-resume.md: add --in-place usage example
with the UUID-change note.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* C11-14: P4 acceptance suite skips in Release
Both acceptance test methods rely on terminalPendingInput(_:), which
delegates to the DEBUG-only accessor TerminalSurface.pendingInitialInputForTests.
In Release builds the accessor returns nil and the assertions fail with
an opaque "got '' instead" message that is not actionable.
Guard each affected method with #if !DEBUG throw XCTSkip so the suite
reports a clean skip in Release instead of a noisy failure. The private
helper stays unchanged: returning nil in Release remains a defensive
fallback that XCTSkip prevents from ever being observed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* C11-14: P11 thread-safe pendingInitialInputForTests
TerminalSurface is an NSView, which AppKit convention puts on the main
actor but does not enforce at the compiler level. The DEBUG-only
pendingInitialInputForTests accessor reads pendingTextQueue without a
lock; an off-main caller would race the queue's mutation sites. Annotate
the accessor @mainactor so the compiler enforces the main-actor
invariant. Existing callers (the Phase 1 acceptance harness) already run
on the main actor so there's no behavioural change.
Rejected the alternative fix (wrap pendingTextQueue mutations with an
NSLock) because it extends lock discipline into the typing-latency hot
path pendingTextBytes rebalancing. @mainactor is lighter and matches
AppKit's actual threading model for NSView-derived state.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* C11-14: P7 browser-first / markdown-first acceptance fixtures
Phase 1 acceptance puts a terminal first in the layout. Phase 3 will
lean on browser- and markdown-first plans (Blueprints author them
directly), and the walker already handles them; there was no integration
test. P7 adds one fixture per non-terminal kind and two new acceptance
methods that exercise the full capture + write + read + convert +
restore loop.
Each new test asserts:
- The distinguishing surface field round-trips byte-equal (url for
browser, filePath for markdown).
- The trailing terminal still receives the synthesised
`cc --resume <session-id>` via the Phase 1 restart registry.
Both methods route through runMixedFirstFixtureRoundTrip to keep the
test bodies symmetric, and both carry the Release XCTSkip guard from P4
because they read the DEBUG-only pendingInitialInputForTests accessor.
- c11Tests/Fixtures/workspace-snapshots/browser-first-mixed.json (new)
- c11Tests/Fixtures/workspace-snapshots/markdown-first-mixed.json (new)
- c11Tests/WorkspaceSnapshotRoundTripAcceptanceTests.swift:
testCaptureAndRestoreBrowserFirstLayout
testCaptureAndRestoreMarkdownFirstLayout
plus the shared helper. Uses the existing loadFixturePlan /
resolveWorkspace / stringMetadataValue helpers; no new test
infrastructure.
Fixtures are loaded via #filePath + relative path (same pattern as the
existing mixed-claude-mailbox fixture) so no pbxproj edit is required.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* C11-14: I5 followup — list-snapshots plain-table subprocess test
The earlier I5 fix replaced the broken `%s` printf in runListSnapshots
with `String.padding(toLength:withPad:startingAt:)` but did not add a
test because the c11Tests target can't easily exercise the CLI binary.
This adds the missing coverage via tests_v2/ (same socket-test pattern
the rest of the suite uses).
The test:
1. Creates a new workspace via the v2 client.
2. Captures a snapshot via `c11 snapshot --workspace <id>` (plain output).
3. Runs `c11 list-snapshots` and asserts the header lists all six
columns and the captured id + source=current appear in the row.
4. Runs `c11 list-snapshots --json` and asserts the JSON payload
contains the captured id with a non-negative integer surface_count
that matches an integer token in the plain-table row.
5. Cleans up the snapshot file and the workspace so repeated runs stay
idempotent. Skips cleanly when ~/.c11-snapshots is not writable.
CI's existing tests_v2/ workflow picks this up automatically.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* C11-14: P5 document claude.session_id threat model
Snapshot envelopes store claude.session_id values in cleartext under
~/.c11-snapshots/. The trident review asked for either (a) document the
threat model, (b) redact on capture, or (c) encrypt at rest via
Keychain. Operator decision captured on the C11-14 ticket is (a)
document-and-ship: session ids are transcript-lookup keys, not
credentials, and an attacker who can read the snapshot file already has
access to ~/.claude/projects/ on the same filesystem.
The new `Privacy and storage` section in skills/c11/references/claude-resume.md
spells out the threat model, why Keychain encryption was rejected, and
the hygiene recommendations for operators whose threat model is tighter
than c11's default assumption (restrict permissions, skip shared-volume
backups, delete snapshots after restore).
Docs-only commit; no code change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* C11-14: sync Localizable.xcstrings for ja/uk/ko/zh-Hans/zh-Hant/ru
Adds translations for the two new English strings introduced in the C11-14
commits (CMUX-37 Phase 1 follow-up):
- cli.listSnapshots.column.unreadable
- cli.listSnapshots.unreadable.prefix
The other keys referenced in the plan (cli.restore.usage.inPlace,
.inPlace.note, .inPlaceAndPath, socket.snapshot.restore.unknownRegistry)
were not wrapped in String(localized:) by the Impl phase, so nothing to
sync for them.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* C11-14: trident-followup B1/B2/B3 + polish
Addresses blockers and polish from the 9-agent trident review pack at
notes/trident-review-C11-14-pack-20260424-1750. PR #77 blockers were
already fixed; these are follow-ups specific to this branch's diff.
Blockers
- F-B1 (single-workspace close no-op): `c11 restore --in-place` on a
window with one workspace used to duplicate. The prior order called
`TabManager.closeWorkspace` (which silently no-ops on `tabs.count <=
1`) and then `apply`, producing two workspaces. Flipped order: apply
first, then close. Tab count is always >= 2 at close time, so the
guard passes. Side benefit: a failed apply is now non-destructive
for the common case (old workspace intact, no phantom replacement).
Sources/WorkspaceLayoutExecutor.swift.
- F-B2 (wrong workspace from operator focus): `--in-place` now resolves
the target via the caller env (CMUX_WORKSPACE_ID / C11_WORKSPACE_ID)
before falling back to `workspace.current`. Background agents on a
non-focused surface previously hit `workspace.current`, which returns
the operator-selected workspace, not the agent's own. Destructive
commands cannot depend on operator focus per the CLAUDE.md socket
focus policy.
CLI/c11.swift.
- F-B3 (list-snapshots --json subcommand-local flag): help has always
advertised `c11 list-snapshots --json`; `runListSnapshots` was
rejecting it as an unknown flag. The new tests_v2 subprocess test
uses the local form and would fail on CI. `--json` is now accepted
both as the global pre-subcommand flag and as a subcommand-local
flag.
CLI/c11.swift.
Important + polish
- workspace_uuid_changed warning: `applyToExistingWorkspace` now
prepends a structured warning to `ApplyResult.warnings` so scripted
consumers see the UUID change in the same channel as pre-apply
warnings. Follow-up F4 will preserve UUIDs in place.
Sources/WorkspaceLayoutExecutor.swift.
- xcstrings keys for --in-place help: `cli.restore.usage.inPlace` and
`cli.restore.usage.inPlace.note` were referenced from
`String(localized:)` call sites but never added to
`Localizable.xcstrings`. Non-English builds fell back to the English
defaults. English-only entries added; translator pass picks up the
other six locales.
Resources/Localizable.xcstrings.
- WorkspaceSnapshotStore.enumerate doc comment: said "skipped silently"
even though I8 emits `unreadable` rows. Comment updated to reflect
the implementation.
Sources/WorkspaceSnapshotStore.swift.
- Em-dashes in user-facing CLI output replaced with colons: the
unreadable-row print and the unknown-restart_registry warning both
use plain punctuation now.
CLI/c11.swift, Sources/TerminalController.swift.
Tests
- c11Tests/WorkspaceLayoutExecutorAcceptanceTests.swift: +4 tests
exercising `applyToExistingWorkspace`:
- testInPlaceRestoreReplacesSingleWorkspaceWithoutDuplicating
(F-B1 regression: count == 1 after replace, original closed)
- testInPlaceRestoreReplacesTargetAndLeavesSiblingIntact
(multi-workspace happy path: count stable, sibling untouched)
- testInPlaceRestoreMissingTargetReturnsInvalidParams
(non-destructive failure on bogus UUID)
- testInPlaceRestoreValidationFailureLeavesTargetIntact
(unsupported version short-circuits before any state change)
Out of scope / surfaced for delegator
- I2 atomic rollback on post-close apply failure: the order flip
already covers the common failure mode; full atomicity (snapshot
the old, swap, rollback on partial apply) is F4 / Phase 2 scope.
- tests_v2 cmux-era socket paths and binary globs: pre-existing
pattern across the entire tests_v2 suite. Fixing one file creates
inconsistency; bulk migration is its own change.
- `readSummary` vs `read(from:)` created_at "disagreement" claim
(Standard Claude #4): both paths share the same two-format fallback
(workspaceSnapshotDateFormatter, then withInternetDateTime). Misread.
- `readSummary` "not truly header-only": JSONSerialization parses the
full envelope object tree. Performance scope; deferred.
- snapshot.list JSON timestamp precision (second-precision .iso8601):
consistency with the store's fractional-second writes. Deferred.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* C11-14: trident-followup additional fixes
Three more concrete bugs called out by the review pack that I missed in
the first follow-up commit. These are real round-trip / output bugs,
not polish.
- Empty snapshot_id for `.json` files: a file literally named `.json`
produced an unreadable row with `snapshotId = ""` because
`deletingPathExtension().lastPathComponent` collapses to the empty
string. Fall back to the full filename (capped at 128 chars) so the
row is identifiable in the plain-table column. Test added in
WorkspaceSnapshotCaptureTests covers the degenerate filename.
Sources/WorkspaceSnapshotStore.swift,
c11Tests/WorkspaceSnapshotCaptureTests.swift.
(Crit Gemini #3, Std Codex #5, Crit Codex nit on filename stem.)
- `snapshot.list` JSON timestamp precision: the v2 handler encoded
`WorkspaceSnapshotIndex.createdAt` with `.iso8601`, which drops
subsecond precision. The store now writes fractional seconds via
`workspaceSnapshotDateFormatter`, so the list response was
silently truncating timestamps that the store had just written.
Switched the encoder to a custom strategy that uses the shared
formatter, matching the round-trip the rest of the store path
already provides.
Sources/TerminalController.swift.
(Crit Codex nit on `v2SnapshotList`.)
- Honest `readSummary` doc comment: the prior comment claimed nested
`plan.layout` and `plan.surfaces[].metadata` "stay in the JSON blob
untouched". `JSONSerialization.jsonObject` actually materialises the
full object graph into NSDictionary/NSArray; the win is skipping the
`WorkspaceApplyPlan` Codable decode, not avoiding the parse. Comment
reworded to describe the real cost shape so future readers don't
trust the false claim.
Sources/WorkspaceSnapshotStore.swift.
(Crit Gemini #1, Crit Codex nit on memory shape.)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* C11-14: extract skipIfReleaseBuild helper for round-trip tests
Std Claude #13: the four acceptance scenarios in
WorkspaceSnapshotRoundTripAcceptanceTests carried an identical 3-line
`#if !DEBUG / throw XCTSkip(...) / #endif` preamble. Extracted a
private `skipIfReleaseBuild()` helper so each call site is one line
and a future skip-reason change is one place, not four.
No behavior change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: nonisolated on defaultVersionString to satisfy strict concurrency
The default parameter in LiveWorkspaceSnapshotSource.init called
defaultVersionString() from a nonisolated context. Under the c11-unit
scheme (which enables strict concurrency checking), this failed because
the @mainactor isolation on the enclosing class made the static method
implicitly @mainactor too.
Adding nonisolated makes the default-parameter callsite legal without
requiring the caller to be on the main actor.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: set PRODUCT_MODULE_NAME=c11 in Debug build; fix TomlSubsetParserTests import
Debug builds set PRODUCT_NAME="c11 DEV" which makes the Swift module name
c11_DEV via the default PRODUCT_MODULE_NAME derivation. All 75+ test files
use @testable import c11 which fails to find the module under the c11-unit
scheme (mailbox-unit CI job) because it builds in Debug mode.
Fix: explicitly set PRODUCT_MODULE_NAME=c11 so the module is always named
c11 regardless of whether PRODUCT_NAME is "c11 DEV" or "c11". The app
product name stays "c11 DEV" for dock/menu bar disambiguation.
Also update TomlSubsetParserTests.swift to use the conditional import
pattern introduced by PR #79's new Blueprint test files, as belt-and-
suspenders defense.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
BenevolentFutures
added a commit
that referenced
this pull request
Apr 27, 2026
…tuck-modifier state (cmux#1153 follow-up) The flagsChanged press/release diff added in 4baf4b0 introduced a state snapshot (lastModifierFlags) but never reset it across focus loss. If a user held Option in c11, switched to another app, released Option there, and switched back, the snapshot would remain [.option] while reality is []: the next flagsChanged for that surface would diff against stale state and could misclassify press vs release. Reset on focus loss (resignFirstResponder) so the snapshot can't go stale, and re-snapshot from NSEvent.modifierFlags on focus gain (becomeFirstResponder) so the very next flagsChanged diffs against ground truth. Allocation-free: NSEvent.ModifierFlags is OptionSet (stack value), intersection() is a stack-value operation, the assignment is on focus-transition paths (not the typing-latency hot path). Sources: trident review C11-21 -- Standard-Claude (Important #3), Critical-Claude (Important #1), Evolutionary-Claude (Concrete Suggestion #1), Evolutionary-Codex (Concrete Suggestion #2), Standard-Gemini (Important #2).
BenevolentFutures
added a commit
that referenced
this pull request
Apr 27, 2026
…ite-side flagsChanged transitions (cmux#1153 follow-up) The flagsChanged press/release diff added in 4baf4b0 derived action from diffing aggregate deviceIndependentFlagsMask. Aggregate flags strip left/right side info: holding one Shift while pressing/releasing the other Shift leaves the .shift bit set both times, so neither set difference is non-empty and the fallback emitted PRESS even on physical release. Same defect for left/right Option, Control, and Command -- and this branch is specifically about fixing stuck modifier state, so a PRESS-on-release is the bug shape we need to avoid. Disambiguate via the side-specific NX device mask in event.modifierFlags.rawValue when the aggregate diff is empty. The post-event raw bit reflects the current state of that side: set means this physical key is now held (PRESS), cleared means the side just released (RELEASE). The aggregate diff stays as the primary fast path; side-aware logic only runs when both subtractions are empty. The 'no recognized modifier' branch keeps the prior unconditional PRESS for backwards compat (mask-stripped bits, synthetic events). Helper sideMaskForModifierKeyCode mirrors the keycode -> NX_DEVICE* mapping that upstream Ghostty's SurfaceView_AppKit.swift uses (~line 1313). Allocation-free: integer switch returning UInt?, masked AND on raw flags, called only on the rare empty-diff path. Sources: trident review C11-21 -- Standard-Codex (Important #1), Critical-Codex (Important #1, What Will Break #1), Critical-Gemini (Important Issue #1).
BenevolentFutures
added a commit
that referenced
this pull request
Apr 27, 2026
* fix(input): tighten ANSI keyCode fallback in matchShortcut (cmux#3061)
On JIS-style layouts the physical key labelled `[` lives at keyCode 30
(where `]` sits on ANSI), so Cmd+Shift+[ produces eventCharsIgnoringModifiers
of `{`. The character pipeline correctly normalises that to `[` and matches
the prevSurface shortcut, but the ANSI keyCode fallback then fires for the
nextSurface shortcut and overrides the working character match (because
`shouldRequireCharacterMatchForCommandShortcut` is false for punctuation).
Result: Cmd+Shift+[ moves to the next surface instead of the previous one.
Tighten the fallback gate so ANSI keyCodes only get a vote when neither
AppKit nor the active layout produced a character. The control-modifier
branch is unchanged (Ctrl combos still need keyCode fallback because they
surface as ASCII control characters).
Reported-by: @wada811 <upstream issue #3061>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(input): Option+Delete fast path and flagsChanged press/release for alt+backspace (cmux#1153)
Two related changes in `GhosttyTerminalView`:
1. **Option+Delete fast path** in keyDown, mirroring the existing Ctrl
fast path. Previously `interpretKeyEvents` ran first and translated
Option+Delete via `deleteWordBackward:` or `insertText` with
replacement, both of which strip the alt prefix Ghostty needs to
encode the backward-kill-word escape sequence (`ESC DEL`, `\x1b\x7f`).
The fast path is gated on keyCode 51 + `.option` only (no Cmd, no
Ctrl) and skipped during IME marked text. Branch predicates are
cheap (no string comparisons, OptionSet stack values) so this
stays safe in the typing-latency hot path.
2. **flagsChanged press/release detection.** Previously every
modifier transition was sent as `GHOSTTY_ACTION_PRESS`, so Ghostty's
input state never observed RELEASE. After releasing Option following
an Option+Delete, the next plain Delete still got encoded as
alt-prefixed. Snapshot the previous flags in a new
`lastModifierFlags` instance var and diff: new bits → PRESS, cleared
bits → RELEASE. The diff is OptionSet stack ops, allocation-free.
This second change also closes cmux#2949 (modifier press/release dropped
during IME marked text); see the follow-up commit that credits @shaun0927
for that report.
Reported-by: @sldx <upstream issue #1153>
Diagnosed-by: @judekim0507 <TUI-vs-shell context, upstream issue #1424>
First-attempt: @shouryamaanjain <upstream PR #1438 — closed unmerged>
Root-cause: @pandec <upstream PR #2246 — closed unmerged>
Reference: manaflow-ai/cmux@ab46c557 + @fda8daad + @151075f7 by @austinywang
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(input): modifier events during IME marked text now include RELEASE (cmux#2949)
The flagsChanged press/release fix landed in 4baf4b0 also closes
cmux#2949: modifier press/release events were dropped during IME marked
text because flagsChanged unconditionally sent GHOSTTY_ACTION_PRESS,
never RELEASE. As a result, Shift+Arrow during Korean Hangul or Japanese
Romaji-to-Kana composition didn't extend selection in the source app's
preedit overlay, and Cmd-chord shortcuts tapped during IME composition
behaved as held-down modifiers.
The keyDown early-return at `cmux_performKeyEvent` (`if hasMarkedText(),
!event.modifierFlags...contains(.command)`) is gated on keyDown only,
not flagsChanged, so modifier-only events already bypass it and reach
flagsChanged regardless of marked-text state. The press/release diff in
4baf4b0 is therefore enough to close this issue end-to-end.
This is a credit-trailer commit. The code change lives in 4baf4b0
("fix(input): Option+Delete fast path and flagsChanged press/release
for alt+backspace (cmux#1153)"). No source changes here.
Reported-by: @shaun0927 <upstream issue #2949>
Reference: see commit 4baf4b0 for the flagsChanged change
Reference: manaflow-ai/cmux@ab46c557 + @fda8daad + @151075f7 by @austinywang
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(input): prefer UTF-8 plain-text pasteboard type for non-ASCII paste (cmux#3069)
In `GhosttyPasteboardHelper.stringContents(from:)`, ask
`pasteboard.string(forType: utf8PlainTextType)` before
`pasteboard.string(forType: .string)`. On modern macOS the two are
aliased to the same UTI (`public.utf8-plain-text`), so the order has
no observable effect when both calls hit the same backing data. The
ordering matters when a producer registers both legacy and UTF-8
variants with different content (the historical Qt / Telegram Desktop
pattern that earlier landed via @dasanworld's #2877): asking for UTF-8
first guarantees we read bytes >= 0x80 unmangled instead of catching
a Mac-OS-Roman-truncated `.string` result.
Investigation note (deviation from plan §2.4 ghostty leg): the plan
hypothesised a `?`-substitution step inside the ghostty submodule's
clipboard paste pipeline. After tracing
`ghostty_surface_complete_clipboard_request` →
`Surface.completeClipboardPaste` → `input.paste.encode` → termio
`writeReq` end to end, every layer is byte-clean for bytes >= 0x80;
the only encoding-aware step (`paste.encode`'s `strip` set) only
touches control bytes < 0x20 plus 0x7F. Earlier upstream fixes
(4d49a7b, e94daa0) already corrected the historical Mac-OS-Roman
ordering on the Swift side. Concluded the residual non-ASCII paste
issue is most likely fixed by drift; the Swift-side reorder above is
the cheap defensive change. Validate phase should confirm by pasting
Hangul / Cyrillic / a Qt-emitted string into the terminal.
Reported-by: @jewel-sallylab <upstream issue #3069>
Also-closes: manaflow-ai/cmux#2756 (reported by @AndreySoloviev)
Also-closes: manaflow-ai/cmux#2891 (reported by @pankrusheff)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(input): keep macos-option-as-alt unset in c11-default.conf so Option-key glyphs reach the shell on non-US layouts (cmux#1469)
Verified Resources/ghostty/c11-default.conf does NOT set
macos-option-as-alt. Upstream ghostty defaults the option to null
(let the user decide), and the c11 bundled defaults respect that.
Per plan §2.5 ("If absent, do not add it: let the user opt in"), no
config change required: c11 already ships the macOS-convention
behavior where Option+L on DE produces `@`, Option+§ on JP produces
the layout glyph, etc., because Ghostty's `translationModsGhostty`
plumbing in GhosttyTerminalView.swift respects the unset / false
config.
No Swift unit test added: the plan's suggested synthetic-NSEvent test
would need either a runtime seam to mock ghostty_surface_key or an
end-to-end CI harness. Per the worktree CLAUDE.md test quality policy
("If no meaningful behavioral or artifact-level test is practical,
skip the fake regression test and state that explicitly"), skipping.
Validate phase should exercise DE/JP/KR/RU input sources and confirm
Option+layout-glyph keys reach the shell.
This is a docs/credit commit. No source changes.
Reported-by: @alceal <upstream issue #1469>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(input): soft-wrap copy already preserves continuation rows in current ghostty (cmux#3096)
Verified in the ghostty submodule that both selection-text paths
already pass unwrap=true to the ScreenFormatter:
- `Screen.selectionString` (src/terminal/Screen.zig) hardcodes
unwrap=true in the formatter init that backs `dumpTextLocked` and
the C export `ghostty_surface_read_selection`. This is the path
Swift's `ghosttyView.swift` reads when writing to the system
pasteboard.
- `Surface.copySelectionToClipboards` (src/Surface.zig) passes
`.unwrap = true` in its formatter Options for plain/vt/html/mixed
copy formats. This is the path the `copy_to_clipboard` action
binding takes.
ScreenFormatter at src/terminal/formatter.zig:1109 honors that flag
correctly: `if (!row.wrap or !self.opts.unwrap) blank_rows += 1;` —
when the row is soft-wrapped and unwrap is true, no `\n` is emitted
between this row and the next continuation row.
No code change required. Concluded the soft-wrap copy bug is fixed by
drift in the current ghostty submodule pin (bc9be90a). Validate phase
should confirm by typing a long command into a narrow terminal,
selecting and copying, then pasting into a second terminal — pasted
text should round-trip without `\n` injection at the wrap boundary.
This is a docs/credit commit. No source changes.
Reported-by: @tmad4000 <upstream issue #3096>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(input): PTY termios already inherits ISIG and VSUSP from openpty defaults so Ctrl-Z routes to SIGTSTP (cmux#2105)
Verified the ghostty submodule's PTY initialisation in
src/pty.zig at the bc9be90a pin:
- `Pty.open` calls libc `openpty()`, which inherits the kernel's
default termios. On macOS that default includes
`c_lflag = ICANON | ISIG | ECHO | ECHOE | ECHOK | ECHOCTL | ...`
and `c_cc = { VINTR=0x03, VQUIT=0x1C, VSUSP=0x1A, VEOF=0x04, ... }`.
- The only c_iflag tweak is `IUTF8`, which doesn't touch signal
delivery. c_lflag is never cleared, so ISIG stays on.
- `childPreExec` resets a small set of signal handlers
(ABRT/ALRM/BUS/CHLD/FPE/HUP/ILL/INT/PIPE/SEGV/TRAP/TERM/QUIT) to
SIG_DFL. SIGTSTP / SIGTSTOP are intentionally NOT in that set — the
child shell sets up its own job-control handler.
With ISIG enabled and VSUSP=0x1A, the line discipline already
translates the 0x1A byte (Ctrl-Z) into SIGTSTP delivered to the
foreground process group. The shell's job-control handler then
stops the foreground job, and `bg` resumes it, which is the expected
behavior.
No code change required. Concluded the Ctrl-Z bug is fixed by drift
in the current ghostty submodule pin (or never reproducible in the
c11 PTY path). Validate phase should confirm by running
`sleep 30` in a c11 terminal, pressing Ctrl-Z, then running `jobs`
(should show "Stopped") and `bg` (should resume).
This is a docs/credit commit. No source changes.
Reported-by: @freshtonic <upstream issue #2105>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(input): zoom-in (Cmd+= and Cmd+Shift+=) symmetry preserved by ghostty default bindings + Swift routing (cmux#1456)
Verified the symmetry end to end at the bc9be90a ghostty pin:
- Ghostty defaults (src/config/Config.zig:6319-6334) bind both
`cmd+=` and `cmd++` to increase_font_size, plus `cmd+-` to
decrease_font_size.
- KeyEvent.effectiveMods (src/input/key.zig:53-56) strips
consumed_mods from the actual mods. The c11 Swift side
(GhosttyTerminalView.swift `consumedModsFromFlags`) marks shift
and option as consumed when they participate in text translation.
So when AppKit reports characters `+` for Cmd+Shift+=, ghostty
observes effective mods = {super} and matches the `+` binding.
- c11's bundled defaults (Resources/ghostty/c11-default.conf, 32
lines) define palette only and never override the keybind set.
- The Swift routing layer at AppDelegate.swift:1773
(`browserZoomShortcutAction`) accepts both `=` and `+` for
zoom-in symmetrically and forwards the event to ghostty.
No code change required. Concluded the asymmetry bug is fixed by
drift in the current ghostty submodule pin (the `cmd++` default
binding and the consumed-mods plumbing land it correctly). Validate
phase should confirm by focusing a terminal and pressing Cmd+= and
Cmd+Shift+= and observing both increase font size.
This is a docs/credit commit. No source changes.
Reported-by: @DaikiHayata <upstream issue #1456>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(input): UTF-8 type ordering parity in hasString and label kVK_ANSI_Delete in Option+Delete fast path
Cosmetic alignment after C11-21 trident review:
- hasString now checks utf8PlainTextType before .string, mirroring the new
read priority in stringContents() (cmux#3069). The contains() chain is
order-independent for correctness; the swap is purely for readability.
- The Option+Delete fast path's bare 'event.keyCode == 51' magic number
now carries an inline kVK_ANSI_Delete comment, matching the existing
convention used by sibling code (e.g. AppDelegate.swift's bare 36/76
with kVK_Return / kVK_ANSI_KeypadEnter comments).
* fix(input): reset lastModifierFlags on focus transitions to prevent stuck-modifier state (cmux#1153 follow-up)
The flagsChanged press/release diff added in 4baf4b0 introduced a state
snapshot (lastModifierFlags) but never reset it across focus loss. If a
user held Option in c11, switched to another app, released Option there,
and switched back, the snapshot would remain [.option] while reality is
[]: the next flagsChanged for that surface would diff against stale
state and could misclassify press vs release.
Reset on focus loss (resignFirstResponder) so the snapshot can't go
stale, and re-snapshot from NSEvent.modifierFlags on focus gain
(becomeFirstResponder) so the very next flagsChanged diffs against
ground truth.
Allocation-free: NSEvent.ModifierFlags is OptionSet (stack value),
intersection() is a stack-value operation, the assignment is on
focus-transition paths (not the typing-latency hot path).
Sources: trident review C11-21 -- Standard-Claude (Important #3),
Critical-Claude (Important #1), Evolutionary-Claude (Concrete Suggestion #1),
Evolutionary-Codex (Concrete Suggestion #2), Standard-Gemini (Important #2).
* fix(input): side-aware press/release fallback for same-modifier opposite-side flagsChanged transitions (cmux#1153 follow-up)
The flagsChanged press/release diff added in 4baf4b0 derived action from
diffing aggregate deviceIndependentFlagsMask. Aggregate flags strip
left/right side info: holding one Shift while pressing/releasing the
other Shift leaves the .shift bit set both times, so neither set
difference is non-empty and the fallback emitted PRESS even on physical
release. Same defect for left/right Option, Control, and Command -- and
this branch is specifically about fixing stuck modifier state, so a
PRESS-on-release is the bug shape we need to avoid.
Disambiguate via the side-specific NX device mask in
event.modifierFlags.rawValue when the aggregate diff is empty. The
post-event raw bit reflects the current state of that side: set means
this physical key is now held (PRESS), cleared means the side just
released (RELEASE). The aggregate diff stays as the primary fast path;
side-aware logic only runs when both subtractions are empty. The 'no
recognized modifier' branch keeps the prior unconditional PRESS for
backwards compat (mask-stripped bits, synthetic events).
Helper sideMaskForModifierKeyCode mirrors the keycode -> NX_DEVICE*
mapping that upstream Ghostty's SurfaceView_AppKit.swift uses (~line
1313). Allocation-free: integer switch returning UInt?, masked AND on
raw flags, called only on the rare empty-diff path.
Sources: trident review C11-21 -- Standard-Codex (Important #1),
Critical-Codex (Important #1, What Will Break #1), Critical-Gemini
(Important Issue #1).
* fix(input): post-review comment hardening and pasteboard empty-UTF8 fallback
S2: clarify flagsChanged PRESS fallback for unrecognized modifier transitions.
S3: skip empty utf8PlainTextType so .string fallback wins when a producer
registers UTF-8 with an empty value but populates legacy .string.
S4: document why consumed_mods=NONE is correct in Option+Delete fast path
and that the pattern must not be copied to glyph keycodes.
S7: rephrase lastModifierFlags comment as "OptionSet operations are
allocation-free" instead of "stack value" (the property lives on the
NSView; the cost-relevant claim is the operations).
S8: inline near-dead shouldRequireCharacterMatchForCommandShortcut helper
into matchShortcut. Single caller, small letter-check; keeps the
short-circuit chain so the work is only done when earlier conditions
pass.
S1: document AZERTY/Dvorak tradeoff inline at the matchShortcut tightening
so future maintainers see the reason ANSI fallback is now gated on both
event-character and layout-character lookup failing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
BenevolentFutures
added a commit
that referenced
this pull request
May 4, 2026
…xes EXC_BREAKPOINT crash, 4× today) (#121) * C11-26 followup: route bare main.sync handlers through v2MainSync (closes EXC_BREAKPOINT crash) After C11-26 (#112), the new socket dispatcher routes default-policy commands through `DispatchQueue.main.sync { MainActor.assumeIsolated { processCommand(...) } }` from the worker thread. That moves every v1 handler (and every default-policy v2 handler) onto the main thread — a behavior change vs. pre-C11-26 where `handleClient` called `processCommand(trimmed)` directly on the worker. Roughly 100 handlers in `TerminalController` and 12 in `ThemeSocketMethods` were written against the old assumption: each does its own `DispatchQueue.main.sync { … }` to hop onto main. Post-C11-26 that hop is reentrant — libdispatch's self-deadlock guard (`__DISPATCH_WAIT_FOR_QUEUE__`) traps with EXC_BREAKPOINT. The operator hit this 4× today on builds 95/96/97 — once each from DEV-main at 14:41 and 15:01 EDT, then on 0.45.0 at 15:56, then on 0.45.1 at 18:49 — every dump bottoms out at `setProgress(_:) + 924` → `closure #1 in processCommand(_:) + 3856` → `__DISPATCH_WAIT_FOR_QUEUE__ + 484`. The earlier 14:26 IPS hang on 0.44.1 (build 95) is the same class of bug pre-detection: same dispatcher path, same self-wait, but on an older libdispatch that hung instead of trapping. Fix: replace the bare `DispatchQueue.main.sync { … }` calls with the existing `v2MainSync` helper (TerminalController) and a parallel `Self.mainSync` helper (ThemeSocketMethods). Both short-circuit when already on main and only hop when called from a worker, so the same handler is correct under either dispatcher path. The `ThemeSocketMethods` enum is not `@MainActor`, so its helper takes a `@MainActor () -> T` body and uses `MainActor.assumeIsolated`, mirroring `GhosttyTerminalView.performOnMain`. The dispatcher comment in `TerminalController` (lines 1639–) is updated to call out the behavior change explicitly so future handler authors know not to use bare `DispatchQueue.main.sync`. The two intentional bare-`DispatchQueue.main.sync` sites are kept: - line 1734 (the dispatcher itself, which must hop from worker) - line 3366 (inside `v2MainSync`'s own implementation) Other call sites in the tree (`TabManager.swift:586`, `Workspace.swift:5656`, `GhosttyTerminalView.swift:1891`) already gate on `Thread.isMainThread` and are unaffected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * C11-26 followup: regression test — v1 handlers don't self-deadlock on main Adds tests_v2/test_v1_handler_main_self_deadlock.py covering two scenarios: 1. 30 successive `set_progress` calls on a fresh workspace, each on a fresh socket connection. Pre-fix this trapped EXC_BREAKPOINT inside libdispatch's __DISPATCH_WAIT_FOR_QUEUE__ on the first call; subsequent connects then failed with ECONNREFUSED because the listener was gone. Post-fix all 30 return "OK". 2. A spread of v1 commands that share the bare-`DispatchQueue.main.sync` shape (`set_status`, `clear_status`, `report_pwd`, `clear_progress`, `report_git_branch`, `clear_git_branch`). They all route through `processCommand` → switch case → bare main.sync (pre-fix). The dispatcher path is the load-bearing piece; covering several anchors means a regression on the dispatcher cannot regress only one sibling. The test follows c11 CLAUDE.md "Test quality policy": runs through the real socket, no source-grep assertions, no plist/pbxproj reads. Per "Testing policy", runs against a tagged debug build only — header docstring documents `C11_SOCKET=/tmp/c11-debug-<slug>.sock`. A regression that produces only a hang (older libdispatch) is also caught by the per-call wall-clock deadline; one that produces a crash is caught by the next-connection ECONNREFUSED check. Both shapes route through the same assertion ("the call returned OK and the next connect succeeds"), which is what the user actually cares about. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
BenevolentFutures
added a commit
that referenced
this pull request
May 5, 2026
…cycle Second consumer. BrowserPanel owns a SurfaceLifecycleController that mirrors `lifecycle_state` to canonical metadata on each transition. Cheap-tier detach (the implementation path that delivers DoD criterion #1, browsers in non-focused workspaces consume <1% CPU) continues to flow through the existing `WebViewRepresentable.shouldAttachWebView` gate in BrowserPanelView (~line 6087): when `isVisibleInUI` flips false the gate calls `BrowserWindowPortalRegistry.hide` which detaches the WKWebView from the host hierarchy and CoreAnimation stops driving compositor work. The lifecycle controller's handler is intentionally a no-op for cheap-tier — C11-25 commit 5 replaces its body with snapshot+terminate dispatch when the target state is `.hibernated` (ARC-grade tier). Mirroring the metadata key gives the sidebar / `c11 tree --json` / snapshot-restore observability of the state today, even before ARC-grade lands. Threads `updateWorkspaceId` and `reattachToWorkspace` through the controller so panel reparenting keeps the metadata mirror pointed at the right workspace.
BenevolentFutures
added a commit
that referenced
this pull request
May 5, 2026
* `c11Tests/SurfaceLifecycleTests.swift` (new) — XCTestCase covering:
- Transition validator: every legal pair (`active ↔ throttled`,
`* → hibernated`, `hibernated → active`) and every reservation
boundary (`suspended` rejected end-to-end in C11-25, self-
transitions are idempotent, `isOperatorPinned` only on hibernated).
- Canonical metadata: every legal `lifecycle_state` value accepted by
the store; "frozen" / non-string values rejected with
`reserved_key_invalid_type`; the key is a member of
`MetadataKey.canonical` and `SurfaceMetadataStore.reservedKeys`.
- `SurfaceLifecycleController`: starts in `.active`, mirrors writes
to the metadata store, rejects invalid transitions without
mutating state, fires the handler only on real transitions
(no-op same-state writes don't re-fire), `seed(from:)` does not
fire the handler, `updateWorkspaceId` redirects subsequent writes.
* `tests_v2/test_surface_lifecycle.py` (new) — socket-driven smoke
through `surface.set_metadata` / `surface.get_metadata`:
- All four canonical values round-trip.
- Unknown values (e.g. "frozen") rejected.
- Non-string values rejected.
Auto-discovered by `scripts/run-tests-v2.sh` which globs
`tests_v2/test_*.py`.
Plan §4 DoD criteria covered scripted: criterion #5 (sidebar CPU/MEM)
is best validated via tagged-build computer-use during Validate;
criteria #1-3 + #6 are covered by the same tagged-build smoke (browser
RSS pre/post hibernate, terminal inter-frame interval after deselect,
right-click menu rendering). Per CLAUDE.md, never run locally — CI
and the VM run these.
This was referenced Aug 4, 2026
This was referenced Sep 13, 2026
This was referenced Sep 29, 2026
This was referenced Oct 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Validates and hardens the c11mux release pipeline end-to-end on Stage-11-Agentics infrastructure (forked from manaflow-ai/cmux). All workflow fixes required to build, sign, notarize, and publish a macOS release on our org — then chain through to the public Homebrew cask — are in this branch.
Dry-run culminated in v0.62.7: clean release build on
macos-15-xlargein 9m50s, notarized DMG uploaded, Homebrew cask bumped to a verified sha256 pin. DMG pull from the public URL resolves and matches the cask sha.What changed
Runner migration (fork has no Warp/Depot runners):
warp-macos-*-arm64-6x→macos-15→macos-15-xlargeacross release.yml, nightly.yml, ci.yml (hot paths). Keptmacos-15on ci-macos-compat, test-depot, test-e2e, build-ghosttykit since they're workflow_dispatch-only or compat-oriented.warp-macos-26-arm64-6xfrom ci-macos-compat matrix (no GH equivalent).Zig install portability:
zig-aarch64-macostarball withuname -m-aware selection across 7 workflows. Bugs out on Intel runners (e.g.macos-15-large) prior to this fix.Timeouts:
Release pipeline:
actions/attest-build-provenancestep from release.yml and nightly.yml — not available on private repos without GitHub Team/Enterprise billing, and was blockingUpload release asset. Easy to restore if we upgrade.Release bumps driven by dry-run iterations:
Included docs (@atin):
docs/c11mux-module-2-metadata-spec.md: canonical per-surface JSON metadata transport specdocs/c11mux-charter.md: charter updates (seven → eight modules, Module 8 added)Test plan
xcrun notarytool submit --wait+stapler)SUPublicEDKeymatch8b5ab52fbumps cask to 0.62.7 with verified sha256 pinshasum -a 256of downloaded DMG matches cask pinbrew install --cask stage-11-agentics/c11mux/c11muxsmoke test (defer to a follower who has Homebrew; trivial given the cask is validated)Known follow-ups (not in this PR)
cmuxTests(macOS Compatibility unit tests) fail to resolve first-party types (AppDelegate,GhosttyNSView) — appears to be fallout from the bundle-ID rename in 912450b. Not release-blocking; separate cleanup.build-ghosttykit.ymlusesGHOSTTY_RELEASE_TOKEN(unset) publishing tomanaflow-ai/ghostty. Fine while we consume the upstream xcframework; needs re-pointing if we fork the ghostty build.🤖 Generated with Claude Code