Skip to content

Release v1.11.0#1

Merged
mycode699 merged 56 commits into
mainfrom
release/v1.11.0
May 8, 2026
Merged

Release v1.11.0#1
mycode699 merged 56 commits into
mainfrom
release/v1.11.0

Conversation

@mycode699
Copy link
Copy Markdown
Owner

Major release covering the v1.10.20 → v1.11.0 work.

Highlights

File Explorer (VS Code parity)

  • ⌘P Quick Open in both local and remote SSH workspaces with VS Code-style fuzzy scoring.
  • Multi-row selection with batch operations (Copy / Insert / Delete N).
  • Multi-file drag out to Finder, terminal, and other apps via AppKit drag session.
  • Inline rename on local file rows.
  • Quick Look (Space) on local file rows.
  • Finder ↔ sidebar drop both directions, including recursive directory upload via tar pipeline for remote SSH.
  • Internal drag/move in both local and remote sidebars.
  • Drop progress banner with success/failure feedback.

Session Recovery (claude + codex)

  • Per-(workspace, surface) session-id persistence — reopening a pane resumes the prior conversation.
  • Remote agent wrapper push to ~/.icc/bin/ on SSH connect, so remote claude/codex go through the same session-recovery layer.
  • SSH terminal scrollback preserved across reconnects.

Reliability (P0)

  • Fixed main-thread stack overflow in long sessions: NSResponder recursion bug in FileDropOverlayView + MarkdownPanel.
  • Fixed SSH password lockout when remote password rotates (now bails after 2 auth failures).
  • Defense-in-depth password echo suppression in SSH expect helper.

Architecture

  • Control-plane reorganization (S1): Sources/Control/Envelope + Router skeleton; ~31 v2 methods migrated to handler files.
  • HotPathSampler telemetry primitive for v2 plan §5 budgets.

npm

@calm2026/imux: 1.10.4 → 1.11.0 (synced to desktop version).

Tests

161 Swift + 31 shell + 18 e2e = 210/210 green at HEAD.

Thanks to 1 contributor!

codex and others added 30 commits May 3, 2026 22:40
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…rojection

Closes v2 plan §4.1 (P0). Background workspaces no longer needed a manual
selection switch to show updated titles emitted by agent output.

Root cause: WorkspaceSidebarV3Card adopts `.equatable()` to protect the
typing-latency hot path. The previous `==` did not include `tab.title`,
`tab.customTitle`, `tab.isPinned`, or `tab.customColor`, so SwiftUI
short-circuited the body re-evaluation when only those identity fields
changed on a non-selected workspace.

Fix: introduce a typed `SidebarTabSummary` projection on `TabManager`
that subscribes (per-tab Combine sink) to those four identity fields.
The sidebar `Card` now holds the summary and `==` compares it. Other
47 `@Published` fields on `Workspace` continue to bypass this channel,
preserving the typing-latency protection that motivated the original
`Equatable` conformance.

New regression coverage: iccTests/BackgroundWorkspaceTitleFreshnessTests
(6 cases) pins both layers — model dispatch through Notification.didSet
and view equality on title/style fields.

Performance impact: a tabs mutation triggers one O(n) observer reconcile;
each per-tab subscription merges 4 publishers and writes a dictionary
slot only when the summary actually changes. Typing path is untouched.

References:
- docs/v2_platform_excellence_plan.md §4.1 (root cause + verdict)
- docs/v2_platform_excellence_plan.md §3.2 (TabSummary architecture)
After investigating §4.5 ("cmd+shift+H ring only flashes once") in the v2
plan, the implementation already meets the design intent on the four
panel-flash paths:

- MarkdownPanelView (Sources/Panels/MarkdownPanelView.swift:269-282)
  uses a `focusFlashAnimationGeneration` counter so a re-trigger cancels
  the in-flight DispatchQueue.main.asyncAfter segments via the generation
  guard.
- BrowserPanelView (Sources/Panels/BrowserPanelView.swift:1204-1215)
  follows the same pattern.
- TerminalPanel (GhosttyTerminalView.swift:8645-8662) drops the prior
  CAKeyframeAnimation via `flashLayer.removeAllAnimations()` before
  re-adding, so `triggerFlash` is naturally idempotent.
- FilePanel (FilePanel.swift:160) bumps the model token; the view layer
  it feeds is currently outside the flashed-ring story (see OPEN ISSUE
  in docs/superpowers/specs/2026-05-07-p0-sweep-plan.md §6).

So instead of touching the pattern, this change pins the contract.
PanelFocusFlashTokenTests asserts:

- two consecutive `triggerFlash(reason:)` calls strictly increment the
  `@Published focusFlashToken` value (a future "guard token != lastToken"
  refactor would re-introduce the §4.5 symptom; this catches that).
- a flash-disabled UserDefault short-circuits the bump.
- Combine subscribers (what SwiftUI's `.onChange(of: token)` observes
  under the hood) receive every emission, not a deduplicated stream.
- `FocusFlashPattern.segments` stays non-empty so view layers always
  have something to animate.

OPEN ISSUE: the original report mentioned a "cmd+shift+H" shortcut that
does not appear in `KeyboardShortcutSettings.Action`. Recorded in the
sweep plan §6 for user clarification before promoting any further work.

References:
- docs/v2_platform_excellence_plan.md §4.5
- docs/superpowers/specs/2026-05-07-p0-sweep-plan.md (W4 task card)
…dChangeAt

Closes v2 plan §4.6 (P0). Manually marking a workspace's notifications
unread via the sidebar context menu used to leave them in their original
insertion position, so the user saw no visible signal of the action.

Root cause: `TerminalNotificationStore` published items in insertion
order (`updated.insert(notification, at: 0)` at TerminalNotificationStore.swift:1014).
`markUnread(forTabId:)` only flipped `isRead` and never reordered.

Fix:
- `TerminalNotification` adds `lastReadChangeAt: Date` (defaults to
  `createdAt` so existing fixtures and the deliver-then-show path keep
  natural newest-first order).
- A new total comparator `applyDisplayOrder(to:)` orders by
  `(isUnread DESC, lastReadChangeAt DESC, createdAt DESC)`.
- Every isRead-flipping mutator now stamps `lastReadChangeAt = Date()`
  and re-applies the order before publishing:
  `markRead(id:)`, `markRead(forTabId:)`, `markRead(forTabId:surfaceId:)`,
  `markUnread(forTabId:)`, `markAllRead`.
- Insertion-time `insert(at: 0)` is replaced with `append + applyDisplayOrder`
  so the contract is "every publish is sorted".
- `replaceNotificationsForTesting` also runs the order so test fixtures
  do not produce a different order than production.

Persistence: TerminalNotification is not Codable and the store does not
hydrate from SessionPersistence; no migration path is required.

Tests: TerminalNotificationSortTests pins the comparator semantics and
each mutator's lastReadChangeAt advancement (5 cases). Pre-existing
WorkspaceManualUnreadTests still passes (8/8 green).

References:
- docs/v2_platform_excellence_plan.md §4.6
- docs/superpowers/specs/2026-05-07-p0-sweep-plan.md (W5 task card)
After investigating §4.4 ("dropping a file shows file:// URL instead of
POSIX path") on the live source, both production paths already meet the
design intent:

- Drop path: FileDropOverlayView.onDrop (Sources/ContentView.swift:3501)
  → terminal.hostedView.handleDroppedURLs(_:) → handleDroppedFileURLs
  → TerminalImageTransferPlanner.plan(fileURLs:target:). For a `.local`
  target the planner returns `.insertText(insertedText(for:))`, which
  joins `escapeForShell($0.path)` per URL — POSIX-shell-safe POSIX path,
  never `file://`.
- Paste path: GhosttyTerminalView.swift:101 already does
  `isFileURL ? escapeForShell($0.path) : absoluteString` when synthesizing
  the pasted string from a multi-URL pasteboard.

So this commit does not modify behavior. Instead it pins the contract:
TerminalDropPathPosixContractTests asserts via the public planner entry
that:
- single fileURL plans `.insertText` containing the bare POSIX path,
- a path with whitespace ends up either backslash-escaped or single-
  quoted (never bare),
- multi-file drops join with a space and never carry the `file://` prefix,
- empty input plans `.reject`,
- GhosttyPasteboardHelper.escapeForShell preserves simple paths,
  single-quotes strings with newlines, and escapes bare metacharacters.

A future refactor that drops `escapeForShell` on either path would
silently regress §4.4; these tests catch that.

OPEN ITEM (recorded in sweep plan §6): if the user observed an actual
`file://` URL appearing in the terminal, it likely came from a path that
this codebase doesn't service — e.g. dragging a Finder sidebar location
URL, or the dragging source posting only `public.url` (not `public.fileURL`)
to the pasteboard. Will be revisited if the user provides a repro.

References:
- docs/v2_platform_excellence_plan.md §4.4
- docs/superpowers/specs/2026-05-07-p0-sweep-plan.md (W3 task card)
…k state

After investigating §4.3 ("sidebar drag state stuck") the production
architecture already handles every terminal path:

- `SidebarDragFailsafeMonitor` (ContentView.swift:12783-12903) observes
  NSApplication.didResignActiveNotification, escape key, local + global
  leftMouseUp, plus a CGEventSource mouse-button poll at 200ms cadence.
- `SidebarDragLifecycleNotification` fan-out + `.onReceive` subscription
  at ContentView.swift:12228-12235 resets `draggedTabId` on any clear.
- `SidebarDragFailsafePolicy.shouldRequestClear*` are pure predicates
  already pinned by `SidebarDragFailsafePolicyTests` in
  `iccTests/SessionPersistenceTests.swift:1532-1579`.

No behavior change. This commit pins the two adjacent contracts that
previously had no unit coverage:

1. `SidebarOutsideDropResetPolicy.shouldResetDrag` — only an active
   sidebar drag with a sidebar-type pasteboard payload triggers a reset.
   Catches regressions where a foreign payload (e.g. file URL drag)
   would steal the sidebar reset path.
2. `SidebarDragLifecycleNotification` wire format — `tabId` and `reason`
   userInfo keys plus the "unknown" fallback. Catches regressions where
   a rename/refactor would break silent subscribers.

5 new cases under `SidebarDragLifecycleContractTests`. A live UI test
(`iccUITests/SidebarDragCancelUITests.swift`) remains on the §4.3 OPEN
ITEM list for future work — this commit does not block on it.

References:
- docs/v2_platform_excellence_plan.md §4.3
- docs/superpowers/specs/2026-05-07-p0-sweep-plan.md (W2 task card)
…ership

Step 1 toward v2 plan §4.2 ("browser-to-terminal arrow-key recovery").

When a webview yields its first-responder slot via
`keyWindow.makeFirstResponder(nil)` — which happens during inspector
preflight, panel close, and a few other paths — AppKit leaves the
responder chain at the window level and key events are silently dropped.
The user reports "arrow keys stop working after switching back from
browser to terminal."

The plan calls for a small stack-based focus tracker. This commit lands
the data structure and the producer side; the consumer hookup
(post-`makeFirstResponder(nil)` fallback) requires a surfaceId →
NSResponder lookup that lives in `AppDelegate` and would expand the
allow-list, so it is deferred to a follow-up. Tracked as the §4.2 OPEN
ITEM in `docs/superpowers/specs/2026-05-07-p0-sweep-plan.md`. With the
producer wired and the contract pinned, the follow-up is a clean diff
("subscribe to top()") rather than design + implement + test.

Producer side:
- `Sources/UI/Focus/FocusStack.swift` (new): main-actor singleton with
  `push` (idempotent on top, capped at 16, oldest dropped on overflow),
  `popIfTop` (only-if-top so out-of-order calls do not perturb the
  stack), `top()`, `clear()`, and `depth`.
- `Sources/Panels/IccWebView.swift` `becomeFirstResponder()` pushes a
  `.browser(webViewId:)` frame on success.
- `Sources/Panels/IccWebView.swift` `resignFirstResponder()` pops only
  when the top still matches (defensive against interleaved focus
  changes).

Tests: FocusStackTests pins push/pop/dedup/overflow/equality semantics.
A subtle hazard found in test development is documented in the test
helper: `ObjectIdentifier(NSObject())` of two back-to-back temporaries
can collide once ARC reuses the slot, so the test holds the sentinel
NSObjects with explicit lifetime.

Performance: push/pop are O(1) on a small array; the 16-cap means the
worst-case overflow trim is a single `removeFirst(1)`. Not on any hot
path — only triggered on first-responder transitions.

References:
- docs/v2_platform_excellence_plan.md §4.2
- docs/superpowers/specs/2026-05-07-p0-sweep-plan.md (W1 task card)
…ld falls back

Step 2 of v2 plan §4.2 ("browser-to-terminal arrow-key recovery"). The
prior commit (34b1f21) wired the producer side on IccWebView and left
two follow-ups: terminal-surface push/pop, and a consumer hook so the
fallback actually runs after the webView lets go of first responder.

Producer completion (GhosttyTerminalView.swift):
- `becomeFirstResponder()` now pushes `.terminal(surfaceId:)` onto
  `FocusStack.shared` when the surfaceId is known.
- `resignFirstResponder()` calls `popIfTop` symmetrically. As with the
  IccWebView pair, the only-if-top guard means interleaved focus changes
  do not perturb the stack.

Consumer hook (BrowserPanel.swift `yieldFocusIntent` `.webView`):
- After `window.makeFirstResponder(nil)` succeeds, peek at
  `FocusStack.shared.top()`. If it is `.terminal(surfaceId:)`, look up
  the matching `GhosttyNSView` via the new
  `AppDelegate.focusableTerminalView(forSurfaceId:in:)` reverse lookup
  and call `window.makeFirstResponder(target)`. Same window only — a
  webView in window A must never re-anchor focus on a terminal in
  window B.
- The lookup walks `tabManager.tabs[*].panels.values`, casts each to
  `TerminalPanel`, matches on `id == surfaceId` (TerminalPanel.id is
  `surface.id`), and gates by `view.window === window`. n is small
  (number of workspaces × panels per workspace, both bounded), so the
  linear scan is fine; no need for a global surfaceId index.

Tests: FocusStackConsumerHookTests pins the lookup contract at the
narrowest gates that don't require a full window/key-cycle harness:
- unknown surfaceId → nil
- nil tabManager → nil (no crash)
- surface in different window → nil (cross-window safety)

The "happy path: surface attached to the queried window → returns the
hosted view" runs in production via BrowserPanel; the integration
coverage path remains a §4.2 OPEN ITEM in the sweep plan. The producer
side is exercised by the existing FocusStackTests (10 cases).

Performance: producer push/pop are O(1) on a 16-cap array; consumer
lookup is O(workspaces × panels-per-workspace) on focus transitions
(not on a hot path). No new allocations on the typing or render path.

References:
- docs/v2_platform_excellence_plan.md §4.2
- docs/superpowers/specs/2026-05-07-p0-sweep-plan.md (W1 task card)
The v2 platform-excellence plan §7 S0 calls out "freeze growth" as the
first move before any structural refactor: keep the existing oversized
files (`ContentView.swift` 24,514 lines, `TerminalController.swift`
15,618, `AppDelegate.swift` 13,722, etc.) from getting bigger, so the
S1–S5 decomposition steps can land on a stable target.

This commit lands the guard:

- `scripts/file-size-baseline.txt`: snapshot of every Swift source/test
  file ≥ 1,500 lines as of branch `fix/sidebar-title-freshness`. 28
  entries. Format is `<path> <line_count>` per line; `#` is comment.
  Re-baselining is a deliberate commit (see `--regenerate` below).
- `scripts/check-file-sizes.sh`: pure-shell guard. Default mode reads
  the baseline, recounts each file, and fails (exit 1) on growth past
  `max(+5%, +50 lines)`. Files that have shrunk past 5% trigger a
  non-fatal hint pointing the maintainer at "the baseline can be
  tightened." `--regenerate` rewrites the baseline preserving the
  header comments — intended as a reviewed commit when an offender
  legitimately needs to grow.

Carmack-mode design notes:
- O(n) over the baseline; n is small (28). Pure shell, no `bc`/`awk`
  beyond `awk '{print $1 $2}'`-style splitting; no `jq`/`python`.
- Tolerance picks `max(+5%, +50)` so a 1,500-line file gets a 75-line
  budget but a 24,000-line file gets 1,225. Small files don't get
  stuck on rounding; big files don't get a free pass.
- Shrink-below-5% surfaces as a hint, not a failure: the goal is to
  shrink these files, so we never want the guard to punish progress.

Validation performed (all manual, since this is itself test infra):
- baseline equal to current state ⇒ 0 failures, 0 hints (exit 0).
- forced over-budget value (Sources/ContentView.swift baseline=100) ⇒
  FAIL, exit 1, ceiling reported.
- forced over-baseline value (Sources/TerminalNotificationStore.swift
  baseline=2000 vs 1570 actual) ⇒ shrink hint, exit 0.
- `--regenerate` after corrupting one row to 9999 restores the row to
  the true 1,570 and preserves the comment header.

Not bundled in this commit (deliberate scope guard): a CI workflow that
runs `./scripts/check-file-sizes.sh`. CI workflow files are shared
infrastructure and are left for a follow-up review with the user.

References:
- docs/v2_platform_excellence_plan.md §7 S0 "证据基线"
- docs/superpowers/specs/2026-05-07-p0-sweep-plan.md (next step after P0)
Second of three S0 baseline tools (after `check-file-sizes.sh`). The
v2 plan §2.2 / §2.3 / §2.5 diagnose the SwiftUI typing-latency churn
as caused by:

  1. Heavy `@Published` counts on big model objects. Adding one more
     `@Published var` to `Workspace.swift` re-dirties the entire
     workspace UI tree on every mutation; the §4.1 `SidebarTabSummary`
     projection landed exactly to bypass this for the sidebar path.
  2. NotificationCenter `addObserver` / `removeObserver` imbalance.
     A growing diff is a dangling-observer smell.

Captured 2026-05-07 baseline:

  Sources/Workspace.swift                62  @published  ← plan §2.3 cited 51; drifted +11
  Sources/Panels/BrowserPanel.swift      25
  Sources/TabManager.swift               11
  Sources/SidebarExplorers.swift          8
  Sources/Panels/TerminalPanel.swift      5
  Sources/Panels/FilePanel.swift          5
  Sources/GhosttyTerminalView.swift       5
  Sources/WorkspaceContentView.swift      4
  Sources/Update/UpdateViewModel.swift    4
  Sources/Panels/MarkdownPanel.swift      4
  Sources/iccApp.swift                    4
  Sources/TerminalNotificationStore.swift 3
  Sources/ContentView.swift               3
  Sources/Update/UpdateTitlebarAccessory.swift 1
  Sources/SidebarSelectionState.swift     1

  notify_balance addObserver=107 removeObserver=56 (diff=51)

Tolerance:
  - per-file `@Published`: actual ≤ baseline ⇒ OK; baseline+1..+2 ⇒
    informational warn (still exit 0); > baseline+2 ⇒ FAIL exit 1.
    `>10%` shrink emits a hint suggesting the baseline can be tightened.
  - notify_balance: current diff (add - remove) > baseline diff ⇒ FAIL.
    Smaller diff is good and emits a hint.

The +2 slack on `@Published` keeps a one-off legitimate observable
addition from blocking PRs; bigger growth must be a deliberate,
reviewed `--regenerate` commit. The intent is the same as the v2 plan
§7 S0 "freeze growth" doctrine: the baseline only loosens by an
explicit, justified act, never by silent drift.

Validation performed (manual; this is itself test infra):
- baseline = current ⇒ 0/0/0 (exit 0) ✓
- forced bigger gap (Workspace.swift baseline=50, actual=62, +12) ⇒
  FAIL exit 1 ✓
- within-slack drift (baseline=60, actual=62, +2) ⇒ warn exit 0 ✓
- shrink (baseline=70, actual=62, -8) ⇒ hint exit 0 ✓
- notify_balance widening (baseline diff=44, current 51) ⇒ FAIL ✓
- `--regenerate` after corrupting Workspace row to 9999 restored 62
  and preserved the comment header ✓

Not bundled here (deliberate scope guard):
- A CI workflow that runs both `check-file-sizes.sh` and this script.
- The third S0 piece, `Telemetry/HotPathSampler.swift`, which needs
  Swift-level instrumentation and merits its own commit.

References:
- docs/v2_platform_excellence_plan.md §2.2, §2.3, §2.5, §7 S0
Two small documentation updates to close the S0 thread:

1. `docs/v2_platform_excellence_plan.md` §1 evidence-snapshot table: the
   Workspace `@Published` count is annotated with the current drift
   (51 when the plan was written 2026-04-30, now 62 at 2026-05-07) and
   links to `scripts/observable-baseline.txt` so a future reader sees
   immediately that the contract has already been silently relaxed by
   +11 and the guard is the thing that stops further drift.

2. `docs/dev/freeze-growth-baselines.md` (new): developer-facing
   explanation of the two S0 guard scripts (`check-file-sizes.sh`,
   `audit-observables.sh`), tolerance policy, regeneration etiquette,
   and what's intentionally not-yet-bundled
   (`Telemetry/HotPathSampler.swift`, CI workflow integration).

No code changes. Zero regression surface.

References:
- docs/v2_platform_excellence_plan.md §1, §7 S0
…rf budgets

Third and final v2 plan §7 S0 baseline tool. The plan §5 sets P95
budgets the codebase has no way to measure today (typing latency,
socket non-focus / focus latency, session restore, snapshot size).
`performOnMainSync` already emits an NSLog past 100ms
(`Sources/TerminalController.swift:2953`), but those events cannot be
aggregated, queried for P95, or asserted against in CI.

This commit lands the read-side primitive:

`Sources/Telemetry/HotPathSampler.swift`
- `record(metric:elapsedMs:)` aggregates count + sum + min + max + a
  256-slot reservoir under an `os_unfair_lock`. Hot-path cost is one
  index write plus a few integer adds.
- `snapshot(metric:)` / `snapshotAll()` returns immutable views; an
  approximate percentile (nearest-rank with linear interp) is computed
  from the reservoir on demand.
- `measure(metric:_:)` wraps a synchronous block and records its
  elapsed time, threading the result back to the caller.
- `isEnabled` defaults false. The plan §7 S0 explicitly schedules a
  collect-only phase before any CI gate, so the sampler ships dark.
- `HotPathMetric` enum collects the stable metric names the plan §5
  budgets refer to (`typing.latency`, `socket.nonFocus`, etc.) so the
  call sites stay greppable and typo-resistant.

Carmack-mode design notes:
- No heap growth on the hot path. Reservoir is fixed-capacity circular;
  older samples are overwritten in place.
- Disabled-sampler cost is a single bool check at the top of `record`.
  Same for the `measure` wrapper.
- String-keyed map intentionally — the metric set is small and bounded,
  and a string key keeps the call sites readable. If the set ever turns
  dynamic, this becomes a hash-stable enum.

Wiring to live hot paths (forceRefresh, performOnMainSync, socket
dispatch) is intentionally NOT in this commit. The plan §7 S0 says
"land collection first, then turn on gates." Hooking the keystroke
path also needs a measurement of "type to pixel" that is not reachable
without GPU frame-flush hooks; that follow-up will need its own
design pass.

Tests (HotPathSamplerTests):
- Disabled sampler is a no-op (no retained samples).
- count / sum / min / max / mean compute correctly.
- Negative, NaN, and infinite inputs are rejected.
- Reservoir caps at `reservoirCapacity` with FIFO eviction.
- Percentile from reservoir for known distribution (1...100).
- Multiple metrics stay isolated; `snapshotAll()` returns sorted keys.
- `measure` wraps a block and threads result + records elapsed time.
- `reset` clears all state.

8/8 green. Combined with the existing P0 sweep and FocusStack tests:
56 tests across the new infra, 0 failures.

References:
- docs/v2_platform_excellence_plan.md §5 (perf budgets), §7 S0
- docs/dev/freeze-growth-baselines.md (now lists all three S0 tools)
Moves the sampler from 'not-yet-bundled' to a dedicated section that
documents its role, the `HotPathMetric` name registry, the coverage
in `HotPathSamplerTests`, and the `isEnabled` collect-only stance.

The new 'not-yet-bundled' list now correctly lists the two real
remaining pieces: sampler wiring to live call sites, and a CI workflow
that runs the guards + exports the sampler's P95.
Bundles the v2 platform-excellence §4 P0 sweep and §7 S0 baseline tools:

- §4.1 sidebar title freshness: SidebarTabSummary projection
- §4.5 ring-flash token contract pinned
- §4.6 notification sort uses lastReadChangeAt
- §4.4 terminal drop POSIX path contract pinned
- §4.3 sidebar drag lifecycle contract pinned
- §4.2 FocusStack producer + consumer fallback for browser → terminal
- §7 S0: check-file-sizes.sh + audit-observables.sh + HotPathSampler

12 feature/fix commits, 56 new tests (0 failures), 2 guard scripts
(0 violations).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- web/app/site-config.ts: bump version reference to v1.10.20 (146)
  with the new SHA256 / size for the freshly notarized DMG.
- web/app/marketing-home.tsx: rewrite the release banner panel for
  zh-CN and EN to lead with the v2 platform-excellence first wave
  (background title freshness, focus recovery, unread sort, file-
  size + observable-graph baselines). The four sanity-check bullets
  call out what users should see immediately after the upgrade.
- CHANGELOG.md: add the [1.10.20] section with Added / Fixed /
  Changed groupings keyed to v2 plan §4 / §7 S0.
- docs/USAGE.md: bilingual (Chinese-first) end-user usage guide
  covering install, workspace mental model, splits, focus ring,
  remote SSH, browser pane, notifications, source control,
  supervisor, command palette, the new performance baselines, and
  the bundled icc CLI.

The remaining 17 of 19 marketing-copy.ts locales are intentionally
unchanged in this commit — they fall back to existing copy until a
proper translation pass lands. The release banner above is the
user-facing surface that needs to be current; the long-form
hero/cards stay as they were so we don't ship half-translated copy.
- web/app/[locale]/posthog.tsx: `r.icc.com` resolved to a Pressable IP
  whose TLS certificate is for `pressable.com`, producing 9 console
  errors per page load (cert CN mismatch). Switched the api_host to
  PostHog's official `https://us.i.posthog.com` until a self-hosted
  proxy is properly TLS-terminated. Open DevTools on the homepage now
  shows 0 errors instead of red.

- web/app/marketing-home.tsx: lifted the visual authority of the hero
  region without tearing up the layout:
  * Added a single trust-badge row directly under the CTA buttons:
    'Apple Notarized', 'Sparkle Auto-Update', '56 Tests Passing',
    'macOS 14+'. Bilingual (zh-CN labels for Chinese locales). Each
    badge is a tiny SVG checkmark + label, no images, no extra HTTP.
    These are exactly the four signals a first-time visitor uses to
    judge whether this is a real product before scrolling.
  * Re-styled the four release-panel sanity-check items as an emerald
    checklist (border-emerald-500/20, ✓ icon) instead of the previous
    flat grey cards. The four items already exist as bilingual copy;
    only the visual layer changed.

No layout / structure changes elsewhere; the sticky orb panel, hero
cards, and right-column terminal preview stay as they were.
Investigated further on 2026-05-08:
- DNS for `r.icc.com` now resolves to Pressable shared hosting IPs.
- TLS certificate served at that endpoint has CN=pressable.com — not a
  valid match for r.icc.com, so the browser was emitting ~9 console
  errors per page load.
- The PostHog `/decide` endpoint at r.icc.com returns 'Domain not
  found', confirming the proxy is not actually serving any PostHog
  traffic to the project.

So the previous configuration was producing noise without producing
analytics. The api_host stays at `us.i.posthog.com` (already deployed
in 69523e6). This commit is a comments-only update so future readers
of the file are not misled into thinking r.icc.com is a working proxy
that just needs a cert renewal — they will instead see the option of
standing up a project-controlled proxy on `r.iccjk.com` if China-side
latency becomes a real concern.

No runtime behavior change. No redeploy needed.
Plan §7 S1 budgets 3 weeks for the control-plane reorg. Slicing it into
8 mergeable commits so we can ship continuously and roll back at any
boundary. This commit is step 1: the design spec only — no source
code is moved yet.

Measured current state (2026-05-08):
- TerminalController.swift  15,618 lines (plan baseline 14,384, +1,234)
- v1 socket cases           228 (plan baseline 222)
- v2 socket cases            98 (plan wrote 186; gap is dead-code removal)
- performOnMainSync callers 174 in this single file
- processV2Command          437 lines, processCommand 356 lines

Plan §7 S1 acceptance carried into the spec verbatim:
  v2 non-focus P95 drops ≥ 15% from baseline; tests_v2 alignment stays
  green; TerminalController shrinks to ≤ 6,000 lines.

Migration order:
  step 2  Extract MainActorHop
  step 3  Land Control/Envelope + Router/CommandRegistry
  step 4  Migrate system.*           (4 cases)  + manifest snapshot test
  step 5  Migrate window/workspace/notification (~25 cases)
  step 6  v1 cases become thin adapters into v2 handlers
  step 7  Verify TerminalController.swift ≤ 6,000 + acceptance suite

References:
- docs/v2_platform_excellence_plan.md §2.1, §3.3, §7 S1
- docs/superpowers/specs/2026-05-08-controlplane-v2-design.md
codex added 26 commits May 8, 2026 09:32
v2 plan §7 S1 step 2: factor the socket-thread → main-thread
trampoline out of TerminalController so all hops route through one
instrumented path. Plan §3.3 + §7 S1 acceptance.

Sources/Control/Router/MainActorHop.swift (new):
- Single `shared` instance + per-hop instrumentation hook into
  HotPathSampler under HotPathMetric.mainThreadHop.
- Preserves the previous semantics exactly: main-thread short-circuit,
  DispatchQueue.main.sync trampoline, 100ms NSLog warning. The threshold
  is a configurable property so tests / DEBUG builds can tighten it
  without editing call sites.
- The sampler hook is async-dispatched to main and is gated on
  `hop.sampler != nil` so the socket-thread hot path stays
  allocation-free when telemetry is disabled (default).

Sources/TerminalController.swift (touched):
- The private `performOnMainSync` helper is now a 7-line proxy that
  delegates to MainActorHop.shared.sync. The 174 call sites in this
  file keep working without source changes — same signature, same
  semantics, but every hop now flows through the new primitive.

iccTests/MainActorHopTests.swift (new):
- 4 cases: main-thread short-circuit, background trampoline +
  telemetry recording, no-sampler no-op, configurable warn threshold.
- Uses `@MainActor` test class because HotPathSampler is main-actor-
  confined (its `isEnabled` and `reset` mutate isolated state).

71 sweep tests + 28 file-size baselines + 15 observable baselines all
green. TerminalController.swift dropped by 3 lines (12-line helper →
8-line proxy). Real reduction from this work happens in step 7 once
the new primitive can replace the file-private helper entirely; this
step preserves backward compatibility while opening the migration
path.

Next step (step 3): land Sources/Control/Envelope/ + CommandRegistry
skeleton so handlers can register without touching TerminalController.

References:
- docs/v2_platform_excellence_plan.md §3.3 (perf hop budget), §7 S1
- docs/superpowers/specs/2026-05-08-controlplane-v2-design.md §3.4
v2 plan §7 S1 step 3: the typed control-plane envelope + registry that
v2 handlers will target. No socket traffic routes through the new
primitive yet — that wiring lands in step 4 alongside the first
namespace migration (system.*).

Sources/Control/Envelope/ControlEnvelope.swift (new):
- `ControlRequest` (id / method / params). Any? on id preserves the
  legacy wire format where id can be string / int / null.
- `ControlError` enum with stable rawValues; these are the strings
  external clients pattern-match. Adding a new code is a wire break.
- `ControlResult` = .ok(Any) / .err(code:message:data:). Shape-
  identical to the file-private V2CallResult in TerminalController.

Sources/Control/Envelope/ControlEncoder.swift (new):
- `encode`, `ok`, `error`, `response`, `orNull`. Byte-for-byte
  identical output to the legacy v2Encode/v2Ok/v2Error/v2OrNull
  helpers; line-oriented socket framing is preserved by escaping
  embedded newlines.

Sources/Control/Router/CommandRegistry.swift (new):
- `CommandRegistry` with register / handler / dispatch / methods /
  reset. Method name is the fully-qualified dot-path (e.g.
  'workspace.remote.configure'); matches do not pre-split.
- `ControlContext` carries tabManager (weak) and mainHop so handlers
  do not each re-thread their own argument list.
- Unknown methods dispatch to .err(.methodNotFound, ...) so callers
  never need a special-case.

All these types are `internal` (not `public`). TabManager is
internal, so ControlContext cannot be wider. The Router namespace is
intentionally closed within the app target today; if it ever needs to
be embedded elsewhere we will widen together.

iccTests/ControlEnvelopeTests.swift (new):
- 12 cases:
  * ok / error shapes (id types, embedded newlines, fallback on bad
    JSON, optional data, response wrapper).
  * ControlError rawValues pinned to the legacy wire strings.
  * Registry happy path, unknown-method path, sorted `methods()`,
    `reset`.

This step is wire-compatible: the socket protocol does not change, no
handlers are migrated yet. Step 4 will register the first 4 handlers
(system.*) and land CommandManifestSnapshotTests.

16 sweep-adjacent tests green (12 new + 4 MainActorHop), 28 file-size
baselines and 15 observable baselines 0 violations.

References:
- docs/v2_platform_excellence_plan.md §7 S1 step 3
- docs/superpowers/specs/2026-05-08-controlplane-v2-design.md §3.1–§3.3
v2 plan §7 S1 step 4: first namespace migration. Lands the
production-path wiring of CommandRegistry inside TerminalController
without changing wire-format behavior for any client.

What moved:
- system.ping is now registered into CommandRegistry.shared by
  SystemHandlers.register(into:). The legacy 'case "system.ping":'
  arm in processV2Command remains as a safety net (registry-not-
  registered fallback). After step 7 the legacy arm gets deleted.

What did NOT move (by design):
- system.capabilities, system.identify, system.tree call into private
  helpers (v2Capabilities, v2Identify, v2SystemTree) that still own
  TerminalController state (v2Ref bookkeeping, tabManager.tabs,
  AppDelegate window indexing). De-coupling those helpers is its own
  step; they stay in the legacy switch until then.

How dispatch works now:
  processV2Command(json) →
    parse →
    performOnMainSync { v2RefreshKnownRefs() } →
    withSocketCommandPolicy { method →
      if registry has handler(method): registry.dispatch → ControlEncoder.response  ←  new path
      else:                            switch method { ... legacy arms ... }
    }

So a client calling 'system.ping' goes through the new registry path
and receives byte-identical wire output as before. A client calling
'system.tree' continues to flow through the legacy switch, untouched.

Sources/Control/Handlers/SystemHandlers.swift (new):
- One `register(into:)` function. Returns the names registered so
  the snapshot test can assert against the live API rather than a
  separate hardcoded list.

iccTests/CommandManifestSnapshotTests.swift (new):
- Pins the registered method set to a single hand-written array
  (`expectedRegisteredMethods`). When step 5/6 add namespaces the
  array grows; tests force a deliberate snapshot diff in code review,
  so a typo in a register call shows up immediately.
- 5 cases: SystemHandlers.register returns the names, manifest
  matches, system.ping dispatches via registry, wire output matches
  legacy shape, unmigrated method (system.tree) returns
  .methodNotFound through the registry (which is what
  processV2Command uses to decide fall-through to the legacy switch).

40 sweep-adjacent tests green, including TerminalControllerSocket-
SecurityTests (11) which exercises the v2 socket protocol end-to-end.
file-size and observable baselines 0 violations. TerminalController.
swift grew by 19 lines (the new registry-first dispatch block); will
shrink past baseline once steps 5–7 remove the legacy switch arms.

References:
- docs/v2_platform_excellence_plan.md §7 S1 step 4
- docs/superpowers/specs/2026-05-08-controlplane-v2-design.md §4
…1 step 5)

- Add WindowHandlers (5 methods), WorkspaceHandlers (17 methods),
  NotificationHandlers (5 methods) under Sources/Control/Handlers/.
  All bridge to TerminalController.registryBridge so per-method internal
  state stays private; the handler files are thin shims.

- Extend ControlResult with .errRaw(code: String, message:, data:) so
  legacy raw error codes ("protected", "remote_error", "forbidden"
  variants) round-trip the registry without being clobbered to
  "internal_error". ControlEncoder gains errorRaw(); response()
  switches across all three result cases.

- TerminalController.toControlResult prefers ControlError(rawValue:)
  when the legacy code maps to a typed enum case, otherwise falls back
  to .errRaw to preserve the wire string.

- CommandManifestSnapshotTests.expectedRegisteredMethods grows to 31
  alphabetically-sorted methods covering all four namespaces. Adds
  per-namespace register checks plus an end-to-end manifest snapshot
  comparing the full set after all handlers register.

- start() now registers System/Window/Workspace/Notification handlers;
  processV2Command consults the registry first and falls through to the
  legacy switch when registry returns nil (gates remain in place
  alongside the new path).

Tests: CommandManifestSnapshotTests, ControlEnvelopeTests,
MainActorHopTests, TerminalControllerSocketSecurityTests all green
(26/26 in targeted run; standalone re-run of security tests 11/11).
…1 step 6)

v2Encode/v2Ok/v2Error/v2OrNull in TerminalController.swift are now
thin adapters that forward to ControlEncoder. The duplicated JSON
serialization path is gone; exactly one implementation now owns the
line-oriented wire format.

The legacy function signatures are preserved because ~80 callers still
spell them out; renaming is a separate cleanup that would churn the
diff without changing behavior.

Tests: 36/36 green (CommandManifestSnapshot/ControlEnvelope/
MainActorHop/TerminalControllerSocketSecurity).
…ep 7)

S1 lands the control-plane skeleton:
- Sources/Control/Envelope/ControlEnvelope.swift (78 LOC)
- Sources/Control/Envelope/ControlEncoder.swift (92 LOC)
- Sources/Control/Router/CommandRegistry.swift (135 LOC)
- Sources/Control/Router/MainActorHop.swift (94 LOC)
- 4 handler files registering 31 methods through the registry

Acceptance vs plan §7 S1:
- Skeleton in place: DONE.
- system/window/workspace/notification migration: DONE (31 methods,
  manifest-pinned).
- v1 cases as thin v2 adapters: PARTIAL — wire encoder unified, but
  the per-method routing table is deferred to S2 (each v2Workspace*
  body still references private TabManager state that has to move
  into ControlContext first; doing the move requires a mechanical
  pass that didn't fit S1's 3-week budget without compromising the
  test gate).
- CommandManifestSnapshotTests: DONE (9 tests, 31-method baseline).
- performOnMainSync → MainActorHop: DONE (single trampoline path,
  HotPathSampler integration in place but disabled by default).
- TerminalController.swift ≤ 6,000 lines: DEFERRED to S2 — current
  15,730 (vs 15,618 baseline). The +112 LOC is the registryBridge
  switch added in step 5 that lets handlers stay external while
  internal state stays private. Net file-size baseline guard (28-
  file freeze) still 0 violations.

Tests targeted to this work all green (51/52 — 1 pre-existing
failure unrelated to control-plane in SocketControlPasswordStore).
File-size + observable-graph guards green.
…forwarding

Crash report (1.10.20, 2.5h after launch, mid-drag):
  Exception Subtype: KERN_PROTECTION_FAILURE at 0x16a76be10
  'Could not determine thread index for stack guard region'
  Triggered: com.apple.main-thread, otherMouseDragged path
  Stack: ELIDED 87,010 LEVELS OF RECURSION through
         AppKit forwardMethod + 252 → _nextResponderForEvent:

Root cause:
FileDropOverlayView caches the mouseDown target across drag events so
the original target keeps receiving dragged/up events. The cache is a
weak NSView, gated only by 'window != nil'. Portals (Terminal +
Browser) detach hostViews mid-drag during workspace/split rebuilds —
the cached view stays alive (still has window) but its superview
chain no longer reaches contentView. AppKit, asked to find the next
responder for the dragged event, walks a corrupt chain whose
nextResponder edges lead back into the overlay; 87k frames later the
main-thread stack overflows.

Fix:
1. isForwardedTargetStillLive walks the cached view's superview chain
   and only honors the cache when the chain reaches contentView. A
   detached portal hostView is treated as dead, so the next event
   re-hitTests fresh.
2. responderChainContainsSelf bounds the nextResponder walk; any
   self-edge or cycle (\u003e256 hops) is treated as a loop and the
   forward is refused. This is the defense in depth so even a
   newly-hitTested target can't loop us.
3. viewWillMove(toWindow: nil) drops the drag-target cache before
   AppKit starts tearing down child views, removing the window
   teardown variant of the same bug.
4. Both helpers are 'static internal' so the regression suite can
   exercise them directly.

Tests: 6/6 in iccTests.FileDropOverlayResponderRecursionTests covering
both liveness and chain-cycle invariants.
… resume-on-reopen

Problem:
Closing an imux pane and reopening it starts claude fresh — the prior
conversation is gone even though ~/.claude/projects/ still has the
full transcript. VS Code's agent panel doesn't have this UX gap
because it owns the chat history in-process; our wrapper execs the
upstream CLI each time and the session-id was a fresh uuidgen per
launch.

Fix:
1. imux-agent-common.sh gains a session-id persistence layer keyed by
   (agent, ICC_WORKSPACE_ID, ICC_SURFACE_ID). The on-disk format is
   one file per (workspace, surface) under
   ~/Library/Application Support/icc/agent-sessions/<agent>/<ws>/<sf>.session,
   holding a single UUID. ICC_AGENT_SESSION_HOME overrides the base
   for tests.

2. imux_claude_session_has_transcript() verifies the id's .jsonl
   actually exists under ~/.claude/projects/ before we --resume it;
   a wiped project dir or stale id falls back to --session-id and a
   fresh uuid instead of hanging claude on a missing resume target.

3. Resources/bin/claude now: loads the prior id, verifies its
   transcript, and either 'exec claude --resume <id>' (replay
   conversation) or 'exec claude --session-id <uuid>' (new session,
   persisting the id for next launch).

4. tests/test_agent_session_id_persistence.sh pins the whole surface
   (path determinism, UUID validation, workspace/surface isolation,
   transcript-existence gate) with 10 assertions.

The app side already injects ICC_WORKSPACE_ID + ICC_SURFACE_ID into
each PTY (GhosttyTerminalView.swift:3684), so no Swift change was
needed — this is a wrapper-only landing.
…ersistence)

Codex CLI lacks claude's --session-id 'fresh launch with this id'
flag, so the wrapper takes a different shape but provides the same
guarantee: closing+reopening an imux pane resumes the prior
conversation.

Strategy:
1. On launch in an imux pane (workspace+surface env present, no
   subcommand): if a stored id exists AND its rollout is still on
   disk under ~/.codex/sessions/, exec 'codex resume <id>'. The
   resume path keeps the same id, so no post-launch capture needed.
2. Otherwise: run codex normally as a child, set an EXIT trap, and
   on exit scan ~/.codex/sessions/ for the rollout written after our
   launch marker. Extract its UUID from the
   rollout-<timestamp>-<UUID>.jsonl filename and store it for the
   next launch.
3. Subcommands (exec, login, mcp, resume, fork, ...) bypass the
   resume layer entirely so we don't double-resume or re-enter our
   own logic recursively.

Helpers added to imux-agent-common.sh:
  - imux_codex_session_has_transcript() — "*<UUID>.jsonl" find under
    sessions dir, early-quit on first hit.
  - imux_codex_uuid_from_filename() — awk extracts the trailing 5
    dash-separated segments (8-4-4-4-12 UUID), validates the shape.
  - imux_codex_latest_session_id_since() — find -newer + stat sort
    picks the newest rollout written after a marker file. Bounded at
    32 candidates to keep capture cheap.

Override knobs:
  - ICC_CODEX_RESUME_DISABLED=1 — escape hatch for users who want
    pre-imux behavior.
  - CODEX_SESSIONS_DIR — test override for the sessions dir.

Tests: 18/18 in tests/test_agent_session_id_persistence.sh, including
8 new codex-specific assertions covering UUID extraction (happy path
+ malformed name + non-rollout name), transcript existence, and the
post-launch latest-since capture (newest pick + marker boundary).
…laude mapping

Edge case: if a user manually runs 'claude --resume <id>' or
'claude --session-id <uuid>' inside an imux pane, our wrapper used
to SKIP_SESSION_ID and exec upstream claude without recording the
user's chosen id. The next imux relaunch of that pane would then
fall back to whichever older id we had cached — silently losing the
conversation the user just continued.

Fix:
1. Extract two helpers into imux-agent-common.sh so the wrapper's
   arg parsing is testable:
     - imux_argv_has_session_directive: does argv reference
       --resume/--session-id/--continue/-c/-r?
     - imux_extract_user_session_id: emit the UUID the user pinned
       (separated form, equals form, or bare — bare returns empty
       because we can't know what the picker will select).
2. When SKIP_SESSION_ID fires and we extracted a concrete UUID,
   persist it under the current (workspace, surface) so the next
   imux relaunch honors the user's choice.
3. Extended tests/test_agent_session_id_persistence.sh from 18 → 31
   assertions covering directive detection (bare flags, short form,
   unrelated args) and UUID extraction (separated, equals, bare,
   malformed, irrelevant).

The wrapper is also easier to read now — the ad-hoc per-arg switch
that used to live inline is gone.
Wires a mock claude binary + mock icc socket so the wrapper can run
its full code path (including the icc_socket_available gate) in
isolation. 18 assertions across 5 user-facing scenarios:

  1. Cold launch: no prior id, no transcript → wrapper passes
     --session-id <new uuid>, NOT --resume; writes the id under
     $ICC_AGENT_SESSION_HOME/claude/<workspace>/<surface>.session.

  2. Warm launch: prior id stored + transcript on disk → wrapper
     passes --resume <prior-id>, NOT --session-id (would conflict).

  3. Stale id: prior id stored but its transcript was wiped →
     wrapper detects the missing .jsonl, falls back to --session-id
     <fresh uuid>, and rotates the stored mapping to the new id.

  4. User --resume <uuid>: wrapper passes the user's flag through
     unchanged AND updates the stored mapping to the user's chosen
     id, so the next imux relaunch resumes the same conversation.

  5. User --continue: wrapper does not inject --session-id (would
     conflict), passes --continue through cleanly, and leaves the
     stored mapping untouched (--continue carries no UUID we can
     capture without intercepting claude's picker).

The mock claude reads ICC_E2E_ARGV_LOG to record argv, so multiple
invocations can be inspected without the wrapper or mock having to
parse /dev/stdin. Mock socket is created via python's AF_UNIX bind
to satisfy the wrapper's [[ -S "$ICC_SOCKET_PATH" ]] gate.
MarkdownPanelPointerObserverView has the same shape as the
FileDropOverlayView bug: weak forwardedMouseTarget cached at
mouseDown, dispatched to in mouseDragged/mouseUp without checking
that the cached view's superview chain still reaches contentView.
A portal/split rebuild between mouseDown and mouseDragged would
hand AppKit a detached view; nextResponder walk then loops.

Fix:
- mouseDragged + mouseUp now gate the forward through
  FileDropOverlayView.isForwardedTargetStillLive (the same helper
  that fixed the original 1.10.20 crash).
- viewWillMove(toWindow: nil) drops the cache before AppKit tears
  down the parent chain, mirroring the FileDropOverlayView fix.

The existing helper is already 'static internal'; nothing to
expose. Two new regression tests in
FileDropOverlayResponderRecursionTests pin that the helper is
reusable across consumers (8/8 green).
Two SSH-related issues found while auditing the remote-connection
chain:

1. Stale-credential lockout
   icc_ssh_run_expect always re-sent the same saved password on each
   reconnect cycle. If the user rotated the server-side password,
   keychain still held the old one and the wrapper's 2-second retry
   loop would hammer SSH every 2 seconds with the wrong credential —
   eventually locking out the account.

   Fix: track consecutive auth failures (SSH exit codes 5 and 255).
   After 2 in a row, print a clear hint ('update saved password via
   Manage host → Password') and bail. Non-auth disconnects (network
   blips, server-side close) reset the counter so genuine reconnects
   still work.

2. Defense-in-depth password echo
   expect's log_user 1 leaves spawn IO mirrored to the tty. SSH
   servers normally disable terminal ECHO during a password prompt,
   but a misconfigured server could surface the password on screen.
   Toggle log_user 0 across the password write and re-enable
   immediately after — interactive output post-auth stays normal.

Tests: 38/38 in WorkspaceRemoteConnectionTests still green.
Solves the remote-side gap in P1 session recovery: when imux opens
an SSH workspace, the user's prior conversation with claude/codex
on that host should resume the same way it does locally. Without
this, every reconnect spawned a fresh session because remote claude
had no idea about our (workspace, surface) → session-id mapping.

Mechanism:
1. RemoteWrapperBootstrap.swift builds a deterministic bash snippet
   that materializes our 3 wrapper files (claude, codex,
   imux-agent-common.sh) under $HOME/.icc/bin/ on the remote host
   and prepends that dir to PATH. Files are base64-encoded inline
   so no scp/rsync dependency. A 16-char FNV-1a digest of the
   payload gates the write — reconnects are O(1) when nothing has
   changed.

2. SidebarExplorers.workspaceConfiguration reads the local bundle's
   wrapper bytes and emits the bootstrap snippet as the SSH remote
   command:
     ssh -t alias 'bash -lc "<bootstrap>; exec $SHELL -l"'
   The user's login shell still runs interactively after the
   bootstrap; the only externally visible change is that PATH now
   has ~/.icc/bin first.

3. imux-agent-common.sh learned a cross-platform session root:
   Darwin → ~/Library/Application Support/icc/agent-sessions
   Linux/other → ~/.icc/agent-sessions
   so the wrapper logic is identical local and remote.

4. icc_ssh_reset_terminal_history no longer clears the visible
   scrollback on every reconnect — only on the first connect for
   that PTY. Matches VS Code's remote-terminal behavior of
   preserving the conversation log across drops.

Tests:
- 10 new unit tests in RemoteWrapperBootstrapTests pin the snippet
  invariants: install dir creation, idempotent PATH prepend,
  base64 payload, executable chmod, digest determinism + content
  sensitivity + order independence + filename sensitivity, empty
  payload no-op.
- 81 Swift + 31 shell + 18 e2e existing tests still green.

Out of scope (acceptable):
- Old Solaris hosts without 'base64' (the snippet's only hard
  dependency) won't bootstrap. Modern Linux distros and macOS all
  qualify.
- The bootstrap doesn't reach Windows/PowerShell remotes; those
  require a separate strategy and aren't a current target.
VS Code parity gap: dragging files from Finder onto our sidebar's
Files section did nothing. Now each row of the local file tree
accepts file URLs from any source (Finder, mail attachments, web
downloads).

Behavior
--------
- Drop on a directory row → land in that directory.
- Drop on a file row → land in the file's parent directory (matches
  Finder/VS Code Explorer expectations).
- Default operation = copy (non-destructive; the source stays put).
- Hold Cmd during drop = move (consistent rule rather than mirroring
  Finder's volume-dependent default).
- Same-name conflict = auto-pick 'name copy.ext' / 'name copy 2.ext'
  rather than overwrite. The auto-rename keeps the first 64 attempts
  predictable; beyond that we suffix with the unix timestamp.
- Cycle guard: cannot move /a/b into /a/b/c (would create
  /a/b/c/b/c/b/...). Skipped silently.
- Drop-target visual: while drag hovers a row we draw a 1.5pt accent
  outline so the user can see exactly where the drop will land.

Architecture
------------
- New FinderSidebarDrop enum holds the pure decision logic — input:
  cursor path + modifiers + source URL + existence probes; output:
  .proceed/.conflict/.skip. No SwiftUI, no FileManager, fully
  unit-testable.
- FileExplorerRow gains .onDrop(of: [.fileURL]) and a small set of
  static helpers (consumeProviders, currentModifiers) that bridge
  AppKit's NSItemProvider + NSEvent.modifierFlags into the pure
  vocabulary.
- LocalFileExplorerSidebar.handleFinderDrop runs the real
  FileManager copy/move per-source after consulting decide() and
  refreshes the affected directory node so new entries appear.
- Errors surface in the existing errorMessage banner — non-modal
  since the source is still in Finder.

Tests
-----
15 new FinderSidebarDropTests cover modifier resolution (default
copy, Cmd→move, option-alone-still-copies), destination resolution
(directory passthrough, file→parent, empty→root), decision branches
(.proceed, .conflict, .skip variants for source==dest, missing dir,
descendant cycle), and name-suggestion (extension + extensionless +
incrementing collisions). All injected via probes — no real FS
needed.
Companion to e376c08 (local Files drops). Drag any file from
Finder onto a remote SSH workspace's file tree and it uploads
through the existing SSH ControlMaster channel.

Behavior
--------
- Drop on a directory row → upload into that directory.
- Drop on a file row → upload to its POSIX parent directory.
- Drop on the root area → upload to the workspace's current
  remoteDisplayDirectory (the one the user sees as 'cwd').
- Always overwrite (matches what users expect from a deliberate
  drop). No automatic rename on the remote — the destination path
  is exactly what you'd get from 'scp file remote:dir/'.
- Folder uploads are deliberately not yet supported. We surface
  'Folder uploads aren't supported yet.' in the inline error
  banner when only directories were dragged. A follow-up can add
  recursive upload via tar | ssh -- tar -x.

Plumbing
--------
- New RemoteSSHFileService.uploadFile streams local bytes through
  ssh stdin into 'cat > target' on the remote — same pattern as
  saveTextFile but binary-safe (Data(contentsOf:) instead of
  Data(text.utf8)). Timeout scales with file size: 30s base + ~1s
  per 512 KB.
- RemoteFileExplorerNodeRows / RemoteFileExplorerRow gain
  onDropFiles closure plumbed up to
  RemoteWorkspaceExplorerSidebar.handleRemoteFinderDrop.
- The drop visual reuses the same accent-tinted outline overlay
  introduced for the local sidebar, so both sides feel identical.
- Static helpers FileExplorerRow.consumeProviders /
  currentModifiers are reused rather than duplicated — the AppKit
  bridge surface stays in one place.

Tests
-----
3 new FinderSidebarDropTests assertions pin that the destination
resolver works the same way for POSIX remote paths as it does
locally (no separate code path needed). Total drop coverage is
now 18 unit tests; full Swift suite 99 + shell 31 + e2e 18 all
green.
Three follow-on improvements to the Finder→sidebar drop pipeline:

1. Recursive directory upload (remote)
   RemoteSSHFileService.uploadDirectory streams a local directory
   tree to the remote via a tar pipeline:
     local tar c -C parent dirname | ssh remote 'tar x -C destDir'
   Pure pipe — no intermediate archive on disk. Preserves binary
   content, symlinks, modes. Timeout scales with the source's
   total file size (60s base + 1s per 256 KB) so a 1 GB tree gets
   ~70 minutes max before bailing. handleRemoteFinderDrop now
   dispatches files to uploadFile and dirs to uploadDirectory.

2. Intra-sidebar drag now explicitly tested
   Dragging from one row to another in our own file tree already
   worked because AppKit delivers the same public.file-url
   payload as a Finder drag. Three new pinning tests fix the
   contract:
     - drop on own parent → skip(sourceIsDestination)
     - drop into sibling dir → proceed(move)
     - drop a directory onto itself → skip(wouldCreateCycle)

3. Progress banner
   New SidebarDropProgress (ObservableObject) + SidebarDropProgressBanner
   View. Both LocalFileExplorerSidebar and RemoteWorkspaceExplorerSidebar
   now show a thin banner above the file tree:
     - .running: 'Copying file.txt' or 'Copying 3 items (1/3)'
     - .completed: green checkmark, 1.5s auto-fade
     - .failed: red triangle + dismiss button, persists until clicked
   Both sidebars now run their drop work via Task.detached so the
   UI doesn't freeze on large trees.

Tests
-----
- 12 new SidebarDropProgressTests cover the state machine
  (idle/running/completed/failed transitions, item counter clamps,
  auto-idle window, failure persistence).
- 3 new FinderSidebarDropTests for intra-sidebar drag invariants.
- Total drop coverage 21 tests + 12 progress tests + existing
  87 Swift / 31 shell / 18 e2e — 136 green.

Out of scope (acceptable for this pass)
---------------------------------------
- The remote tar pipeline assumes /usr/bin/tar exists on both
  sides; this is true on every macOS and modern Linux distro.
- No real progress bytes-streamed metric — we count items, not
  bytes. Bytes-level progress would need to wire NSProgress through
  the Process pipe, which is a larger surface.
Two parity gaps with VS Code's Explorer closed in one pass:

1. Quick Look on local file rows
   New QuickLookPreviewBridge wraps QLPreviewPanel.shared() and
   gives each file row a 'Quick Look' menu item (space shortcut).
   Native macOS preview — same panel Finder uses, so PDFs, images,
   audio, code with syntax highlighting all light up for free.

2. Remote → remote drag move
   Dragging a row inside the remote SSH explorer now executes
   'mv' on the remote host instead of bouncing the path through
   the conversation insert. The drop receiver inspects providers
   for our ExplorerConversationDragTransfer pasteboard type
   first; remote-path payload routes through a new
   handleRemoteIntraDrop, while plain fileURL drags continue to
   the upload path.

Server-side new helper:
- RemoteSSHFileService.moveRemoteEntry runs
  'mkdir -p "$(dirname dst)" && mv -- src dst' over SSH.
  Refuses no-op self-moves locally (skip the SSH round-trip).
  Local cycle guard refuses moving a directory into its own
  descendant before bothering the server, so the user gets an
  immediate error banner instead of an opaque 'mv' failure.

Drop progress banner reused: moves get the same running /
completed / failed visual treatment as uploads. Tests: 71/71
green across drop, progress, and remote suites.
Pin standard editor keybindings on the local file row context menu
so power users don't have to right-click for every action:

- Cmd+Shift+Return → Rename (Finder uses Return alone, but we'd
  conflict with Open).
- Cmd+Delete → Delete (matches Finder's 'move to trash' habit).
- Cmd+Return → Insert into conversation.
- Cmd+Option+C → Copy path (Cmd+C is reserved for the system text
  copy in case the row label is being edited).
- Space → Quick Look (already wired in the previous commit, listed
  here for discoverability completeness).

The shortcuts only fire while a row's context menu is the focused
menu, which is what SwiftUI's .keyboardShortcut on a Button inside
.contextMenu binds to. Full focusable-tree keyboard navigation
(arrow keys, type-ahead) needs a richer focus story and is left as
a follow-up.
VS Code parity gap: the local Files explorer only let you work on
one row at a time. Now you can Cmd+click to toggle rows in and out
of a selection, Shift+click to extend a range against the visible
flatten of the tree, and the context menu offers batch equivalents
when the set has more than one entry.

Behavior
--------
- Plain click → single select + set anchor (same behavior the old
  code had, just now routed through the selection state machine).
- Cmd+click → toggle the clicked row, anchor moves to it.
- Shift+click → range from anchor to clicked row, inclusive.
  Anchor stays put so successive shift+clicks re-extend from the
  same starting point.
- Context menu gains 'Copy N Paths', 'Insert N Into Conversation',
  'Delete N Items' entries below the single-row actions when the
  row participates in a multi-select.
- Multi-select rows paint the selection fill so the set is visible
  at a glance (previously only selectedFilePath got highlight).

Architecture
------------
- New ExplorerSelection (pure value type): paths + anchor, plus
  applyingClick / pruning / dragPaths helpers. Zero SwiftUI deps.
- FileExplorerNodeRows/Row gain selectedPaths + isMultiSelectMember
  + onRowClick + onBatchAction props; plain-click expand/open still
  runs when modifiers are empty so directory toggling is unchanged.
- LocalFileExplorerNode learns flattenVisiblePaths (used by range
  resolution) and descendant(matchingPath:) (used by batch delete
  to reuse the single-row prompt).
- LocalFileExplorerSidebar owns @State ExplorerSelection, handles
  row click + batch action centrally. Pruning on refresh is wired
  so a 'rm' mid-session doesn't leave ghost selections.

Tests
-----
18 ExplorerSelectionTests pin the state machine (plain / cmd /
shift / anchor recovery / prune / drag-paths / constructors).
Full Swift sweep 132 green + shell 31 + e2e 18 = 181/181.

Out of scope (deferred)
-----------------------
- Multi-file drag: SwiftUI .onDrag is limited to a single
  NSItemProvider. Dragging multiple rows to Finder/terminal
  requires switching to an NSViewRepresentable + NSDraggingSource.
  The batch context menu covers the 80% workflow (bulk copy
  paths, bulk insert, bulk delete) without that surface area.
- Remote sidebar multi-select: only local side is wired this pass;
  the same ExplorerSelection value type can be reused when we
  port it, but remote requires separate SSH-side plumbing.
…ssion

Closes the two follow-ups left after the local multi-select
landing — both sidebars now have parity, and drags out to
Finder/terminal can carry the entire selection.

1. Remote multi-select
   RemoteFileExplorerNode/Row gain selectedPaths +
   isMultiSelectMember + onRowClick + onBatchAction props,
   matching the local explorer. The same ExplorerSelection state
   machine drives both sides — flattenVisiblePaths + descendant
   ported to the remote node so shift-range and batch-delete know
   the same vocabulary.

   Batch context menu offers Copy N Paths / Insert N Refs /
   Delete N Items. Delete prompts a destructive-confirmation
   modal (no remote Trash to recover from), then hands the path
   list to a single SSH round-trip.

2. RemoteSSHFileService.deleteRemoteEntries
   New helper runs 'rm -rf -- p1 p2 ...' over SSH. Refuses
   patently-unsafe paths ('/' / '~' / '~/') as a defense in
   depth, so an upstream bug can't escalate through this
   destructive endpoint. Each path goes through
   shellSingleQuoted — embedded spaces, quotes, and unicode all
   round-trip cleanly.

3. Multi-file drag via AppKit
   New MultiFileDragHandle (NSViewRepresentable wrapping
   NSDraggingSource) layers over each row that's part of a
   multi-row selection. SwiftUI's .onDrag is single-provider,
   so dragging from selected rows previously only shipped one
   URL; the AppKit drag session here begins with N
   NSDraggingItems (one per URL) so Finder, the terminal, and
   any other receiver see the entire selection.

   Drag visuals stack file icons with a 4pt offset to mirror
   Finder's 'multiple files in flight' preview.

   Slop threshold (16pt² = 4pt linear) prevents stray pointer
   jitter from kicking off a drag — single-click → SwiftUI
   Button still selects, mouse moves >4pt → AppKit promotes to
   drag session.

Tests: 132 Swift + 31 shell + 18 e2e all green (181/181).
VS Code's signature 'Files: Go to File' palette now lives in our
local sidebar. Press ⌘P, type a few characters, hit Enter — the
matched file opens in the workspace.

Architecture
------------
Three new pure-Swift building blocks plus one SwiftUI view:

1. FuzzyMatcher (pure logic, 19 tests)
   Subsequence matcher with VS Code-style scoring:
     - consecutive runs (run length × 6 bonus per char)
     - boundary bonus at start / after / _ - . space (+9)
     - CamelCase boundary bonus (+7)
     - basename-exact bonus (+50)
     - first-match position penalty (-0.5/index)
     - length penalty (-0.05/char)
   Returns matched character indices for UI highlight; ranked()
   sorts a candidate list with a deterministic alpha tie-breaker.

2. SidebarFileIndexer (10 tests)
   Walks rootPath via FileManager.enumerator, skipping common
   build/dependency dirs (.git, node_modules, DerivedData, target,
   etc.) and hidden files by default. Caps at 50,000 entries so
   a stray /tmp drop in a project root can't melt the indexer.
   Symlinks/non-regular entries are filtered out.

3. SidebarQuickOpenModel
   ObservableObject that owns the indexed paths + query +
   selected index. prepare(rootPath:) builds the index off the
   main thread; filteredResults is computed on demand from
   FuzzyMatcher.ranked. moveSelection wraps for smooth ↑↓ feel.

4. SidebarQuickOpenPalette View
   480pt floating panel with magnifying-glass + TextField + result
   list (LazyVStack + ScrollViewReader auto-scroll-to-cursor).
   Each row shows basename + parent path; matched chars in the
   basename render in heavy/accent style. ↑↓ moves cursor, Enter
   commits to onPick, Esc dismisses. .onKeyPress wired (macOS 14).

LocalFileExplorerSidebar wires it up:
- @StateObject SidebarQuickOpenModel + @State quickOpenVisible.
- Hidden Button on the background captures ⌘P (no menu surface
  area, doesn't show up in tooltips).
- Overlay renders the palette over a dimming layer; tap-outside
  dismisses; clicking a row also commits.

Tests
-----
- FuzzyMatcherTests: 19 cases covering match/miss decisions and
  every score-ordering invariant we care about (run > scatter,
  early > late, basename-exact wins big, etc.).
- SidebarFileIndexerTests: 10 cases on a real temp-dir tree —
  skip rules, hidden inclusion knob, max-cap truncation, symlink
  filtering, missing root → empty result.
- Full sweep: 161 Swift + 31 shell + 18 e2e = 210/210 green.

Out of scope (acceptable)
-------------------------
- Remote-side ⌘P: requires SSH 'find' or rsync-list to build the
  remote index. Same UI/matcher reuses; only the indexer changes.
  Scheduled as a follow-up so this commit can land without
  cross-cutting the SSH plumbing.
- Persisted recents / scoring boost for recently-opened files.
  VS Code does this; we can layer on top of FuzzyMatcher without
  changing the API.
Two follow-ons that close the last big VS Code parity gaps in the
file explorer.

1. Remote ⌘P
   SidebarQuickOpenModel learned a pluggable indexer closure;
   default reads from SidebarFileIndexer (local), but the remote
   sidebar swaps in an SSH-backed walker:

     RemoteSSHFileService.indexRemoteFiles
       runs
       through the existing SSH ControlMaster, prunes the same
       build/dependency dirs as the local indexer, drops hidden
       files, caps at 50,000 entries via  so the
       overshoot bit detects truncation.

   RemoteWorkspaceExplorerSidebar gains @StateObject
   SidebarQuickOpenModel + a ⌘P background button. Overlay
   gates rendering on remoteConfiguration so the palette never
   renders in disconnected workspaces.

   The palette View itself is unchanged; both sidebars share the
   same fuzzy matcher, the same ↑/↓/Enter/Esc keyboard, and the
   same matched-character highlight.

   Picking a row hands the relative path back to the caller —
   local sidebar joins it into a file URL, remote joins via POSIX
   string concat and routes through onOpenRemoteFile.

2. Inline rename (local)
   FileExplorerRow now renders a TextField in place of the Text
   label when inlineRenamePath matches the row's path.
   onSubmit commits via LocalExplorerMutationController.rename
   (same path validation and error surfacing as the modal flow);
   onExitCommand cancels.

   Selecting Rename in the context menu (or pressing ⌘⇧↩) sets
   inlineRenamePath to the row, the row autofocuses the field on
   appear. No more sheet for the common case — Finder-style edit
   in place.

   The modal LocalExplorerNamePrompt path stays for the 'New
   File' / 'New Folder' creation case where there's no
   pre-existing row to edit; that flow is unchanged.

Tests: 161 Swift + 31 shell + 18 e2e = 210/210 green.

Out of scope (acceptable)
-------------------------
- Inline rename on remote rows: needs a remote-mv plumb that
  doesn't exist yet. Same UI shape will work; only the commit
  closure differs. Deferred.
Major release covering the v1.10.20 → v1.11.0 work:

Added
- ⌘P Quick Open palette (local + remote SSH workspaces) with VS
  Code-style fuzzy scoring and keyboard navigation.
- Inline rename on local file rows (no more sheet for the common
  case).
- Quick Look (Space) on local file rows.
- Multi-row selection in both sidebars: Cmd-click toggle, Shift
  range, batch context menu (Copy / Insert / Delete N), and a
  multi-file AppKit drag session for receivers like Finder and
  the terminal.
- Finder ↔ sidebar drop, both directions, including recursive
  directory upload via tar pipeline.
- Per-(workspace, surface) session-id persistence for claude and
  codex; reopening a pane resumes the prior conversation.
- Remote agent wrapper push to ~/.icc/bin/ on SSH connect, so
  remote claude / codex go through the same session-recovery
  layer as local.
- SSH terminal scrollback preserved across reconnects.

Fixed
- Main-thread stack overflow on long sessions (P0): NSResponder
  recursion in FileDropOverlayView + MarkdownPanel.
- SSH password lockout when remote password rotates (auto-bail
  after 2 auth failures).
- Defense-in-depth password echo suppression in the SSH expect
  helper.

Changed
- Control-plane reorganization (S1): Sources/Control/Envelope +
  Router skeleton, ~31 v2 methods migrated to handler files.
- HotPathSampler telemetry primitive lands as collection-only
  foundation for the v2 plan §5 budgets.

npm: @calm2026/imux 1.10.4 → 1.11.0 (synced to desktop version).
…rver.sh

Two CI fixes for release/v1.11.0:

1. web/bun.lock had drifted from web/package.json (added @swc/helpers
   transitive). bun install --frozen-lockfile rejected it on Linux.
   Re-run bun install locally to regenerate.

2. tests/test_web_download_server.sh was committed without the +x
   bit. macOS 'chmod +x' didn't propagate into the git index, so the
   Linux workflow-guard runner refused to execute it. git update-index
   --chmod=+x fixes the recorded mode.
Previous fix updated git's index mode but a subsequent 'git add' from
the working copy (where macOS still recorded 644) overwrote it back.
Set the executable bit on disk first this time so 'git add' picks up
the right mode and the index stays at 100755.
@mycode699 mycode699 merged commit d886286 into main May 8, 2026
3 of 9 checks passed
@mycode699 mycode699 deleted the release/v1.11.0 branch May 8, 2026 09:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants