Skip to content

[CI VERIFY ONLY] worktree-switcher rebased onto upstream/main#2

Closed
COCPORN wants to merge 56 commits into
mainfrom
verify/worktree-switcher-rebased
Closed

[CI VERIFY ONLY] worktree-switcher rebased onto upstream/main#2
COCPORN wants to merge 56 commits into
mainfrom
verify/worktree-switcher-rebased

Conversation

@COCPORN

@COCPORN COCPORN commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Throwaway PR to run ci.yml (lint + build-app + test) on the rebased worktree-switcher feature against current upstream/main. Not for merge — verifies the rebase compiles and tests pass before refreshing upstream PR supabitapp#370. Will be closed once CI reports.

marvtub and others added 30 commits May 29, 2026 15:24
* Add per-worktree title and color customization (issue supabitapp#300)

Ports the customize-worktree feature onto upstream's new per-row
`SidebarItemFeature` architecture (supabitapp#323). The user-facing surfaces are
the same as before; the wiring changed entirely:

User-facing
- Right-click a non-main, non-pending, non-folder worktree row →
  "Customize Worktree…" opens a sheet with a Title text field and a
  Color swatch row.
- New Worktree dialog gains a "Title & Color" section; the title round-
  trips even when it equals the branch name (no silent suppression).
- Customization survives archive / unarchive and reload.

Persistence
- `SidebarState.Item` gains `title: String?` + `color: RepositoryColor?`,
  optional-encoded so existing `sidebar.json` files decode cleanly and a
  malformed color drops just that one field via the lossy decoder.

Reducer
- `requestCustomizeWorktree(worktreeID, repositoryID)` opens the sheet,
  guarded against folders, main worktrees, and absent worktrees.
- `worktreeCustomization` is a `@Presents`ed `WorktreeCustomizationFeature`;
  save writes title / color into the worktree's currently-owning bucket
  Item and re-runs `syncSidebar` so the per-row mirror updates immediately.
- The New Worktree prompt's `submit` delegate now carries `title` + `color`.
  The parent stashes them in `pendingCreationCustomizations` keyed by
  `(repoID, branchName)`, then drains the entry when `createWorktree-
  InRepository` materialises the `PendingWorktree` (`customization:` field
  carries to the in-flight row so reconcile renders the user-typed title
  / color while git creates the worktree). On success, the customization
  is written to the bucketed Item BEFORE `syncSidebar` so the first paint
  after the pending→real swap is correctly tinted.
- Three drop sites for the customization carrier: prompt cancel, sync
  duplicate, async duplicate, and create-against-removing-repo all
  remove the (repo, branch) entry. `.dismiss` intentionally does NOT
  drop — `.dismiss` also fires when the parent nils the prompt on the
  success path, and the in-flight creation still needs the value.
- `applyRepositories` pending-row prune now matches by `(repo, worktree
  name)` first (count-based fallback for unnamed pending rows). When a
  customized pending row is reconciled out because the real worktree
  arrived in the reload before the per-pending success handler fired,
  its title / color is transferred onto the matching bucketed Item — no
  customization loss across the race.

Per-row state
- `SidebarItemFeature.State` gains `customTitle` + `customTint`, mirrored
  from `@Shared(.sidebar)` by `reconcileSidebarItems`. Pending rows mirror
  from `PendingWorktree.customization` so the user-typed title is visible
  during git create-worktree, not just after the swap.
- `resolvedSidebarTitle` centralises the empty / whitespace fallback rule.

Views
- `SidebarItemView` reads `store.customTitle` / `store.customTint` and
  paints the title accordingly. `TitleView` Equatable updated.
- `ArchivedWorktreeRowView` accepts customization and renders it the same
  way; archived rows preserve title / color across archive (the bucket
  `archive` mutator now carries the source Item forward instead of writing
  a fresh `.init(archivedAt:)`).
- `ContentView` mounts the worktree-customization sheet via the TCA 1.7
  view-side API (`item: $store.scope(state:action:)`).

Tests
- `WorktreeCustomizationFeatureTests`, `WorktreeCustomizationParentTests`,
  `WorktreeCreationPromptFeatureTests`, `WorktreeCreationPromptParent-
  Tests` cover the feature reducers, the parent-side wiring, the pending
  carrier transfer, the name-match prune, the per-row mirror update on
  save, and the main-worktree / folder / pending guards.

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

* Lint: factor applyRepositories pending-prune helpers

Two lint failures from the customization port:
- Cyclomatic complexity (18 vs 15) on `applyRepositories`.
- 3-tuple in the customization-transfer collection.

Extract `prunedPendingWorktrees` (returns filtered pending + transfer
records) and `seedCustomizationForDiscoveredWorktree` (writes transfers
into the bucketed Item). Replace the tuple with a small struct.

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

* Cache invalidations: cover the new worktree-customization actions

`RepositoriesFeature.Action.cacheInvalidations` is the exhaustive
"every action declares its cache impact" switch with no `default` — a
new action that doesn't appear here is a build error. Adding two cases
from the customization port:

- `.requestCustomizeWorktree`: just opens the sheet, no cache touch → `[]`
- `.worktreeCustomization(.presented(.delegate(.save)))`: mutates the
  bucketed Item title / color, which flows into the per-row mirror via
  reconcile and changes the sidebar row label / tint → `.all`
- All other `.worktreeCustomization` variants (cancel, dismiss, etc.):
  presentation-only, no cache touch → `[]`

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

* Worktree customization: refresh selected detail slice

* Fix two test regressions from the customization port

1. `prunedPendingWorktrees` was iterating once and using a single
   filter pass — a pending row with NO discovered-name match could
   hit the count-based fallback BEFORE a later pending row's name
   match drained the budget, wrongly dropping the unmatched row. Split
   into two passes: name matches consume the discovered-name set and
   the budget up front, count-based fires only on the leftover.
   (`reloadMatchesPendingRowsToDiscoveredWorktreesByName` covered it.)

2. `saveDelegatePersistsTitleAndColorToBucketedItem`: the save action
   invalidates every cache (sidebarStructure, selectedWorktreeSlice,
   toolbarNotificationGroups) but the test only asserted the per-row
   mirror update. Pre-warm the post-reduce caches in `makeInitialState`
   so the in-reducer recompute is a delta from a populated baseline,
   and mirror the recompute in the test closure with the existing
   `applyPostReduceCacheRecomputes()` helper.

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

* Fix pending prune to preserve named sibling rows

* Fix pin/unpin to preserve worktree title and color

Pin / unpin moved a row between buckets via `removeAnywhere` + `insert`,
which reinserted a fresh empty Item and discarded user-set `title` /
`color`. Match the archive flow: carry the existing Item forward.

* Pin/unpin: prefer logical source bucket when carrying Item

The first cut of removeAnywhere always returned the `.unpinned` payload
first, which on unpin against a corrupted double-bucket pre-state would
surface the stale unpinned sibling over the live pinned row's title /
color. Take an explicit `preferring:` order and have pin / unpin pass
their logical source first.

* Toolbar notifications: use resolved sidebar title

`computeToolbarNotificationGroups` was emitting `worktree.name` (raw
branch name) for the popover group title, so a customized title set
via `WorktreeCustomizationFeature` wouldn't surface there even though
the save invalidates the toolbar-notification cache. Read through the
per-row `resolvedSidebarTitle` mirror so the cache invalidation
actually changes what the user sees.

* Fix test signatures after rebase onto supabitapp#350 / supabitapp#351

Upstream's New Worktree dialog work added a `placement` parameter to
the prompt's `.submit` Delegate and to `.startPromptedWorktreeCreation`,
plus reshaped `WorktreeCreationPromptFeature.State` to take per-branch
inventory inputs (`repositoryRootURL`, `defaultBranch`, `remoteNames`,
`branchMenu`, `defaultWorktreeBaseDirectory`). Align the parent tests'
state seed and action assertions accordingly.

* Customize Appearance: unify menu label, support folders, refine tint

- Unify the per-row + section-ellipsis entry to "Customize Appearance…" so a
  folder, a worktree, and a git repository all advertise the same affordance.
  Folder synthetic rows route through `requestCustomizeWorktree` (the row IS
  what gets tinted), with the request handler now allowing folder repos and
  picking the repository name as the default label.
- Toolbar nav item: mirror the sidebar's tint policy. The custom title now
  replaces the branch / folder name (matching the row's title text), and the
  user-picked color paints that top-line text. The auto-derived subtitle stays
  on the accent style so it keeps its disambiguating role.
- Sidebar: only the row title takes the custom tint. Subtitles (plain + the
  highlight section trail) stay on the accent style so the tint stays a
  signal of identity, not a sea of colored secondary text.
- Customize sheet: drop the `trimmed == defaultName` collapse on both the
  worktree and the repository flows. Typing the default name "locks it in"
  as an explicit override instead of silently being dropped.
- Carry-forward: extract `SidebarState.mergeCustomization(...)` so both the
  post-create seed and the discovered-worktree seed land in whatever bucket
  already holds the row (falling back to `.unpinned`), instead of writing a
  phantom `.unpinned` entry against a persisted `.pinned` Item.
- Lifecycle: drain the pending customization on the repo-disappeared hard
  error in `.startPromptedWorktreeCreation`, and log the silent drop on the
  customization sheet save when the bucket has gone missing.
- Pin/unpin context menu now gates on `!isPending` so the click no longer
  routes to a reducer no-op.
- Em-dash sweep across the PR-added comments and the user-facing prompt
  text. Add tests for archive/unarchive customization carry, the
  `mergeCustomization` invariants, and `SidebarState.Item` Codable round-trip
  (including the malformed-hex forward-compat drop).

Extracts the worktree-customization reducer arms into
`RepositoriesFeature+WorktreeCustomization.swift` so the main `body` switch
stays under the Swift type-checker's complexity limit.
…ceholder (supabitapp#367)

* Make the new-worktree title & color section collapsible

* Use trimmed branch-name placeholder for the new-worktree title field

* Title the new-worktree appearance section "Appearance"
…h state (supabitapp#369)

* Add attach-aware zmx session listing parser

zmx ls --short emits only the session name, so the reaper cannot tell whether another instance still holds a client. Parse the full ls format through a pure ZmxSessionListParser (name=/clients=, supa- prefix filter, clients=nil for unreachable lines) behind a listSessionsWithClients field that returns nil on probe failure, so an unprobeable session is never reaped. The reaper consumes this exclusively, so the name-only listSessions is gone.

* Add serialized off-main layout merge writer

Per-mutation persistence cannot run the whole-dict encode and atomic write synchronously on the main actor. LayoutsIncrementalWriter is a single FIFO actor that re-reads layouts.json, splices in only the keys a flush carries (positive snapshot or explicit delete tombstone), and writes atomically through the temp+rename storage. It skips the write when the splice leaves the dict unchanged so high-frequency projection ticks do not churn the file. A transient read failure aborts the flush so siblings are not clobbered, while corrupt bytes are rotated aside to layouts.json.corrupt-<timestamp> and persistence recovers, mirroring SidebarPersistenceKey. LayoutsKey.save becomes a no-op so the actor is the sole disk writer.

* Persist terminal layouts incrementally and reap zmx sessions by attach state

Layouts were only written on quit, so a second instance launched mid-session pruned tabs the first instance had just opened and terminated their sessions. Drive a per-worktree debounced markLayoutDirty off the settled tab callbacks (create, close, projection drift, removal, rename, selection), capturing the freshest snapshot plus live agent records at fire time and merging off main; prune deletes immediately so a queued save cannot resurrect a removed worktree, and the on-quit synchronous flush stays the terminal write. reapOrphanSessions and the orphan subset of terminateAllSessions now spare any session that still has a client attached (or an unknown count), so a live instance keeps its sessions. A tab rename now routes through WorktreeTerminalState so a custom title persists incrementally instead of only at quit, while the layout-restore path keeps seeding setCustomTitle directly. Also fixes the close-last-surface-via-close_surface path that returned without emitting a projection, leaving the closed surface's session orphaned.
* Always focus the terminal after worktree navigation

Makes the terminal regain focus whenever you navigate to a worktree outside the
command palette — sidebar click / arrow-nav, worktree hotkey slots, next/prev,
and history back/forward — matching the menu and deeplink paths that already
passed `focusTerminal: true`.

Previously a sidebar selection left focus floating, so every click had to be
followed by a manual click into the terminal to start typing. This honors the
"terminal is the default focus" invariant across all navigation surfaces.

Implemented with AI assistance (Claude Code); reviewed and verified in a real
build.

* Drop the focus inline comments (review nit)

Per review on supabitapp#371: the comments narrated the WHAT, not the WHY. The
`focusTerminal: true` call sites and the history-nav focus block are
self-explanatory; removing the prose.

* Assert focus-terminal action in worktree navigation tests

Keyboard and history worktree navigation now emit a trailing
focusTerminalRequested row action; the navigation tests didn't
assert it and failed on an unexpected received action.

---------

Co-authored-by: Lars Thomas Denstad <ungedenstad@gmail.com>
…ull integration (supabitapp#368)

Surfaces used to be launched by handing Ghostty `command = "zmx attach <id>"`,
which overrode the user's `command` config and made Ghostty's shell detection
and integration target zmx instead of the real shell. The fallout: OSC 7 cwd
reporting stopped working unless the user's own rc emitted it (so split cwd
inheritance went stale), a custom `command` (e.g. nushell) was ignored, and the
macOS login(1) treatment was lost.

Add a `command-wrapper` option to GhosttyKit (a small out-of-tree patch applied
to the pinned submodule at build time; the pin is never moved and no fork is
created). The wrapper's argv is prepended to the command Ghostty resolves after
shell resolution, shell integration, and the macOS login(1) wrapping, so the
resolved shell runs as a child of the wrapper.

Supacode now leaves `command` unset for interactive surfaces and instead passes
`zmx attach <id>` as the wrapper. Ghostty resolves and integrates the user's real
shell exactly as it would without zmx (honoring `command` and `shell-integration`
config), login(1) preserves the integration env via `-p`, and zmx wraps the whole
thing for session persistence. Explicit commands (scripts) keep wrapping the
command string as before. The global `shell-integration = none` override is
dropped, since Ghostty now integrates the real shell rather than the zmx command.

scripts/build-ghostty.sh applies patches/*.patch to the submodule working tree
before building and reverts on exit, so the submodule stays clean and pinned to
upstream. A patch that no longer applies fails the build loudly.
…itapp#374)

- Update runKiroVersionCommand to call 'kiro-cli --version' instead of 'kiro --version'
- Update supportedVersionPrefix from '1.' to '2.' to match kiro-cli 2.x
- Update all test fixtures to use kiro-cli 2.x version strings
- Update log messages and comments to reference kiro-cli

The Kiro project renamed their binary from 'kiro' to 'kiro-cli' and bumped
the major version to 2.x. Users with kiro-cli 2.x installed were seeing
'unsupported version' errors because Supacode was still checking the old
'kiro' binary (0.x) instead of the new 'kiro-cli' binary.
…bitapp#376)

* Cap and coalesce terminal / worktree event streams to bound memory

The terminal and worktree-info event streams used unbounded AsyncStream
buffers, so a producer outpacing the main-actor consumer (e.g. a per-tab
projection / progress / task-status storm) could grow the in-process
buffer without bound and steadily increase memory use over a long session.

Switch both streams to bufferingNewest(2048). Coalesce the latest-wins
terminal state events (tab projection, progress, task status, focus) by
identity so a storm collapses to its last value, dropping only a value
equal to the immediately-previous one per key. Seed the coalesce cache
from the resubscribe replay (both the pending-buffer drain and the
projection re-seed run through emit) so the first live event after a
resubscribe is deduped consistently. Bound and coalesce the
pre-subscription pending buffer, mirror the teardown purge into it, and
clear lastEmittedProjections alongside the coalesce keys on prune. On a
backpressure drop, log a compact identity of the shed event (case plus
key ids, never the payload) so a drop storm can't flood the log.

The worktree-info stream is capped but not deduped: its events are refresh
signals where each repeat is meaningful. Surface a non-cancellation error
from the per-worktree refresh loop instead of breaking silently.

Add tests for the coalescing, the live and pending buffer caps, the
teardown purge, lifecycle events never coalescing, the pending-replay
cache seeding, and the worktree-info buffer cap.

* Speed up and de-flake the presence test harness drain

drain() ran Task.megaYield(count: 10_000) up to 64 times per call, and each
megaYield spawns `count` detached tasks, so a single drain churned hundreds of
thousands of tasks; the settle check also never early-returned. Socket-presence
tests that drain several times took 13 to 43 seconds each despite a TestClock.

Lower the per-pass yield count and settle on a genuinely quiescent pass
(consumer parked, nothing processed, and no idle-hook debounce still scheduled
via a test-only count on the manager). The last clause closes the race where
clock.advance returned but the awoken idle task had not yet emitted, which would
otherwise let a busy suite conclude "idle" too early. The six socket tests drop
from 13 to 43 seconds to between 0.06 and 1.5 seconds with no behavior change.
* Strip login-shell noise from GitHub CLI JSON output and surface decode failures

A login shell sources .zprofile/.zlogin before gh runs, so banner or version-manager
output prepends to captured stdout and corrupts the JSON. JSONDecoder then throws a
DecodingError that surfaces in the GitHub settings pane as the opaque 'The data couldn't
be read because it isn't in the correct format', even though gh is installed, authed, and
returns valid JSON in the user's terminal.

Enumerate every balanced top-level JSON value ({...} or [...]) in the output, skipping
openers that never balance (a stray brace from a verbose shell), and decode the last
decodable span since gh prints its JSON after any leading noise. Applied at all five gh JSON
decode sites that share the polluted stdout via runGh. A missing payload throws a
shell-pollution message; a present-but-undecodable one throws a version-incompatibility
message (only when the span was valid JSON) and logs the underlying error. latestRun and
resolveRemoteInfo go through decodeIfPresent so a no-payload result stays nil (no runs / no
remote) while leading noise is tolerated and a genuine parse failure is logged. Also fix the
latent multi-host bug where auth-status scanned an arbitrary host: it now prefers github.com,
then a stable sort, so selection is deterministic even when more than one host is active.

Closes supabitapp#377

* Test GitHub CLI noisy-output parsing, decode-error wrapping, and multi-host auth

Cover the balanced-span scanner (clean object, banner-prefixed object and array, trailing
noise, braces and escaped quotes inside strings, two top-level values in order, a stray
unbalanced opener that is skipped, pure noise/empty/unterminated yield no spans), the
deterministic multi-host active-account selection (non-first host, prefer github.com, sort
when github.com absent, no active account), and end-to-end through the live client that
banner-polluted stdout still decodes for authStatus, resolveRemoteInfo, defaultBranch, and
latestRun; that latestRun skips a stray-brace banner and prefers the real array over a
leading empty-array banner; and that a missing payload, bracket-only noise, and an
undecodable payload throw their respective GithubCLIError.commandFailed messages.
…pp#381)

* Drop unused SidebarItemFeature shortcut-hint state

Sidebar rows read the hint from the parent's `shortcutHintByID` join,
so the per-row `shortcutHint` field and its setter action go unused.
Remove them, prune the cache-invalidation entry, and trim the matching
test to the drag-session guard that still applies.

* Cross-fade shortcut hints and drop the hold-to-reveal delay

Hold-Cmd flips instantly and the hint affordances cross-fade in place:
a fixed-width trailing slot on terminal tabs and an opacity ZStack on
sidebar rows. Drops the toolbar accessory text swap, the PullRequest
"Open on GitHub" hint, and the WorktreeDetailView showExtras plumbing
that drove the open-menu label rewrite. CommandKeyObserver picks up a
class-level note clarifying it tracks ⌘ or ⌃, and the sidebar cross-fade
reuses fadeAnimationDuration so both stay in lockstep.
* Expose OSC 3008 context signals as a libghostty apprt action

Carry the OSC 3008 hierarchical context signal (UAPI spec) through the
zig stream into the apprt layer. New `context_signal` action variant
on Action / apprt.Message holds an `action` (0=start, 1=end), an `id`
clamped to 64 bytes, and a `metadata` buffer sized to 2047 bytes so a
base64-encoded notification payload fits alongside the small presence
fields. The Stream handler pins the u8 wire mapping with a compile-time
assert so a future ghostty bump that reorders Action fails loudly.

Both the id and metadata truncation sites log when the parser hands
over more bytes than the buffer can hold, so a future spec-cap raise
can't hide a regression on either field.

* Route OSC 3008 context signals through GhosttySurfaceBridge

Add an `onContextSignal` callback fed by a dedicated
`handleContextSignal` arm of `handleAction`. The callback receives the
u8 action discriminator, the id, and the raw key=value metadata, all as
Swift strings.

Null id or null metadata drops the signal with a warning rather than
substituting empty strings, so an ABI regression on the C side can't
silently feed garbage to the terminal-state ingest. The new
`contextSignalDropsNullIDOrMetadata` test pins that contract.

* Define the OSC 3008 presence and notify wire format

`AgentPresenceOSC` is the single source of truth for the OSC 3008
agent-presence/notify wire shared by the emit side (agent hooks) and
the parse side (the app). It owns:

- The emit shell snippets: `ttyResolveSnippet` (recovers $__tty from
  the parent agent's controlling terminal, since the hook itself has
  none), `emitShell` (presence), `emitNotifyShell` (notify base64).
- The parse helpers: `parse` for presence, `parseNotify` for the
  base64 envelope, `isNotifyMetadata` for the routing check, and
  `parseFields` for the key=value split.
- The trust check `tokensMatch`, an equal-length constant-time compare
  against the per-surface nonce echoed in `SUPACODE_OSC_TOKEN`.

`parsePid` rejects 0/negative pid values, so a buggy hook (e.g.
`pid=$$` from a subshell whose parent recycled) can't pin a permanent
badge via `kill(0/-N, 0)` matching the caller's process group.
`parseFields` rejects any metadata that repeats one of the
trust-bearing keys (`token=`, `event=`, `kind=`) so an attacker who
can splice into the wire can't override a legitimate field with a
later occurrence under the historical last-write-wins.

* Switch agent hook emits to OSC 3008 as the sole transport

The shell and Pi-extension hooks now emit presence and notify entirely
as OSC 3008 sequences via `AgentPresenceOSC`. The socket leg is
dropped: OSC reaches the app over SSH too, where the local Unix socket
can't, so the previous dual-transport split was redundant.

`AgentHookSettingsCommand.compositeCommand` composes one guarded brace
group (opened by the `ttyResolveSnippet`) that emits the presence OSC
per event in order, then the notify OSC when stdin forwarding is on,
then closes with the error-suppression tail so a hook failure never
breaks the agent. `compositeCommand` is gated on
`SUPACODE_OSC_TOKEN` so the hook is a no-op outside Supacode surfaces.

The Pi extension mirrors the same shape in JavaScript: it resolves
`/dev/tty` (the host shell's, not the hook's own), emits the OSC
sequences, and rate-limits stderr noise to one warning per minute with
each err field (`code` / `errno` / `message`) hoisted out of the
template so the line fits the lint cap.

`AgentHookCommandTests` covers every branch, including a
fully-inlined byte snapshot for the `claudeBusy` composite that
survives a coordinated refactor of both production code and the helper
DSL the other snapshots compose from.

* Drop agent presence and notifications from the socket, keep it for the CLI

`AgentHookSocketServer` shrinks back to the two CLI-control message
shapes (`command` and `query`): the agent presence and notification
legs are gone now that hooks ride OSC 3008 directly. The agent JSON
body decoder stays as `parseNotification` since the OSC notify path
reuses it to decode the base64 payload.

The synthesizing memberwise init on `AgentHookEvent` now mirrors the
Decodable path and clamps a non-positive pid to nil with a warning, so
a future caller that bypasses the wire validation can't ship a
forged-but-typed event that would pin a permanent badge in the
liveness sweep.

The matching tests prune the socket-level presence/notification cases
and keep coverage on the surviving CLI deeplink and query message
paths plus the agent-JSON body decoder.

* Teach AgentPresenceFeature about pid-less OSC-seeded records

The reducer used to assume every `PresenceRecord` carried a local pid
the liveness sweep could check. With hooks now riding OSC over SSH, a
remote `session_start` arrives with no pid attribution. Treat
`pids.isEmpty` as the discriminator for the pid-less lifecycle:

- `sessionStart` with no pid seeds a pid-less record, but never
  clobbers a record that already carries a pid.
- `sessionStart` with a pid both seeds a fresh record AND upgrades a
  pre-existing pid-less one in place (an SSH attach followed by a
  local hook landing for the same surface).
- `sessionEnd` with a pid removes that pid and drops the record once
  it goes empty, so the upgrade case cleans up via the same path.
- `sessionEnd` with no pid only tears down records whose pids set is
  already empty, so a still-live pid-bearing record stays.
- `applyActivity` auto-seeds a pid-less record only when the
  activity would carry a badge: SSH attach can land on `busy` /
  `awaiting_input` with no prior `session_start`, but an `idle`
  arriving after the documented `session_end` + `idle` composite
  shutdown emit must NOT re-create the record. Re-seeding on idle
  leaves a pid-less record the sweep cannot reap, pinning the badge
  until surface close.

The liveness sweep already filters records by `pids.isEmpty` so it
never touches a pid-less record, and `stageRestore` filters out
pid-less records on relaunch so they re-seed cleanly from the next OSC
event.

* Make WorktreeTerminalState the OSC ingest funnel for hooks and OSC 9 dedupe

`WorktreeTerminalState` now owns the full app-side ingest for hook
signals. The surface ID is the only scope (the wire never carries a
worktree or surface id), so attribution can't be spoofed across
worktrees.

- Each surface gets a 128-bit hex `SUPACODE_OSC_TOKEN` minted by
  `makeOSCToken` (`SecRandomCopyBytes` with an `arc4random_buf`
  fallback so a degraded RNG never returns to UUID entropy) and
  injected into the environment. The token is the per-surface
  capability nonce checked by `tokensMatch` before any signal is
  trusted, and it's wiped in `cleanupSurfaceState` alongside the
  pending OSC 9 task and last-custom timestamp.
- `handleContextSignal` routes by intent (`isNotifyMetadata`) so a
  notify whose payload was truncated past the cap logs as a notify
  drop instead of silently falling through to presence.
  `handlePresenceSignal` and `handleNotifySignal` then unpack
  through pure `presenceEvent` / `notification` decisions that
  return typed `PresenceDrop` / `NotifyDrop` failures so each cause
  gets the right severity (warn for spoof-shaped failures, debug
  otherwise). The notify drop log carries the metadata byte count so a
  Ghostty 2047-byte truncation can be correlated with the Swift drop.
- The agent's own OSC 9 desktop notification is now held briefly and
  deduped against the custom (hook / OSC 3008) notification it
  summarizes. The window is split into two named constants
  (`oscSuppressionAfterCustom` for the post-custom suppression,
  `oscHoldWindow` for the pre-fire hold) so future tuning of one
  knob doesn't silently move the other. The `elapsed` helper fails
  open on a clock-type mismatch so the suppression can't stay pinned
  true forever.
- The decoded notification payload runs through
  `sanitizeNotificationText`: title/body get bounded and stripped of
  C0 controls (with newline/tab/CR collapsed to a space) so an OSC
  escape embedded in the agent's output can't reach the toast.

`WorktreeTerminalManager` drops the socket-level
`onNotification` / `onEvent` wiring (the socket no longer carries
either) and routes OSC-sourced presence events through the same
`dispatchHookEvent` idle-debounce funnel as before.

* Cover OSC presence end-to-end through the bridge and dispatch funnel

`AgentBusyStateTests` now drives the full OSC funnel: a live surface
gets its env-injected `SUPACODE_OSC_TOKEN`, a synthesized OSC 3008
metadata payload rides through `onContextSignal` to
`handlePresenceSignal`, and the resulting `AgentHookEvent` lands in
the manager's `dispatchHookEvent`. Coverage includes the spoof
scenarios that have to drop:

- A presence signal with a mismatched token never reaches
  `onAgentHookEvent`.
- A presence signal carrying surface A's token delivered to surface B
  is rejected on both surfaces (per-surface token isolation).
Notifications rode OSC 3008 by base64-encoding the agent's entire hook-stdin
JSON. For a real turn that JSON ends with last_assistant_message and routinely
exceeded libghostty's 2048-byte OSC buffer, over which the parser discards the
whole sequence (it does not truncate), so substantial turn-completion
notifications never reached the app while the tiny presence badges did.

Extract just the display fields at emit time instead:

- The wire is now kind=notify;token=...;title=<base64>;body=<base64>. A portable
  awk pass (no jq/python, so it works over SSH), run under LC_ALL=C so its
  length/substr cap is byte-based on gawk too, pulls the title and the first
  non-empty body key (message, last_assistant_message, assistant_response),
  byte-capped so the wire stays well under the OSC ceiling. parseNotify decodes
  the fields directly; the app no longer parses agent-specific JSON.
- decodeNotifyValue sheds trailing bytes until the value parses as a JSON string,
  so a field cut mid-escape or mid surrogate pair previews instead of dropping.
- Title and body are both optional: a body-less notify shows a title-only toast,
  and a body present on the wire but decoded empty is logged rather than dropped
  silently.
- The Pi extension builds the same title/body wire natively.
- ghostty patch: OSC 3008 now captures with an allocating buffer, so an oversized
  sequence keeps its title and earlier fields rather than being discarded whole
  (a backstop; the emit budgets keep the wire under 2048).

Removes the now-dead socket-era JSON body decoder.
The per-surface token gated both presence and notify signals, but it
went stale whenever a zmx session was reattached across an app restart:
the running agent kept its original SUPACODE_OSC_TOKEN while the app
rolled a fresh nonce for the restored surface. Every rich notification
from that session was then rejected on token mismatch, while the agent's
own OSC 9 toast (which carries no token) still showed, so the generic
"needs your input" notification won over the extracted title/body.

The token only guarded against a program spoofing a notification or
presence badge, and that text is already control-char-sanitized and
length-capped, so the worst case is a spurious toast. Remove it: the
wire no longer carries token=, the app no longer verifies it, and
emission gates on SUPACODE_SURFACE_ID (present on every surface) instead.
…pp#393)

* Restructure CI flows and cache all ThirdParty build outputs

- Promote the Tuist warm step to an upstream `needs` gate so the same
  commit's test and release jobs consume it, instead of recompiling the
  graph in parallel beside it.
- Collapse test.yml, inspect-dependencies.yml, warm-cache.yml and
  release-tip.yml into ci.yml (PRs) and main.yml (push to main); fold the
  implicit-dependency check into the build job instead of a dedicated runner.
- release.yml warms its own Release cache (bump-and-release publishes the
  release and pushes main concurrently, so it cannot rely on main.yml's warm).
- Cache zmx the way ghostty is cached (GitHub Actions cache keyed on the
  submodule SHA). Every CI run previously rebuilt it from zig.
- Add patches/*.patch to the ghostty cache key so editing a patch busts a
  now-stale xcframework; bump the key version to v3.
- Archive consumes the binary cache (development profile, Release
  configuration) instead of --cache-profile none, which recompiled the whole
  dependency graph on every release.
- Derive the build number from `date +%s` (seconds since epoch) on both the
  tip and stable pipelines: monotonic, workflow-independent, and identical
  across channels. Replaces BASE*1000+github.run_number, which reset to a
  lower value whenever the workflow file was renamed.
- Pass -showBuildTimingSummary to the CI debug build to surface where the
  build phase spends its time.

* Unify the stable release into the main pipeline

- Fold release.yml into main.yml: one build job's notarized binaries serve both
  the tip prerelease and the stable vX.Y.Z release, so a version bump no longer
  archives and notarizes the same commit twice.
- detect (after build, so the pushed tag is settled) flags a release commit when
  v$MARKETING_VERSION points at HEAD and the release has no assets yet; it strips
  CR so a CRLF xcconfig can't silently disable releases.
- publish-stable reuses build's binaries and cuts the release with GitHub
  auto-generated notes; gated by detect's idempotency check against re-runs.
- publish (tip) runs after publish-stable and only when it succeeded or was
  skipped, so the tip-into-stable appcast merge never runs against a
  half-published release.
- concurrency cancel-in-progress is false so a later push cannot kill a run
  mid-notarize/publish; newer pushes queue.
- bump-and-release just pushes the commit and tag; CI owns release creation.
- Trim the comments added across the CI restructure.

* Narrow ThirdParty cache keys to build-affecting inputs

Drop action.yml and Project.swift from the ghostty/zmx cache keys. Neither
changes what zig builds, so editing them (even a comment, or adding a target)
needlessly evicted the ~17min native cache. Key only on the submodule SHA, the
build script, mise.toml (zig version), .gitmodules, and patches.
) (supabitapp#409)

The toolbar notification popover reads its list from the per-row mirror
(`SidebarItemFeature.State.notifications`), fed by the row projection
(`WorktreeRowProjection`). That projection only re-emitted via
`onNotificationIndicatorChanged`, which was gated on the aggregate
`hasUnseenNotification` flag *flipping*.

So dismissing notifications you'd already read (flag already false) never
re-emitted the projection: the live state cleared but the mirror — and thus
the popover — kept showing them. Same gap hit single dismiss, mark-read, and
already-read incoming notifications.

Fire on any notification mutation instead of only on the flag flip. Both
downstream effects already self-dedupe (count, and the whole projection
value), so over-calling is free. Renamed the helper accordingly and dropped
the now-dead `previousHasUnseen` locals.
* Update build tooling, CI workflows, and remote setup docs

* Add SSH transport and remote host settings model

* Model repository and worktree identity with branded local and remote types

* Make git, zmx, github, and deeplink clients remote-aware

* Add remote connections and route the repositories feature through branded ids

* Wire remote worktrees through the terminal, app, settings UI, and menus

* Cover remote repositories and branded identity with tests

* Key remote settings by host and report accurate remote load failures

Brand the per-repository settings key with the remote host so two hosts
that point at the same path no longer share settings, and so a remote
repository never reads or writes a local supacode.json at that path on
the local disk. The branded key mirrors RepositoryLocation.id, leaving a
local repository at the same path on its own bare-path key. RemoteHost
gains the id-bearing authority, threaded through every settings call site
and the repository settings feature (which now carries its host through
the settingsChanged delegate).

Stop collapsing a multi-worktree remote to a single synthetic main when
the worktree listing throws transiently: that case now surfaces a load
failure and keeps the placeholder so the next reload re-lists in full,
while a genuinely empty listing still falls back to a synthetic main.

Distinguish a reachable host with a missing path from an unreachable one:
classifyRemotePath reports a dedicated missing kind so the failure names
the path instead of blaming the connection.

* Make remote repositories reorderable and disable Open for them

Remote repos were pinned below the local ones and excluded from the
reorder machinery, so dragging them did nothing. Treat every repository
uniformly in `orderedRepositoryIDs()`: the persisted sidebar order wins
and local roots and host-keyed remote ids interleave freely, so a drag
sticks across recompute and reload. The sidebar structure now renders in
that single order and exposes every repo id as reorderable.

Open / Reveal in Finder target local paths that an SSH host can't serve,
so disable them for a remote row across the sidebar context menu, the
toolbar Open menu, and the Worktrees menu, and reject them in
`openWorktreeEffect` so a hotkey can't reach the workspace client.

* Resolve failed remote repository window title from its placeholder name

A failed remote repository's id is a `remote://` authority, not a local
path, so deriving the window title name from a file URL mangled it. Prefer
the placeholder repository's resolved name, keeping the file-URL leaf only
for a local failure that has no placeholder.

* Run remote worktree branch checks on the host, not the local machine

The prompted worktree-creation duplicate check and the whole rename-branch
flow (name validation, availability, and the rename itself) resolved the
injected local git client unconditionally, so for a remote repository they
ran against the local machine instead of the SSH host. Route both through a
host-aware client: a `gitClient(for: repository)` overload for the creation
duplicate check, and a host carried on the rename prompt's state.
* Add OpenCode agent integration

Adds OpenCode (github.com/anomalyco/opencode) as a first-class agent
alongside Claude/Codex/Kiro/Pi, covering presence shimmer, awaiting-input,
and the Supacode CLI skill. Closes supabitapp#406.

OpenCode's config is `opencode.json` (no Claude-style `hooks` block) and its
extensibility surface is JS/TS plugins auto-loaded from
`~/.config/opencode/plugins/`. So presence is delivered by a generated
plugin that runs the same guarded OSC 3008 shell command every other agent
uses, single-sourced from AgentHookSettingsCommand:

- session_start    ← plugin init
- busy             ← tool.execute.before
- idle             ← tool.execute.after / session.idle event
- awaiting_input   ← permission.ask hook
- busy (resume)    ← permission.replied event
- session_end+idle ← dispose hook

The notification leg is intentionally omitted: OpenCode's session.idle event
carries no assistant text (unlike Claude's Stop stdin), so there is nothing
to forward without fabricating a body.

- SkillAgent: new `opencode` case (config `.config/opencode`, display
  "OpenCode", asset `opencode-mark`).
- OpenCodePluginContent / OpenCodePluginInstaller: generate and install the
  plugin file; install is idempotent, reports `.outdated` on byte drift, and
  only removes a file Supacode owns.
- AgentIntegrationFactory: wire the opencode integration (plugin + CLI skill).
- CLISkillContent / CLISkillInstaller: opencode SKILL.md installed to
  ~/.config/opencode/skills/supacode-cli/ (OpenCode's global skills path).
- opencode-mark: monochrome glyph rendered as a template, matching the pi mark.
- Tests: OpenCodePluginInstallerTests + opencode byte snapshots.

* Sort opencode before pi and update opencode mark SVG
…app#415)

* Preserve remote repositories on settings writes

* Keep remote repositories during local repo reloads

* Simplify remote repository merge result

* Fix remote repository merge lint
supabitapp#416)

The Ghostty goto_tab keybinds for worktree selection were emitted from a
fixed ctrl+0..9 list that ignored shortcutOverrides. Disabling or remapping
"Select Worktree N" in Settings therefore had no effect inside the terminal:
ctrl+<digit> was always captured, blocking chords like Mosh's ctrl+^.

Derive the goto_tab bindings from each shortcut's effective(from:) result so a
disabled shortcut emits no binding and a remapped shortcut moves the binding to
the chosen key. The physical digit_N fallback is kept only while the binding
stays the default Control+digit. Default output is unchanged.

Fixes supabitapp#414
* Generalize worktree open targets

* Guard editor action in worktree opener

* Expand Xcode open target exclusions
…pabitapp#410)

* Fall back to /bin/zsh for non-POSIX login shells (supabitapp#100)

Repository add/open ran every git/wt/gh command through the user's login
shell as `<shell> -l -c "<POSIX snippet>"`. A non-POSIX login shell —
Nushell, pwsh, elvish, xonsh, csh/tcsh — can't interpret the POSIX
rc-sourcing + `exec "$@"`, so the command failed and surfaced as a bogus
"not a git repository" (common on Nix/NixOS setups defaulting to `nu`).

Route the one-shot command path through `loginShellInvocation`, which keeps
POSIX shells (+ fish) as-is and falls back to /bin/zsh for anything we don't
know how to drive. Scoped to command execution only — the interactive
terminal still spawns the user's real shell via `defaultShellPath()`.

Adds ShellClientLoginShellTests covering the POSIX/fish/bash cases and the
Nushell fallback.

* Address review: only drive zsh/bash/fish, else fall back to zsh (supabitapp#100)

sh/dash/ksh were in `drivable` but still hit the zsh `.zshrc` snippet —
sourcing zsh syntax under them is a parse error, so the command never ran.
Restrict drivable to the shells we have a correct rc snippet for (zsh/bash/fish);
everything else falls back to /bin/zsh. Also trims the doc comment.
Closes supabitapp#430. The trailing new-tab and split icon buttons were only
clickable on the glyph itself. Give each a square, full-bar-height
hit target via a min frame plus contentShape, leaving the icons their
original size.
…#447)

OpenWorktreeAction.zed only matched the stable bundle id dev.zed.Zed, so the editor picker omitted Zed when only the Zed Preview channel (dev.zed.Zed-Preview) was installed. Add a .zedPreview action mirroring .zed — same bundled-CLI open behavior, listed right after Zed in editorPriority — following the existing VS Code / VS Code Insiders / VSCodium multi-channel convention.
A remote repository could vanish from the sidebar entirely (including
across relaunch) once its host became unreachable. Two paths caused it:

- A superseded in-flight probe could downgrade an already-resolved
  remote back to an empty placeholder. With no recorded failure the row
  rendered as nothing instead of a reachable repo or an offline row.
- Reloads stopped re-listing resolved remotes, so a remote that failed
  once was never re-probed and never came back.

Guard `remoteRepositoryResolved` so a probe that did not own the
in-flight resolution cannot replace a non-empty remote with an empty
placeholder, and re-probe every persisted remote on reload so refreshes
and post-worktree-add reloads re-list them. A still-unreachable remote
keeps its last-known worktrees rather than disappearing.
)

Remember the terminal font zoom (cmd+/cmd-) so new surfaces, tabs, and
sessions open at the last deliberate zoom. Capture the focused surface's
zoom on worktree switch and at quit, and seed sourceless surfaces from it,
gated on window-inherit-font-size.

A leak-free ghostty_surface_font_size getter (out-of-tree ghostty patch)
reports the manually-zoomed size, returning 0 at the configured default so
a cmd+0 reset clears the memo and new terminals open at the default,
matching Ghostty.

Also free cmd+0 for terminal font reset by moving Show Main Window to
cmd+shift+0, and limit worktree quick-select to slots 1-9.
sbertix and others added 26 commits June 22, 2026 19:16
…file (supabitapp#457)

* Signal blocking-script tabs via surface environment

Blocking-script tabs (user scripts, archive, delete) opened a normal
interactive surface, so the user's shell profile fully initialized
(prompt, plugins, banners) before the script ran, with no signal to
gate on.

Emit SUPACODE_BLOCKING_SCRIPT=1 on every blocking-script surface, plus
SUPACODE_SCRIPT_KIND (the script kind, or archive/delete for lifecycle
tabs) and, for user-defined scripts, SUPACODE_SCRIPT_ID and
SUPACODE_SCRIPT_SCOPE (repo/global, resolved repo-wins). The kind is
recorded before the surface is built so surfaceEnvironment can read it.

* Export blocking-script markers on remote runner

Local surfaces carry the SUPACODE_BLOCKING_SCRIPT markers via the
surface environment, but those don't cross the ssh boundary, so remote
worktree script tabs had no signal. Apply the same markers via an env
prefix on the remote login-shell invocation (SSHCommand) so the first
login shell inherits them before sourcing its profile, where a plain
export inside the -c script would run only after the profile loaded.
Scope resolution is shared with the local path via
blockingScriptEnvironment(for:).
…upabitapp#453)

* Recover terminal surfaces when their zmx client exits unexpectedly

A surface's zmx client can exit out from under us for reasons the user
never asked for: a transient network drop, a detach, a zmx server
restart, a crash. The old path treated every such exit as a
user-initiated close, so it tore down the tab and killed the underlying
session, losing any running agent. A brief network drop (~10s) hit every
surface at once and wiped the whole window.

Detach an unexpected zmx exit from the close path and recover in place.
When a surface's client dies, look up its session and swap a fresh
surface view under the same UUID (bumping a per-tab surfaceGeneration so
SwiftUI rebuilds) only when we positively own an idle session
(0 clients). A session another client still holds (clients > 0) or one
with an unknown count (nil) is spared, never killed, matching the orphan
reaper's spare-on-in-use rule.

Also shield repositories that failed to load from prune: a transient
load failure leaves them with no worktree rows, so prune would drop them
and kill their restored zmx sessions. prune now takes
protectingRepositoryIDs seeded from loadFailuresByID.

Closes supabitapp#422.

* Add zmx crash-recovery smoke test

Drive a real zmx session through abrupt client exit and detach, then
assert the surface reattaches in place and the session survives, so the
unexpected-exit recovery path stays covered end to end outside the unit
suite.
Ghostty seeds every surface with TERM_PROGRAM=ghostty. Override it to
supacode via the bundled config env directive so programs detect the
real host terminal, and pair it with TERM_PROGRAM_VERSION set to the app
version. The version is always emitted because Ghostty's env map can
override a key but not clear its seeded value, falling back to a
placeholder when no app version is available.

Closes supabitapp#440
* Add GitHub Copilot CLI as a coding agent

Add Copilot CLI ("Copilot CLI") to the SkillAgent integration system
alongside Claude Code, Codex, Kiro, OpenCode, and Pi.

Copilot CLI auto-loads any JSON hook file under ~/.copilot/hooks/, so the
integration writes and owns ~/.copilot/hooks/supacode.json (the Pi/OpenCode
own-file model) rather than merging into a shared settings object. Hooks emit
the OSC 3008 presence signals every other agent uses, mapped to Copilot's
lifecycle events (verified against GitHub's hooks docs):
  sessionStart        -> session_start
  userPromptSubmitted -> busy
  pre/postToolUse     -> busy
  agentStop           -> idle
  sessionEnd          -> session_end
  notification        -> rich alert for every Copilot notification; also flips
                         the badge to awaiting_input for permission_prompt /
                         elicitation_dialog ("needs you") types.

Done/permission notifications route through Copilot's `notification` event
(carries message/title), so they're richer than agentStop (whose payload has
no assistant text).

- SkillAgent: new .copilot case (.copilot dir, "Copilot CLI", copilot-mark)
- CopilotHookSettings: builds the {version,hooks} JSON; notification hook reads
  stdin once and branches on notification_type
- AgentPresenceOSC.emitNotifyShell gains readsStdin (emit notify from a payload
  already captured by a branching hook)
- CopilotHooksInstaller: own-file install/uninstall/state, sentinel ownership,
  only ever touches supacode.json (never sibling user hook files)
- AgentIntegrationFactory / CLISkillInstaller / DeveloperSettingsView wired up
- copilot-mark asset + CopilotHooksInstaller tests

* Sort coding agents alphabetically and harden Copilot hook source

Insert Copilot in alphabetical position in SkillAgent so it surfaces in
order in Settings and the sidebar card instead of trailing the list. Make
CopilotHookSettings.source() throw rather than returning an empty string, so
an encode failure can never write a marker-less hook file the installer can
no longer recognize or remove.

Backfill the sidebar-card mode fixtures with the new case (a missing case
read as still-checking and hid the card) and add coverage for the notify
shell stdin capture, notification prompt gating, non-UTF8 install state, and
the alphabetical ordering invariant.

---------

Co-authored-by: B Lacroix <b.lacroix-ext@example.com>
…upabitapp#459)

A Ghostty binding chord that collided with a default macOS app/window
management menu item fired both: pressing super+alt+h bound to
goto_split:left moved the split focus and also triggered Hide Others,
because the surface forwarded the chord to the whole main menu via
performKeyEquivalent.

Resolve the specific app-owned menu item for the chord and dispatch
only that item, skipping the non-remappable system built-ins (Hide,
Hide Others, Show All, Minimize, Zoom, Enter Full Screen, Bring All to
Front). Dispatching the resolved item directly also avoids AppKit
firing a built-in that happens to share a chord with an app-owned item.
App-owned shortcuts (Quit, command palette, and friends) still forward
and flash, and Ghostty-only bindings such as reload_config still reach
the terminal.

Closes supabitapp#450
…upabitapp#460)

The login-shell wrapper sourced the user rc file (.zshrc/.bashrc) without
arguments, so the rc shared $@ with the caller. An rc that resets the
positional parameters (set --, or a framework that does) wiped the command
before exec "$@" ran, so which gh never executed and the GitHub CLI was
reported unavailable even when installed and on PATH.

Capture the positional parameters into a namespaced array before sourcing,
then exec from it, making the lookup immune to rc files that clobber $@. The
fish branch is unaffected (it scopes argv across source) and stays as is.
supabitapp#459 dispatched a resolved menu item via `performActionForItem(at:)` after
calling the parent menu's `update()`. For SwiftUI-backed command items
(Close Terminal, Quit) `update()` makes SwiftUI rebuild the menu and replace
the item, so `index(of:)` returned -1, the dispatch silently failed, and the
chord fell through to the terminal. ⌘W and ⌘Q stopped closing and quitting.

Forward to the menu only when the chord resolves to an app-owned item, so
Ghostty-only shortcuts like ⌘⇧, are not eaten by AppKit's menu-matching
quirks. For the common case dispatch through the native
`NSMenu.performKeyEquivalent(with:)` path, which fires SwiftUI command items
reliably. When the chord also collides with a non-remappable macOS built-in
(Hide, Minimize, ...), fire the resolved item directly via `NSApp.sendAction`
so the built-in can't fire too and the app action's own side effects still
run: a remapped close_surface keeps its explicit-close bookkeeping instead of
falling through to Ghostty, which would reattach a zmx surface rather than
close it. A chord with no forwardable item (e.g. ⌥⌘H Hide Others vs a
goto_split binding) still falls through to Ghostty so the terminal binding
wins, keeping the supabitapp#459 fix intact.
…ce (supabitapp#462)

* Make remote repository ids self-descriptive and unnest them from global settings

Remote repositories now persist as self-descriptive `[user@]host[:port]<path>`
ids in a top-level `SettingsFile.remoteRepositoryRoots` (the sibling of
`repositoryRoots`), instead of `RemoteRepositoryConfig` entries nested in
`GlobalSettings`. Both lists merge into the one in-memory repository roster.

- Drop the `remote://` scheme prefix: the authority already disambiguates, and
  a local id is always an absolute path (leading `/`) while a remote authority
  never is, so that is the local-vs-remote discriminator.
- Drop the `folder:` worktree-id prefix; git-vs-folder is a runtime
  classification carried by `Worktree.kind`, not baked into a persisted id. The
  kind argument is now required so a worktree can't be silently misclassified.
- Dissolve `RemoteRepositoryConfig` into `RepositoryLocation.remote`, and drop
  the dead `displayName` and `RemoteHost.worktreeBasePath` fields.
- Add `RemoteHost.init?(authority:)` and `RepositoryLocation.parse(persistedID:)`
  to round-trip an id back to host + path.
- Teach `RepositoryPathNormalizer.normalize` to leave remote ids verbatim so a
  pinned / focused / archived remote worktree id is no longer mangled by
  `URL(fileURLWithPath:)`.
- One-shot launch migration (sidebar schema v2): drains the retired
  `global.remoteRepositories` into `remoteRepositoryRoots` and strips the
  `remote://` / `folder:` prefixes from persisted repository, pin, and sidebar
  ids. Idempotent and gated on the schema version.

* Close remote-id migration data-loss windows and fix IPv6 round-trip

- Capture the retired global.remoteRepositories before the v0->v1 sidebar
  migration can re-encode settings and drop the field; skip both passes when
  settings.json is present but unreadable so a save can't strip it first.
- Treat a present-but-unreadable/undecodable sidebar.json as a transient error:
  bail instead of overwriting the layout with empty state and stamping the
  schema, which would skip the migration permanently.
- Re-key terminal layouts (layouts.json) off the retired remote:// / folder:
  prefixes so a remote/folder worktree's tabs restore after upgrade.
- Decode the legacy remote array per-element lossily so one malformed entry
  doesn't strand the rest.
- Bracket IPv6 hosts in authority/displayAuthority so a bare IPv6 literal
  round-trips through RemoteHost(authority:); the ssh CLI still gets the bare host.
- Log unparseable persisted remote ids instead of dropping them silently.
- Add regression tests for each.

* Simplify remote repository name fallback

split(separator:) omits empty subsequences, so the leaf is never an empty
string: drop the redundant emptiness check and force-unwrap.

* Snapshot settings before migration and drain remotes ahead of sidebar gates

- Snapshot settings.json + sidebar.json (raw) before any migration or settings
  hydration can rewrite them, so a botched migration or a downgrade is
  recoverable by hand.
- Drain captured legacy remotes into remoteRepositoryRoots before the sidebar
  read/gate, so a corrupt sidebar.json can't discard them.
- Bail the migration (retry next launch) when a present layouts.json can't be
  re-keyed, instead of stamping the schema and orphaning its keys.
- Add tests for the snapshot, drain-before-sidebar, and layout re-key paths.
A disconnected remote is kept in the roster as an empty placeholder (no
worktrees). reconcileSidebarState read that empty roster as "these worktrees
are gone" and pruned the repo's curation against it on any non-initial reload:
a pinned remote worktree (git or folder) was dropped and demoted to unpinned on
reconnect, and the stored branch-collapse prefixes were wiped. Skip both prunes
for an unresolved remote placeholder so its curation survives until the host
resolves or the user removes it.

The "can't reach" error row also rendered only the section-level title/color, so
a remote folder (whose custom name/color live on its synthetic folder-worktree
item) lost them while disconnected, even though the live folder row shows them.
Fall back to the folder-worktree item so the error row matches the live row.
Persisting settings.json, sidebar.json, and layouts.json went through an
atomic temp+rename that replaced a symlinked destination with a regular file,
so a user who symlinks these into a dotfiles repo lost the link on every save.

Route the global config saves through a shared SymlinkPreservingFileWriter
that follows a symlink at the destination to its real target and writes there,
leaving the link intact. Relative targets resolve against the link's real
directory; a cycle, or a chain deeper than the kernel's symlink limit, is
refused rather than clobbered; and an unreadable link surfaces its error
instead of being misread as a plain file. Corrupt-file recovery for sidebar
and layouts moves the resolved target aside rather than the link.

Repo-local supacode.json keeps a non-following atomic write: its contents come
from a possibly-untrusted cloned repository, so following a symlink there could
redirect the save to an arbitrary user file.
* Pin swift-format, xcbeautify, and swiftlint via mise

make format ran the Xcode toolchain's built-in `swift format`, which is
unpinned: a newer Xcode's formatter rewrites the whole tree (Swift
call-site trailing commas), so contributors on different Xcodes disagree
and produce spurious ~195-file churn. xcbeautify was piped via `mise
exec` but absent from mise.toml, so with pipefail `make build-app`/`test`
failed even when the build itself succeeded. swiftlint floated on
`latest`.

Pin all three in mise.toml (swift-format via the spm: backend, matching
how zig/tuist/swiftlint are already managed; tag 602.0.0 ↔ Swift 6.2),
point `make format` at the pinned `swift-format`, and make the xcbeautify
pipe tolerant of its absence as defense in depth.

* Auto-pin a Zig-linkable Xcode via DEVELOPER_DIR

On macOS 26.4+ the GhosttyKit build dies with a wall of `undefined symbol:
_malloc, _free, _sigaction, ...` in build_zcu.o. The pinned Zig (0.15.2,
required exactly by ghostty) can't link the macOS 26.4+ SDK: that SDK's
libSystem.tbd dropped the plain arm64-macos target (keeping only
arm64e-macos), and Zig 0.15.2's linker won't match — ziglang/zig#31658,
fixed only in Zig 0.16+. The fix is to build against an Xcode <= 26.3
(ships the macOS 26.2 SDK, whose .tbd still has arm64-macos), without a
global `sudo xcode-select -s` that would disrupt other projects needing a
newer Xcode.

Add scripts/select-developer-dir.sh: it scans candidate Xcodes (current
selection, then versioned 26.3..26.0, then Xcode.app) and prints the first
whose macOS SDK is Zig-linkable — probing with the `xcrun --sdk macosx`
form Zig uses, not bare `xcrun --show-sdk-path` (which can resolve to the
CommandLineTools SDK and mislead). build-ghostty.sh and build-zmx.sh
self-resolve DEVELOPER_DIR (honoring an inherited value from the Makefile
or Xcode's foreignBuild), and the Makefile exports it for build-app /
test / run-app / archive so the app and its ghostty build share one
toolchain. An explicit DEVELOPER_DIR still wins as an override.

Document the macOS 26.4+ setup (Xcode 26.3, license/first-launch, Metal
Toolchain, submodules, the verification quirk, and why no patches/ entry
can fix a bug in Zig's own linker) in AGENTS.md and README.md.

* Add `make doctor` self-diagnosing build preflight

A first-time / returning contributor on macOS 26.4+ hits a chain of build
failures and the worst (the Zig link failure) shows up as a 200-line
undefined-symbol dump with no hint of the cause. Add scripts/doctor.sh: it
checks every prerequisite in the order failures surface — mise on PATH,
submodules initialized, a Zig-linkable Xcode (reusing
select-developer-dir.sh), Xcode license/first-launch, the Metal Toolchain,
and the pinned mise tools — and prints the exact fix command for each
failure.

`make doctor` runs it verbose; the build targets (build-app, test,
build-ghostty-xcframework, build-zmx) gain a quiet `preflight` as an
order-only prerequisite so a missing prerequisite fails fast with an
actionable message instead of a linker dump. Set SUPACODE_SKIP_PREFLIGHT=1
to skip. Docs now point at `make doctor` as the front door.

* ci: select the Zig-linkable Xcode via the shared selector

The setup-macos action duplicated the "pick an Xcode <= 26.3" scan inline.
Replace it with a call to scripts/select-developer-dir.sh so CI and local
builds (and `make doctor`) share one source of truth for which Xcode can
link the pinned Zig — and probe by libSystem.tbd rather than by version
name. Still applies it via `sudo xcode-select -s`, which is fine on an
ephemeral runner.

* Harden the Xcode selector, doctor, and build preflight

- Reject CommandLineTools as a developer dir: its SDK can still carry
  arm64-macos and pass the link probe, but xcodebuild needs a full Xcode,
  so require usr/bin/xcodebuild before accepting a candidate.
- Always delegate to the selector in the ghostty and zmx build scripts so
  an inherited non-linkable DEVELOPER_DIR is validated and overridden
  rather than trusted. Split assignment from export so a selector failure
  aborts under set -e.
- Guard the empty candidate array so a machine with no Xcode on bash 3.2
  prints the actionable message instead of an unbound-variable abort.
- Match hyphenated Xcode bundle names (Xcode-26.3.0.app), not just the
  underscore form.
- Target the selected Xcode when installing the Metal Toolchain, in the
  doctor fix and the docs.
- Run the preflight before Tuist by attaching it to the install stamp, so
  a fresh checkout fails fast with doctor's guidance instead of a cryptic
  tuist error, and skip it on CI where the setup-macos action already
  pins a Zig-linkable Xcode.
…bitapp#477) (supabitapp#482)

The `gh` detection probe runs `zsh -l -c '<cmd>' -- /usr/bin/which gh`,
passing the target as positional parameters. `posixLoginCommand` captured
`$@` into a saved array (so a `set --` in the rc couldn't wipe the exec,
supabitapp#441) but left the live positionals set while sourcing the rc file.

Any `~/.zshrc` that sources a dual-mode script dispatching on `$1`
(e.g. `fzf-git.sh`) then sees the probe's `/usr/bin/which gh`, hits that
script's `exit`, and kills the probe shell before `gh` is resolved —
surfacing as "GitHub CLI not found" despite gh being installed and on PATH.

Insert `set --` after the capture and before sourcing. The exec reads from
the saved array, so clearing the live positionals is safe. Verified with a
real zsh dual-mode rc reproduction: old snippet returns empty stdout, the
fixed snippet resolves the binary. Applies to both the zsh and bash branches;
fish is unaffected. Adds a regression test asserting capture → set -- → source
ordering.
)

* fix: refuse to open a repo with duplicate worktree paths

A repository whose git config is corrupt -- e.g. a stale `core.worktree`
redirect that resolves the bare root onto a linked worktree's directory --
makes `git`/`wt` report two worktrees at the same path. Both load paths feed
the listing into `IdentifiedArray(uniqueElements:)`, whose precondition traps
on duplicate ids (a worktree id is its path). Because the root is persisted,
the trap recurs on every launch, crash-looping the app.

Rather than crash -- or silently dedupe and present a guessed, possibly-wrong
worktree -- detect the duplicate and refuse to open the repository, routing it
through the existing load-failure path so the sidebar shows an error row
explaining the repo may be corrupt. This validates at the boundary (the
git/wt listing) and leaves the trusted-internal `IdentifiedArray` construction
unchanged, matching how missing/unreadable roots are already handled.

- Extract `firstDuplicateWorktreeID(in:)` and `duplicateWorktreePathMessage`
  shared by the local and remote load paths.
- Extract the per-root fetch into `worktreesFetchResult(for:gitClient:)`.
- Cover the detection helper and the local end-to-end failure-row behavior.

* Cover the remote duplicate-worktree-path guard and tidy guard comments

Add a test for the remote load path's duplicate-worktree-path refusal,
asserting it returns the placeholder repository (carrying its host) and a
failure naming the host-keyed worktree id. Expand the firstDuplicateWorktreeID
unit test with empty, single, and leading-duplicate cases and pin the
user-facing failure copy. Trim the guard comments to state only the why.
PostHog/Sentry hosts were injected into ReleaseOverrides.xcconfig as raw
URLs; xcconfig treats // as a comment, so https://host collapsed to
https:, leaving shipped builds unable to send any events. Escape // with
$() in the generated overrides, and verify the embedded Info.plist
values match the secrets after export so this can't regress silently.
…fig-url-truncation

Fix xcconfig // truncation of telemetry URLs in release builds
…e tag (supabitapp#486)

bump-version now refuses to run without VERSION, printing the next
patch/minor/major as runnable commands instead of silently
auto-incrementing the patch. Minor and major bumps require a headline
(## title, a blank line, then a non-empty body), authored via TITLE/BODY
or $EDITOR; it rides the annotated, signed tag message (verbatim cleanup
keeps the heading), and publish-stable prepends it to the auto-generated
release notes when the tag is annotated and its subject is a heading.
Headlines are optional on patches but used when present. The logic moves
from inline Makefile shell into scripts/bump-version.sh, with all
validation before any mutation so an error leaves the tree untouched.

Variables reach the script through the environment (make export) so
headline markdown survives without a shell re-parse, and the bump commit
is scoped to the version file so unrelated staged changes never ride
into the tagged release.
Adds Nova (Panic's Mac-native code editor) to the list of supported
worktree open actions. Uses the default workspace open behavior and the
standard bundle identifier com.panic.Nova.
Adds a worktree switcher on ⌘P and moves the existing Command Palette to ⌘⇧P,
aligning with VS Code (⌘⇧P = Command Palette; ⌘P = fast navigation). Both
shortcuts remain user-rebindable via shortcut overrides.

Reworked from the original project switcher per the PR supabitapp#370 review: Supacode's
core use case is multi-worktree-per-repo, where a project-level switcher
collapses the whole history down to one entry per repo and loses the worktree
you actually had open. Worktrees are the primary rows instead.

- ⌘P opens a fuzzy switcher of every worktree, ordered most-recently-used
  (RepositoriesFeature.State.worktreeMRU). The current worktree is rendered (so
  you see where you are) but flagged `isCurrentWorktree` so the default
  selection lands on the *previous* worktree — ⌘P then Enter is a Cmd+Tab-style
  toggle. Typing fuzzy-matches the combined `repo / worktree` title, so a query
  hits either the project name or the worktree name.
- worktreeMRU is recorded on BOTH navigation paths — setSingleWorktreeSelection
  (hotkeys/palette) and reduceSelectionChangedEffect (sidebar clicks) — so it
  tracks however you navigate. Single-worktree repos behave exactly as the
  original project switcher did.
- New PaletteMode (.commands / .worktreeSwitcher). The switcher emits
  `selectWorktree` directly (no project→worktree resolution step), so activation
  lands focus in the chosen terminal; cancelling refocuses the current terminal.

Covered by CommandPaletteFeatureTests (switcher ordering/flagging, MRU sort,
combined title, mode dispatch) and RepositoriesFeatureWorktreeMRUTests (MRU
recording on both nav paths).

Implemented with AI assistance (Claude Code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The worktree switcher records every concrete selection into the new
`worktreeMRU` state on both nav paths (setSingleWorktreeSelection and
reduceSelectionChangedEffect). TestStore checks state exhaustively, so the 27
selection/history tests that drive those paths now see an unasserted change.
Assert the new MRU head in each — selecting worktree W prepends W; nil/archived
selections leave it untouched (those tests already pass and are unchanged).

Behavior change is intentional (the switcher's whole point), so the tests follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two bits of feedback from running the build:

- The query placeholder was hardcoded "Search for actions or branches…" — stale
  now that ⌘P is worktree-only (actions live on ⌘⇧P). Make it mode-aware:
  "Go to worktree…" for the switcher, the actions wording for the command palette.
- Switcher rows rendered one flat `repo / worktree` string at a single weight, so
  the worktree name was hard to pick out from the repo. Split them: worktree name
  is the title, repo is the secondary subtitle (VS Code "filename big, path small").
  The fuzzy scorer matches title and subtitle, so a query still hits either name.
  Folder rows keep the repo name alone (no redundant subtitle).

The ⌘⇧P command palette is unchanged (still the flat `repo / worktree` rows
alongside actions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `setup-macos` Tuist auth step was gated to skip only cross-fork PRs, so an
intra-fork PR (or a push to a fork's main) still tried `tuist auth login` and
failed with `notAuthenticated` / "No projects linked to the repository" — the
fork isn't linked in the Tuist dashboard. That blocked the whole build job
before lint/build/test ran.

Tuist auth is OIDC against the canonical upstream project and only buys the
remote cache; `tuist generate` (and thus the build) works without it — the
cross-fork-PR path already relies on this. Gate auth on the upstream repo so
forks (and cross-fork PRs) skip it and can run the full build+test job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Local builds are impossible on this Mac (Zig/libSystem), and test.yml builds but
never uploads the app, so a fork has no way to get a runnable build. Add a
`make package-app` target (zips the Debug build via the same -showBuildSettings
path resolution as run-app/install-dev-build) and a `fork-build.yml` workflow on
feat/** | fix/** | exp/** that builds, packages, and uploads `supacode-<sha>`.
No tests, no signing — just a downloadable .app.zip for local testing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@COCPORN COCPORN closed this Jun 28, 2026
@COCPORN
COCPORN deleted the verify/worktree-switcher-rebased branch June 28, 2026 20:17
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.