[CI VERIFY ONLY] worktree-switcher rebased onto upstream/main#2
Closed
COCPORN wants to merge 56 commits into
Closed
[CI VERIFY ONLY] worktree-switcher rebased onto upstream/main#2COCPORN wants to merge 56 commits into
COCPORN wants to merge 56 commits into
Conversation
* 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.
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.