From 4d07b0a502c1d0c2569f6a37488b3bb1f06cdb6b Mon Sep 17 00:00:00 2001 From: Marvin Aziz <33159023+marvtub@users.noreply.github.com> Date: Fri, 29 May 2026 08:24:15 -0700 Subject: [PATCH 01/56] Add per-worktree title and color customization (#308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add per-worktree title and color customization (issue #300) Ports the customize-worktree feature onto upstream's new per-row `SidebarItemFeature` architecture (#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) * 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) * 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) * 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) * 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 #350 / #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. --- supacode/App/ContentView.swift | 8 + supacode/Domain/PendingWorktree.swift | 24 + .../Reducer/CommandPaletteFeature.swift | 67 +-- .../BusinessLogic/SidebarState.swift | 125 ++++- .../BusinessLogic/SidebarStructure.swift | 11 +- .../Models/ToolbarNotificationGroup.swift | 2 +- .../Reducer/RepositoriesFeature+Sidebar.swift | 24 + ...itoriesFeature+WorktreeCustomization.swift | 70 +++ .../Reducer/RepositoriesFeature.swift | 284 ++++++++++-- .../RepositoryCustomizationFeature.swift | 3 +- .../Reducer/SidebarItemFeature.swift | 35 +- .../WorktreeCreationPromptFeature.swift | 18 +- .../WorktreeCustomizationFeature.swift | 65 +++ .../Views/ArchivedWorktreeRowView.swift | 17 +- .../Views/ArchivedWorktreesDetailView.swift | 2 + .../Views/RepositoryCustomizationView.swift | 2 +- .../Repositories/Views/SidebarItemView.swift | 40 +- .../Repositories/Views/SidebarItemsView.swift | 76 ++-- .../Repositories/Views/SidebarListView.swift | 4 +- .../Views/WorktreeCreationPromptView.swift | 17 + .../Views/WorktreeCustomizationView.swift | 50 ++ .../Views/WorktreeDetailTitleView.swift | 24 +- .../Views/WorktreeDetailView.swift | 23 +- supacodeTests/RepositoriesFeatureTests.swift | 6 +- .../RepositoryCustomizationFeatureTests.swift | 16 +- supacodeTests/SidebarStateTests.swift | 191 ++++++++ .../ToolbarNotificationGroupingTests.swift | 26 ++ .../WorktreeCreationPromptFeatureTests.swift | 104 ++++- .../WorktreeCreationPromptParentTests.swift | 427 ++++++++++++++++++ .../WorktreeCustomizationFeatureTests.swift | 67 +++ .../WorktreeCustomizationParentTests.swift | 352 +++++++++++++++ 31 files changed, 2049 insertions(+), 131 deletions(-) create mode 100644 supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCustomization.swift create mode 100644 supacode/Features/Repositories/Reducer/WorktreeCustomizationFeature.swift create mode 100644 supacode/Features/Repositories/Views/WorktreeCustomizationView.swift create mode 100644 supacodeTests/WorktreeCreationPromptParentTests.swift create mode 100644 supacodeTests/WorktreeCustomizationFeatureTests.swift create mode 100644 supacodeTests/WorktreeCustomizationParentTests.swift diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 038d0c793..6c81a1834 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -85,6 +85,14 @@ struct ContentView: View { ) { customizationStore in RepositoryCustomizationView(store: customizationStore) } + .sheet( + item: $repositoriesStore.scope( + state: \.worktreeCustomization, + action: \.worktreeCustomization + ) + ) { customizationStore in + WorktreeCustomizationView(store: customizationStore) + } .sheet( item: $repositoriesStore.scope( state: \.renameBranchPrompt, diff --git a/supacode/Domain/PendingWorktree.swift b/supacode/Domain/PendingWorktree.swift index 89683ae6c..c7e531dd1 100644 --- a/supacode/Domain/PendingWorktree.swift +++ b/supacode/Domain/PendingWorktree.swift @@ -1,7 +1,31 @@ import Foundation +import SupacodeSettingsShared struct PendingWorktree: Identifiable, Hashable { + /// Sidebar customization that travels with the in-flight creation. Reconcile + /// reads this when materializing the pending row so a user-typed title / + /// color is visible while the worktree is being created; on success the + /// reducer copies it into the bucketed `SidebarState.Item` before the next + /// reconcile, on failure it's dropped with the row. + struct Customization: Hashable { + let title: String? + let color: RepositoryColor? + } + let id: String let repositoryID: Repository.ID var progress: WorktreeCreationProgress + var customization: Customization? + + init( + id: String, + repositoryID: Repository.ID, + progress: WorktreeCreationProgress, + customization: Customization? = nil + ) { + self.id = id + self.repositoryID = repositoryID + self.progress = progress + self.customization = customization + } } diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index f4cf29a93..e025d5d40 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -230,37 +230,20 @@ struct CommandPaletteFeature { #if DEBUG items.append(contentsOf: debugToastItems()) #endif - if let selectedWorktreeID = repositories.selectedWorktreeID, - let selectedRow = repositories.sidebarItems[id: selectedWorktreeID], - let selectedRepositoryID = repositories.repositoryID(containing: selectedWorktreeID), - let selectedWorktree = repositories.worktree(for: selectedWorktreeID), - !selectedRow.isFolder, - !selectedRow.name.isEmpty, - selectedRow.lifecycle == .idle, - selectedWorktree.isAttached, - !selectedWorktree.isMissing - { - let repositoryName = Repository.sidebarDisplayName( - custom: repositories.sidebar.sections[selectedRepositoryID]?.title, - fallback: repositories.repositoryName(for: selectedRepositoryID) ?? "Repository" - ) - items.append( - CommandPaletteItem( - id: CommandPaletteItemID.renameBranch(selectedWorktreeID), - title: "Rename Branch", - subtitle: "\(repositoryName) · \(selectedRow.name)", - kind: .renameBranch(selectedWorktreeID, selectedRepositoryID) - ) - ) + if let renameBranchItem = renameBranchItem(from: repositories) { + items.append(renameBranchItem) } for row in repositories.orderedSidebarItems() { guard row.lifecycle == .idle else { continue } - let repositoryName = repositories.repositoryName(for: row.repositoryID) ?? "Repository" - // Folder rows only have a synthetic "main" worktree whose name - // matches the repository, so the usual `repo / worktree` - // format would render as `Foo / Foo`. Use the repository name + let repositoryName = Repository.sidebarDisplayName( + custom: repositories.sidebar.sections[row.repositoryID]?.title, + fallback: repositories.repositoryName(for: row.repositoryID) ?? "Repository" + ) + let worktreeDisplayName = SidebarDisplayName.resolved(custom: row.customTitle, fallback: row.name) ?? row.name + // Folder rows only have a synthetic "main" worktree whose name matches the repository, so + // the usual `repo / worktree` format would render as `Foo / Foo`. Use the repository name // alone for folders. - let title = row.isFolder ? repositoryName : "\(repositoryName) / \(row.name)" + let title = row.isFolder ? repositoryName : "\(repositoryName) / \(worktreeDisplayName)" items.append( CommandPaletteItem( id: CommandPaletteItemID.worktreeSelect(row.id), @@ -273,6 +256,36 @@ struct CommandPaletteFeature { return items } + /// The "Rename Branch" action for the selected git worktree, or `nil` when the + /// selection isn't an idle, attached, non-folder worktree that can be renamed. + static func renameBranchItem(from repositories: RepositoriesFeature.State) -> CommandPaletteItem? { + guard let selectedWorktreeID = repositories.selectedWorktreeID, + let selectedRow = repositories.sidebarItems[id: selectedWorktreeID], + let selectedRepositoryID = repositories.repositoryID(containing: selectedWorktreeID), + let selectedWorktree = repositories.worktree(for: selectedWorktreeID), + !selectedRow.isFolder, + !selectedRow.name.isEmpty, + selectedRow.lifecycle == .idle, + selectedWorktree.isAttached, + !selectedWorktree.isMissing + else { + return nil + } + let repositoryName = Repository.sidebarDisplayName( + custom: repositories.sidebar.sections[selectedRepositoryID]?.title, + fallback: repositories.repositoryName(for: selectedRepositoryID) ?? "Repository" + ) + let worktreeDisplayName = + SidebarDisplayName.resolved(custom: selectedRow.customTitle, fallback: selectedRow.name) + ?? selectedRow.name + return CommandPaletteItem( + id: CommandPaletteItemID.renameBranch(selectedWorktreeID), + title: "Rename Branch", + subtitle: "\(repositoryName) · \(worktreeDisplayName)", + kind: .renameBranch(selectedWorktreeID, selectedRepositoryID) + ) + } + static func recencyRetentionIDs( from repositories: IdentifiedArrayOf, scripts: [ScriptDefinition] = [] diff --git a/supacode/Features/Repositories/BusinessLogic/SidebarState.swift b/supacode/Features/Repositories/BusinessLogic/SidebarState.swift index e3737318c..708855088 100644 --- a/supacode/Features/Repositories/BusinessLogic/SidebarState.swift +++ b/supacode/Features/Repositories/BusinessLogic/SidebarState.swift @@ -185,6 +185,44 @@ nonisolated struct SidebarState: Equatable, Sendable, Codable { /// item leaves `.archived`, so the field is a reliable "is this /// currently archived?" signal without a bucket check. var archivedAt: Date? + /// Optional user-supplied display title that overrides the + /// worktree's branch / folder name in the sidebar row. `nil` (or + /// whitespace-only after trim) means "use the default name". + var title: String? + /// Optional user-supplied tint applied to the sidebar row title. + /// `nil` means "default styling". + var color: RepositoryColor? + + private enum CodingKeys: String, CodingKey { + case archivedAt + case title + case color + } + + init(archivedAt: Date? = nil, title: String? = nil, color: RepositoryColor? = nil) { + self.archivedAt = archivedAt + self.title = title + self.color = color + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.archivedAt = try container.decodeIfPresent(Date.self, forKey: .archivedAt) + self.title = try container.decodeIfPresent(String.self, forKey: .title) + // Use `try?` so a malformed hex color (introduced by a downgrade + // that doesn't understand `.custom`, hand-edit, etc.) drops just + // this field rather than killing the row's entire entry. + self.color = (try? container.decodeIfPresent(RepositoryColor.self, forKey: .color)) ?? nil + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(archivedAt, forKey: .archivedAt) + // Customization fields are only emitted when set so the file + // stays clean for worktrees the user never touched. + try container.encodeIfPresent(title, forKey: .title) + try container.encodeIfPresent(color, forKey: .color) + } } /// Flat reference to an archived worktree: the owning repo, the @@ -295,6 +333,48 @@ nonisolated extension SidebarState { sections[repositoryID] = section } + /// Merge a user-supplied title / color into whatever bucket already holds the row, falling back + /// to `.unpinned` when the row hasn't been seeded yet. Pre-existing non-nil fields on the + /// bucketed Item win, so a re-seed never clobbers a manual customization. Used by the + /// post-create and discovered-worktree seed sites so a persisted `.pinned` Item never gets + /// manufactured into a phantom double-bucket row. + mutating func mergeCustomization( + title: String?, + color: RepositoryColor?, + worktree worktreeID: Worktree.ID, + in repositoryID: Repository.ID + ) { + let destinationBucket = currentBucket(of: worktreeID, in: repositoryID) ?? .unpinned + var section = sections[repositoryID] ?? .init() + var bucket = section.buckets[destinationBucket] ?? .init() + var item = bucket.items[worktreeID] ?? .init() + if item.title == nil { item.title = title } + if item.color == nil { item.color = color } + bucket.items[worktreeID] = item + section.buckets[destinationBucket] = bucket + sections[repositoryID] = section + } + + /// Overwrite a row's user-supplied title / color, falling back to `.unpinned` when the row + /// hasn't been seeded yet. Unlike `mergeCustomization`, the incoming values always win, since + /// this represents an explicit user save intent that must not be silently absorbed. + mutating func setCustomization( + title: String?, + color: RepositoryColor?, + worktree worktreeID: Worktree.ID, + in repositoryID: Repository.ID + ) { + let destinationBucket = currentBucket(of: worktreeID, in: repositoryID) ?? .unpinned + var section = sections[repositoryID] ?? .init() + var bucket = section.buckets[destinationBucket] ?? .init() + var item = bucket.items[worktreeID] ?? .init() + item.title = title + item.color = color + bucket.items[worktreeID] = item + section.buckets[destinationBucket] = bucket + sections[repositoryID] = section + } + /// Archive `worktreeID`: drop from `from`, insert into `.archived` /// at the tail with the given timestamp. Materialises the section /// and the archived bucket when missing so a late-arriving @@ -307,9 +387,12 @@ nonisolated extension SidebarState { at timestamp: Date ) { var section = sections[repositoryID] ?? .init() - section.buckets[from]?.items.removeValue(forKey: worktreeID) + // Carry the source-bucket Item forward so user-set title / color survive + // archive; fall back to a fresh Item when the source bucket didn't hold it. + var carried = section.buckets[from]?.items.removeValue(forKey: worktreeID) ?? .init() + carried.archivedAt = timestamp var archived = section.buckets[.archived] ?? .init() - archived.items[worktreeID] = .init(archivedAt: timestamp) + archived.items[worktreeID] = carried section.buckets[.archived] = archived sections[repositoryID] = section } @@ -334,15 +417,39 @@ nonisolated extension SidebarState { /// Remove `worktreeID` from every bucket of `repositoryID`. Used /// by the delete flow (the worktree is going away entirely, so - /// we don't need to know which bucket currently owns it). O(1) - /// — exactly three bucket subscripts, no scan. - mutating func removeAnywhere(worktree worktreeID: Worktree.ID, in repositoryID: Repository.ID) { + /// we don't need to know which bucket currently owns it) and by + /// pin / unpin (which collapse any pre-existing multi-bucket state + /// before reinserting). Returns the first found Item in + /// `preferring` order so callers that reinsert (pin / unpin) can + /// carry user-set `title` / `color` forward; pass the logical + /// source bucket first (e.g. `.pinned` for unpin) so a corrupted + /// double-bucket pre-state preserves the live row's payload rather + /// than a stale sibling's. Default order matches the typical + /// "where would a curated row live" search. O(1): exactly three + /// bucket subscripts, no scan. + @discardableResult + mutating func removeAnywhere( + worktree worktreeID: Worktree.ID, + in repositoryID: Repository.ID, + preferring: [BucketID] = [.unpinned, .pinned, .archived] + ) -> Item? { guard sections[repositoryID] != nil else { - return + return nil + } + // Remove from every bucket (the "removeAnywhere" contract); the + // `preferring` order only determines which carried Item we + // return. Buckets outside `preferring` are still purged but + // their payload is discarded. + var removed: [BucketID: Item] = [:] + for bucketID in [BucketID.pinned, .unpinned, .archived] { + if let item = sections[repositoryID]?.buckets[bucketID]?.items.removeValue(forKey: worktreeID) { + removed[bucketID] = item + } } - sections[repositoryID]?.buckets[.pinned]?.items.removeValue(forKey: worktreeID) - sections[repositoryID]?.buckets[.unpinned]?.items.removeValue(forKey: worktreeID) - sections[repositoryID]?.buckets[.archived]?.items.removeValue(forKey: worktreeID) + for bucketID in preferring { + if let item = removed[bucketID] { return item } + } + return nil } /// Reorder `bucketID`'s items in `repositoryID` to exactly diff --git a/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift b/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift index c73aa8551..c530d8602 100644 --- a/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift +++ b/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift @@ -385,8 +385,14 @@ extension RepositoriesFeature.Action { case .repositoryCustomization: return [] - // Branch rename updates the worktree.name shown in the sidebar row and - // the notification group title, mirroring `.worktreeBranchNameLoaded`. + // Worktree customization save mutates the bucketed Item's title / color, picked up via + // per-row `customTitle` / `customTint` mirror (highlight tags + notification group name). + case .worktreeCustomization(.presented(.delegate(.save))): + return .all + case .worktreeCustomization: + return [] + + // Branch rename updates the worktree.name shown in the sidebar row and notification group. case .renameBranchPrompt(.presented(.delegate(.renamed))): return .all case .renameBranchPrompt: @@ -419,6 +425,7 @@ extension RepositoriesFeature.Action { .showToast, .dismissToast, .delayedPullRequestRefresh, .openRepositorySettings, .requestCustomizeRepository, + .requestCustomizeWorktree, .requestRenameBranch, .contextMenuOpenWorktree, .worktreeCreationPrompt, diff --git a/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift b/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift index 4ac274908..ee9371aea 100644 --- a/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift +++ b/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift @@ -47,7 +47,7 @@ extension RepositoriesFeature.State { } return ToolbarNotificationWorktreeGroup( id: worktree.id, - name: worktree.name, + name: row.resolvedSidebarTitle ?? worktree.name, notifications: Array(row.notifications), hasUnseenNotifications: row.hasUnseenNotifications ) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Sidebar.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Sidebar.swift index 1c6557209..1b38f34d1 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Sidebar.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Sidebar.swift @@ -64,6 +64,12 @@ extension RepositoriesFeature { item.isMainWorktree = isMain item.isPinned = isPinned item.isMissing = worktree.isMissing + // Mirror per-worktree customization from `@Shared(.sidebar)`. Reading + // through the currently-owning bucket survives pin / unpin / archive + // transitions because the bucket-flow `move` carries the `Item` over. + let customization = storedCustomization(for: id, in: repository.id, sidebar: state.sidebar) + item.customTitle = customization.title + item.customTint = customization.color // Clear the PR query branch when the worktree was renamed. if let existing, existing.branchName != worktree.name { item.pullRequestBranchAtQueryTime = nil @@ -99,6 +105,8 @@ extension RepositoriesFeature { ) item.name = pendingName item.branchName = pendingName + item.customTitle = pending.customization?.title + item.customTint = pending.customization?.color item.lifecycle = state.removingRepositoryIDs[pending.repositoryID] != nil ? .deleting @@ -126,6 +134,22 @@ extension RepositoriesFeature { } } + /// User-set title / color for `worktreeID`, read through whichever bucket + /// currently owns the row so the lookup survives pin / unpin / archive moves. + /// Both fields are `nil` when the row isn't bucketed yet. + private static func storedCustomization( + for worktreeID: Worktree.ID, + in repositoryID: Repository.ID, + sidebar: SidebarState + ) -> (title: String?, color: RepositoryColor?) { + guard let bucketID = sidebar.currentBucket(of: worktreeID, in: repositoryID), + let storedItem = sidebar.sections[repositoryID]?.buckets[bucketID]?.items[worktreeID] + else { + return (nil, nil) + } + return (storedItem.title, storedItem.color) + } + /// Pair with `reconcileSidebarItems`; recomputes `state.sidebarGrouping`. static func rebuildSidebarGrouping(_ state: inout State) { var buckets: OrderedDictionary = [:] diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCustomization.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCustomization.swift new file mode 100644 index 000000000..044a4bf29 --- /dev/null +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCustomization.swift @@ -0,0 +1,70 @@ +import ComposableArchitecture +import Foundation +import OrderedCollections +import SupacodeSettingsShared + +extension RepositoriesFeature { + /// Dedicated reducer for the per-worktree customization flow. Lives in its own file so the main + /// `body` switch stays under the Swift type-checker's complexity limit. + static var worktreeCustomizationReducer: some Reducer { + Reduce { state, action in + switch action { + case .requestCustomizeWorktree(let worktreeID, let repositoryID): + guard let repository = state.repositories[id: repositoryID], + let worktree = repository.worktrees.first(where: { $0.id == worktreeID }) + else { + repositoriesLogger.warning( + "requestCustomizeWorktree dropped: unknown wt=\(worktreeID) repo=\(repositoryID)" + ) + return .none + } + // Git main worktree is repository-level (use requestCustomizeRepository instead). The + // folder synthetic worktree IS the row, so we allow it through. + if repository.isGitRepository, state.isMainWorktree(worktree) { + repositoriesLogger.warning( + "requestCustomizeWorktree dropped: git main worktree is repository-level wt=\(worktreeID)" + ) + return .none + } + let bucket = state.sidebar.currentBucket(of: worktreeID, in: repositoryID) + let storedItem = bucket.flatMap { + state.sidebar.sections[repositoryID]?.buckets[$0]?.items[worktreeID] + } + // For folder synthetic worktrees, default name = repository name (what the row shows when + // no override). For git worktrees, default name = branch name. + let defaultName = repository.isGitRepository ? worktree.name : repository.name + state.worktreeCustomization = WorktreeCustomizationFeature.State( + worktreeID: worktreeID, + repositoryID: repositoryID, + defaultName: defaultName, + title: storedItem?.title ?? "", + color: storedItem?.color + ) + return .none + + case .worktreeCustomization(.presented(.delegate(.cancel))): + state.worktreeCustomization = nil + return .none + + case .worktreeCustomization( + .presented(.delegate(.save(let worktreeID, let repositoryID, let title, let color))) + ): + // Always overwrite (user save intent); falls back to `.unpinned` when the row hasn't been + // seeded into a bucket yet (folder synthetic before first reconcile, deeplink/palette). + state.$sidebar.withLock { sidebar in + sidebar.setCustomization(title: title, color: color, worktree: worktreeID, in: repositoryID) + } + syncSidebar(&state) + state.worktreeCustomization = nil + return .none + + case .worktreeCustomization(.dismiss): + state.worktreeCustomization = nil + return .none + + default: + return .none + } + } + } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 9b9d02621..3d0cdde01 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -91,6 +91,12 @@ struct RepositoriesFeature { var isOpenPanelPresented = false var isInitialLoadComplete = false var pendingWorktrees: [PendingWorktree] = [] + /// In-flight customization payloads, keyed by `(repositoryID, branchName)` + /// so the New Worktree prompt's `submit` delegate can hand title / color + /// off without bloating four action signatures in the creation chain. + /// Drained when the `PendingWorktree` materialises in `createWorktreeInRepository`, + /// or on a prompt cancel / dismiss. + var pendingCreationCustomizations: [Repository.ID: [String: PendingWorktree.Customization]] = [:] /// In-flight repo-level removals keyed by repository id. Each record /// carries the disposition (only `.gitRepositoryUnlink` / `.folderUnlink` /// / `.folderTrash`) and the id of the owning batch aggregator that @@ -166,6 +172,7 @@ struct RepositoriesFeature { var toolbarNotificationGroupsCache: [ToolbarNotificationRepositoryGroup] = [] @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? @Presents var repositoryCustomization: RepositoryCustomizationFeature.State? + @Presents var worktreeCustomization: WorktreeCustomizationFeature.State? @Presents var renameBranchPrompt: RenameBranchFeature.State? @Presents var alert: AlertState? @@ -394,10 +401,12 @@ struct RepositoriesFeature { case delayedPullRequestRefresh(Worktree.ID) case openRepositorySettings(Repository.ID) case requestCustomizeRepository(Repository.ID) + case requestCustomizeWorktree(Worktree.ID, Repository.ID) case requestRenameBranch(Worktree.ID, Repository.ID) case contextMenuOpenWorktree(Worktree.ID, OpenWorktreeAction) case worktreeCreationPrompt(PresentationAction) case repositoryCustomization(PresentationAction) + case worktreeCustomization(PresentationAction) case renameBranchPrompt(PresentationAction) case alert(PresentationAction) case delegate(Delegate) @@ -970,6 +979,9 @@ struct RepositoriesFeature { return .none case .worktreeCreationPrompt(.presented(.delegate(.cancel))): + if let repositoryID = state.worktreeCreationPrompt?.repositoryID { + state.dropPendingCustomization(repositoryID: repositoryID) + } state.worktreeCreationPrompt = nil return .merge( .cancel(id: CancelID.worktreePromptLoad), @@ -979,10 +991,27 @@ struct RepositoriesFeature { case .worktreeCreationPrompt( .presented( .delegate( - .submit(let repositoryID, let branchName, let baseRef, let fetchOrigin, let placement) + .submit( + let repositoryID, + let branchName, + let baseRef, + let fetchOrigin, + let placement, + let title, + let color + ) ) ) ): + // Overwrite (or clear) any stale entry for the same (repo, branch) so a user who typed a + // title, hit a validation error, blanked the field, and re-submitted doesn't keep the + // dropped value alive. + if title != nil || color != nil { + state.pendingCreationCustomizations[repositoryID, default: [:]][branchName] = + PendingWorktree.Customization(title: title, color: color) + } else { + state.dropPendingCustomization(repositoryID: repositoryID, branchName: branchName) + } return .send( .startPromptedWorktreeCreation( repositoryID: repositoryID, @@ -1006,6 +1035,9 @@ struct RepositoriesFeature { title: "Unable to create worktree", message: "Unable to resolve a repository for the new worktree." ) + // Drain the just-stashed customization so a later retry with the same name doesn't pick + // up the orphaned entry. + state.dropPendingCustomization(repositoryID: repositoryID, branchName: branchName) return .none } state.worktreeCreationPrompt?.validationMessage = nil @@ -1014,6 +1046,9 @@ struct RepositoriesFeature { if repository.worktrees.contains(where: { $0.name.lowercased() == normalizedBranchName }) { state.worktreeCreationPrompt?.isValidating = false state.worktreeCreationPrompt?.validationMessage = "Branch name already exists." + // Synchronous duplicate rejection. Drop the stashed customization so it can't be + // re-applied if the user retries with a different branch name. + state.dropPendingCustomization(repositoryID: repositoryID, branchName: branchName) return .none } let gitClient = gitClient @@ -1051,6 +1086,8 @@ struct RepositoriesFeature { state.worktreeCreationPrompt?.isValidating = false if let duplicateMessage { state.worktreeCreationPrompt?.validationMessage = duplicateMessage + // Async-validation duplicate rejection. Same drop reasoning as the sync path. + state.dropPendingCustomization(repositoryID: repositoryID, branchName: branchName) return .none } state.worktreeCreationPrompt = nil @@ -1071,21 +1108,30 @@ struct RepositoriesFeature { let fetchOrigin, let placement ): + // Pull the parked branch name so every rejection arm can drain its (repo, branch) entry + // through the same helper — keeps the dict from leaking when a creation is rejected via + // any of the three guards below. + let rejectedBranchName: String? = if case .explicit(let name) = nameSource { name } else { nil } guard let repository = state.repositories[id: repositoryID] else { state.alert = messageAlert( title: "Unable to create worktree", message: "Unable to resolve a repository for the new worktree." ) + if let rejectedBranchName { + state.dropPendingCustomization(repositoryID: repositoryID, branchName: rejectedBranchName) + } return .none } - // Guard against folder-kind entries arriving here via - // deeplink / palette paths that bypass + // Guard against folder-kind entries arriving here via deeplink / palette paths that bypass // `.createRandomWorktreeInRepository`. if !repository.isGitRepository { state.alert = messageAlert( title: "Unable to create worktree", message: "Worktrees are only supported for git repositories." ) + if let rejectedBranchName { + state.dropPendingCustomization(repositoryID: repository.id, branchName: rejectedBranchName) + } return .none } if state.removingRepositoryIDs[repository.id] != nil { @@ -1093,6 +1139,11 @@ struct RepositoriesFeature { title: "Unable to create worktree", message: "This repository is being removed." ) + // Creation is being rejected; drop just the in-flight (repo, branch) entry so other + // concurrent customizations for this repo aren't wiped out. + if let rejectedBranchName { + state.dropPendingCustomization(repositoryID: repository.id, branchName: rejectedBranchName) + } return .none } let previousSelection = state.selectedWorktreeID @@ -1112,11 +1163,24 @@ struct RepositoriesFeature { let copyUntrackedOnWorktreeCreate = repositorySettings.copyUntrackedOnWorktreeCreate ?? globalSettings.copyUntrackedOnWorktreeCreate let initialWorktreeName: String? = if case .explicit(let name) = nameSource { name } else { nil } + // Pull any customization the New Worktree prompt parked for this + // (repo, branch) and attach it to the pending row so reconcile + // can render the user-typed title / color while git creates the + // worktree. Drop the dict entry to avoid leaks if the same name + // is used in a later run. + let pendingCustomization: PendingWorktree.Customization? + if let initialWorktreeName { + pendingCustomization = state.pendingCreationCustomizations[repository.id]?[initialWorktreeName] + state.dropPendingCustomization(repositoryID: repository.id, branchName: initialWorktreeName) + } else { + pendingCustomization = nil + } state.pendingWorktrees.append( PendingWorktree( id: pendingID, repositoryID: repository.id, - progress: WorktreeCreationProgress(stage: .loadingLocalBranches, worktreeName: initialWorktreeName) + progress: WorktreeCreationProgress(stage: .loadingLocalBranches, worktreeName: initialWorktreeName), + customization: pendingCustomization ) ) Self.syncSidebar(&state) @@ -1424,6 +1488,9 @@ struct RepositoriesFeature { } case .worktreeCreationPrompt(.dismiss): + // Don't drain `pendingCreationCustomizations` here: `.dismiss` also fires on the success + // path (when the reducer nils the prompt after validation passes) and the in-flight + // creation still needs the customization. Only `.cancel` is an explicit user back-out. state.worktreeCreationPrompt = nil return .merge( .cancel(id: CancelID.worktreePromptLoad), @@ -1444,6 +1511,11 @@ struct RepositoriesFeature { let pendingID ): analyticsClient.capture("worktree_created", nil) + // Capture the pending row's customization BEFORE the pending drops, + // then forward it to the bucketed Item so reconcile renders the + // user-typed title / color from the very first paint after the + // pending row swaps to the real worktree. + let carriedCustomization = state.pendingWorktrees.first(where: { $0.id == pendingID })?.customization state.removePendingWorktree(pendingID) if state.selection == .worktree(pendingID) { // History was already recorded when the pending row was @@ -1454,6 +1526,19 @@ struct RepositoriesFeature { state.setSingleWorktreeSelection(worktree.id, recordHistory: false) } state.insertWorktree(worktree, repositoryID: repositoryID) + if let carriedCustomization, carriedCustomization.title != nil || carriedCustomization.color != nil { + // Seed customization into whatever bucket currently holds the row (falls back to + // `.unpinned` for a brand-new worktree). The bucket probe avoids manufacturing a + // phantom double-bucket entry against a persisted `.pinned` Item. + state.$sidebar.withLock { sidebar in + sidebar.mergeCustomization( + title: carriedCustomization.title, + color: carriedCustomization.color, + worktree: worktree.id, + in: repositoryID + ) + } + } Self.syncSidebar(&state) // Synchronous so the detail body never observes a brief `.idle` window // between the real-worktree swap and the setup-script path. @@ -1555,6 +1640,11 @@ struct RepositoriesFeature { let archivedDisplay = AppShortcuts.archivedWorktrees .effective(from: settingsFile.global.shortcutOverrides)?.display ?? "none" + let alertWorktreeName = + SidebarDisplayName.resolved( + custom: state.sidebarItems[id: worktree.id]?.customTitle, + fallback: worktree.name + ) ?? worktree.name state.alert = AlertState { TextState("Archive worktree?") } actions: { @@ -1566,7 +1656,7 @@ struct RepositoriesFeature { } } message: { TextState( - "You can find \(worktree.name) later in Menu Bar > Worktrees > Archived Worktrees (\(archivedDisplay))." + "You can find \(alertWorktreeName) later in Menu Bar > Worktrees > Archived Worktrees (\(archivedDisplay))." ) } return .none @@ -2572,11 +2662,22 @@ struct RepositoriesFeature { // invariant against pre-states that have the row in `.pinned` and // `.unpinned` simultaneously (hand-edit, migrator race) and also // handles the not-bucketed case (folders before first reconcile). - sidebar.removeAnywhere(worktree: worktreeID, in: repositoryID) + // The carried Item preserves user-set `title` / `color` across + // the bucket move. Prefer the logical source (`.unpinned`) so a + // corrupted double-bucket pre-state surfaces the live unpinned + // row's payload, not a stale `.pinned` sibling. + var carried = + sidebar.removeAnywhere( + worktree: worktreeID, + in: repositoryID, + preferring: [.unpinned, .pinned, .archived] + ) ?? .init() + carried.archivedAt = nil sidebar.insert( worktree: worktreeID, in: repositoryID, bucket: .pinned, + item: carried, position: 0 ) } @@ -2596,12 +2697,22 @@ struct RepositoriesFeature { analyticsClient.capture("worktree_unpinned", nil) state.$sidebar.withLock { sidebar in // Same invariant as `pinWorktree`: collapse any pre-existing - // bucket placement into a single `.unpinned` entry. - sidebar.removeAnywhere(worktree: worktreeID, in: repositoryID) + // bucket placement into a single `.unpinned` entry, carrying + // the Item forward so `title` / `color` survive unpin. Prefer + // `.pinned` so a corrupted double-bucket pre-state surfaces + // the live pinned row's payload over a stale unpinned sibling. + var carried = + sidebar.removeAnywhere( + worktree: worktreeID, + in: repositoryID, + preferring: [.pinned, .unpinned, .archived] + ) ?? .init() + carried.archivedAt = nil sidebar.insert( worktree: worktreeID, in: repositoryID, bucket: .unpinned, + item: carried, position: 0 ) } @@ -3406,6 +3517,12 @@ struct RepositoriesFeature { case .repositoryCustomization: return .none + case .requestCustomizeWorktree, + .worktreeCustomization: + // Handled by `WorktreeCustomizationParentReducer` below; main switch is at type-checker + // capacity, so the customization arms are split out into a dedicated reducer. + return .none + case .requestRenameBranch(let worktreeID, let repositoryID): guard let repository = state.repositories[id: repositoryID], repository.isGitRepository, @@ -3485,6 +3602,10 @@ struct RepositoriesFeature { return .none } } + // These presentation `ifLet`s hang off the main `Reduce` so each child runs before the + // parent handles its `.delegate` / `.dismiss` and nils the state. The `.worktreeCustomization` + // ifLet sits on `worktreeCustomizationReducer` below (same child-first ordering, but kept off + // the main `Reduce` so the `body` expression stays within the type-checker's complexity limit). .forEach(\.sidebarItems, action: \.sidebarItems) { SidebarItemFeature() } @@ -3497,6 +3618,10 @@ struct RepositoriesFeature { .ifLet(\.$renameBranchPrompt, action: \.renameBranchPrompt) { RenameBranchFeature() } + Self.worktreeCustomizationReducer + .ifLet(\.$worktreeCustomization, action: \.worktreeCustomization) { + WorktreeCustomizationFeature() + } // Targeted post-reduce hook: only the actions that demonstrably touch // structure inputs trigger a recompute. The Equatable diff inside the // helper suppresses no-op rebuilds at the SwiftUI layer. Gated on @@ -3707,34 +3832,122 @@ struct RepositoriesFeature { return (loaded, failures) } - private func applyRepositories( - _ repositories: [Repository], - roots: [URL], - shouldPruneArchivedWorktreeIDs: Bool, - state: inout State, - animated: Bool - ) -> ApplyRepositoriesResult { + /// Customization transfer record produced by `prunedPendingWorktrees` and + /// consumed by `seedCustomizationForDiscoveredWorktree`. A struct rather + /// than a tuple so the two helpers can pass the payload around without + /// tripping the `large_tuple` lint. + private struct PendingCustomizationTransfer { + let repositoryID: Repository.ID + let worktreeName: String + let customization: PendingWorktree.Customization + } + + /// Filter `state.pendingWorktrees` against a freshly-loaded roster. Pending + /// rows whose `worktreeName` matches a newly-discovered worktree are pruned + /// and (when customized) hand their title / color to the caller for + /// transfer onto the bucketed Item. Pending rows without a final name fall + /// back to a count-based drop so the random-name path keeps its old shape. + private func prunedPendingWorktrees( + state: State, + repositories: [Repository], + repositoryIDs: Set + ) -> ([PendingWorktree], [PendingCustomizationTransfer]) { let previousCounts = Dictionary( uniqueKeysWithValues: state.repositories.map { ($0.id, $0.worktrees.count) } ) - let repositoryIDs = Set(repositories.map(\.id)) - let newCounts = Dictionary( - uniqueKeysWithValues: repositories.map { ($0.id, $0.worktrees.count) } - ) + let newCounts = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0.worktrees.count) }) var addedCounts: [Repository.ID: Int] = [:] for (id, newCount) in newCounts { - let oldCount = previousCounts[id] ?? 0 - let added = newCount - oldCount - if added > 0 { - addedCounts[id] = added + let added = newCount - (previousCounts[id] ?? 0) + if added > 0 { addedCounts[id] = added } + } + var remainingDiscoveredNamesByRepo: [Repository.ID: Set] = [:] + for repository in repositories { + let previousNames = Set(state.repositories[id: repository.id]?.worktrees.map(\.name) ?? []) + let added = Set(repository.worktrees.map(\.name)).subtracting(previousNames) + if !added.isEmpty { remainingDiscoveredNamesByRepo[repository.id] = added } + } + var transfers: [PendingCustomizationTransfer] = [] + // Pass 1: consume name matches up front. This drains the discovered-name + // set AND the count budget so the count-based fallback in pass 2 only + // fires for the leftover budget, never for a discovered name that a + // later pending row was going to match. + var droppedPendingIDs: Set = [] + for pending in state.pendingWorktrees { + guard repositoryIDs.contains(pending.repositoryID), + let pendingName = pending.progress.worktreeName, + remainingDiscoveredNamesByRepo[pending.repositoryID]?.contains(pendingName) == true + else { continue } + remainingDiscoveredNamesByRepo[pending.repositoryID]?.remove(pendingName) + if let customization = pending.customization, + customization.title != nil || customization.color != nil + { + transfers.append( + PendingCustomizationTransfer( + repositoryID: pending.repositoryID, + worktreeName: pendingName, + customization: customization, + ) + ) } + addedCounts[pending.repositoryID, default: 0] = max(0, (addedCounts[pending.repositoryID] ?? 0) - 1) + droppedPendingIDs.insert(pending.id) } - let filteredPendingWorktrees = state.pendingWorktrees.filter { pending in + // Pass 2: count-based drop for the unnamed remainder. Named pending rows + // only drop via pass 1's exact name match; otherwise concurrent creations + // can prune the wrong row when only a sibling worktree appears. + let filtered = state.pendingWorktrees.filter { pending in guard repositoryIDs.contains(pending.repositoryID) else { return false } + if droppedPendingIDs.contains(pending.id) { return false } + if pending.progress.worktreeName != nil { return true } guard let remaining = addedCounts[pending.repositoryID], remaining > 0 else { return true } addedCounts[pending.repositoryID] = remaining - 1 return false } + return (filtered, transfers) + } + + /// Write each transferred pending customization onto the bucketed Item for + /// the matching newly-discovered worktree. Skips fields the user has + /// already set via Customize Worktree… so the bucketed Item stays + /// authoritative once non-nil. + private func seedCustomizationForDiscoveredWorktree( + transfers: [PendingCustomizationTransfer], + repositories: [Repository], + state: inout State + ) { + guard !transfers.isEmpty else { return } + state.$sidebar.withLock { sidebar in + for transfer in transfers { + guard + let worktreeID = repositories.first(where: { $0.id == transfer.repositoryID })? + .worktrees.first(where: { $0.name == transfer.worktreeName })?.id + else { continue } + sidebar.mergeCustomization( + title: transfer.customization.title, + color: transfer.customization.color, + worktree: worktreeID, + in: transfer.repositoryID + ) + } + } + } + + private func applyRepositories( + _ repositories: [Repository], + roots: [URL], + shouldPruneArchivedWorktreeIDs: Bool, + state: inout State, + animated: Bool + ) -> ApplyRepositoriesResult { + let repositoryIDs = Set(repositories.map(\.id)) + let (filteredPendingWorktrees, customizationTransfers) = + prunedPendingWorktrees(state: state, repositories: repositories, repositoryIDs: repositoryIDs) + seedCustomizationForDiscoveredWorktree( + transfers: customizationTransfers, + repositories: repositories, + state: &state, + ) let availableWorktreeIDs = Set(repositories.flatMap { $0.worktrees.map(\.id) }) let (filteredRemovingRepositoryIDs, filteredActiveRemovalBatches) = prunedRemovalTrackers(state: state, availableRepoIDs: repositoryIDs) @@ -4410,11 +4623,15 @@ extension RepositoriesFeature.State { let nameByRepoID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0.name) }) return ids.compactMap { id in guard let item = sidebarItems[id: id] else { return nil } + let repositoryName = Repository.sidebarDisplayName( + custom: sidebar.sections[item.repositoryID]?.title, + fallback: nameByRepoID[item.repositoryID] ?? "" + ) return HotkeyWorktreeSlot( id: item.id, - name: item.name, + name: SidebarDisplayName.resolved(custom: item.customTitle, fallback: item.name) ?? item.name, repositoryID: item.repositoryID, - repositoryName: nameByRepoID[item.repositoryID] ?? "" + repositoryName: repositoryName ) } } @@ -4434,6 +4651,21 @@ extension RepositoriesFeature.State { RepositoriesFeature.syncSidebar(&self) } + /// Single source of truth for draining the in-flight customization parked by the New Worktree + /// prompt's `submit` delegate. `branchName == nil` drains every entry for the repo (used by the + /// removing-repo guard and prompt cancel); a non-nil `branchName` drains just that (repo, branch) + /// pair so concurrent creations don't lose unrelated customizations. + mutating func dropPendingCustomization(repositoryID: Repository.ID, branchName: String? = nil) { + if let branchName { + pendingCreationCustomizations[repositoryID]?.removeValue(forKey: branchName) + } else { + pendingCreationCustomizations.removeValue(forKey: repositoryID) + } + if pendingCreationCustomizations[repositoryID]?.isEmpty == true { + pendingCreationCustomizations.removeValue(forKey: repositoryID) + } + } + @discardableResult mutating func updatePendingWorktreeProgress( _ id: String, diff --git a/supacode/Features/Repositories/Reducer/RepositoryCustomizationFeature.swift b/supacode/Features/Repositories/Reducer/RepositoryCustomizationFeature.swift index 893c66f33..351d32c08 100644 --- a/supacode/Features/Repositories/Reducer/RepositoryCustomizationFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoryCustomizationFeature.swift @@ -37,7 +37,8 @@ struct RepositoryCustomizationFeature { case .saveButtonTapped: let trimmed = state.title.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedTitle = trimmed.isEmpty || trimmed == state.defaultName ? nil : trimmed + // Preserve verbatim; typing the default name locks it in as an override. + let resolvedTitle = trimmed.isEmpty ? nil : trimmed return .send( .delegate( .save( diff --git a/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift b/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift index 98428943c..2f91a5305 100644 --- a/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift +++ b/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift @@ -50,6 +50,12 @@ struct SidebarItemFeature { var hasMergedBadge: Bool /// Mirror of `Worktree.isMissing`; drives the orphan row UI. var isMissing: Bool = false + /// Mirror of `SidebarState.Item.title`; reconcile fans this in from + /// `@Shared(.sidebar)`. `nil` or whitespace-only means fall back to `name`. + var customTitle: String? + /// Mirror of `SidebarState.Item.color`; reconcile fans this in from + /// `@Shared(.sidebar)`. `nil` means default styling. + var customTint: RepositoryColor? var lifecycle: Lifecycle = .idle @@ -216,9 +222,15 @@ extension SidebarItemFeature.State { /// then the subtitle's last path component, then `branchName`. var sidebarDisplayName: String? { SidebarDisplayName.compute( - isMainWorktree: isMainWorktree, id: id, subtitle: subtitle, branchName: branchName + isMainWorktree: isMainWorktree, id: id, subtitle: subtitle, branchName: branchName, ) } + /// Final string the row should render: user override (trimmed) when set, + /// else `sidebarDisplayName`. Centralised so sidebar / archive / detail + /// views stay in lock-step on the empty / whitespace fallback rule. + var resolvedSidebarTitle: String? { + SidebarDisplayName.resolved(custom: customTitle, fallback: sidebarDisplayName) + } var accent: WorktreeAccent { WorktreeAccent.derive(isMainWorktree: isMainWorktree, isPinned: isPinned) } /// True iff any tracked agent on this row is awaiting user input. /// Drives the Active section's classification ("agent awaiting input"). @@ -233,7 +245,7 @@ enum SidebarDisplayName { isMainWorktree: Bool, id: SidebarItemID, subtitle: String?, - branchName: String + branchName: String, ) -> String? { guard !isMainWorktree else { return nil } if id.contains("/") { @@ -246,6 +258,15 @@ enum SidebarDisplayName { } return branchName } + + /// Returns `custom` when set (after trim), otherwise `fallback`. Shared so + /// the sidebar, archive, and detail views can't drift on the empty / + /// whitespace fallback rule for user-overridden titles. + static func resolved(custom: String?, fallback: String?) -> String? { + let trimmed = custom?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { return trimmed } + return fallback + } } extension SidebarItemFeature.State.Lifecycle { @@ -278,6 +299,8 @@ struct SelectedWorktreeSlice: Equatable, Sendable { let subtitle: String? let isMainWorktree: Bool let isPinned: Bool + let customTitle: String? + let customTint: RepositoryColor? let lifecycle: SidebarItemFeature.State.Lifecycle let pullRequest: GithubPullRequest? let runningScripts: IdentifiedArrayOf @@ -291,6 +314,8 @@ struct SelectedWorktreeSlice: Equatable, Sendable { self.subtitle = row.subtitle self.isMainWorktree = row.isMainWorktree self.isPinned = row.isPinned + self.customTitle = row.customTitle + self.customTint = row.customTint self.lifecycle = row.lifecycle self.pullRequest = row.pullRequest self.runningScripts = row.runningScripts @@ -298,10 +323,14 @@ struct SelectedWorktreeSlice: Equatable, Sendable { var sidebarDisplayName: String? { SidebarDisplayName.compute( - isMainWorktree: isMainWorktree, id: id, subtitle: subtitle, branchName: branchName + isMainWorktree: isMainWorktree, id: id, subtitle: subtitle, branchName: branchName, ) } + var resolvedSidebarTitle: String? { + SidebarDisplayName.resolved(custom: customTitle, fallback: sidebarDisplayName) + } + var accent: WorktreeAccent { WorktreeAccent.derive(isMainWorktree: isMainWorktree, isPinned: isPinned) } var isFolder: Bool { kind == .folder } diff --git a/supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift index 5af03c9b3..db9b81834 100644 --- a/supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift @@ -33,6 +33,10 @@ struct WorktreeCreationPromptFeature { var showAdvancedOptions: Bool = false var validationMessage: String? var isValidating = false + /// Optional sidebar customization captured by the new Title / Color + /// section; transferred to `PendingWorktree.customization` on submit. + var title: String = "" + var color: RepositoryColor? /// Default leaf folder name shown as the name-override placeholder. var worktreeNamePlaceholder: String { @@ -100,7 +104,9 @@ struct WorktreeCreationPromptFeature { branchName: String, baseRef: String?, fetchOrigin: Bool, - placement: WorktreePlacementOverride + placement: WorktreePlacementOverride, + title: String?, + color: RepositoryColor? ) } @@ -137,6 +143,12 @@ struct WorktreeCreationPromptFeature { } state.validationMessage = nil let pathOverride = state.worktreePathOverride.trimmingCharacters(in: .whitespacesAndNewlines) + // Preserve the user's typed title verbatim, even when it equals the + // branch name. The render is identical (no override → fall back to + // branch name) but the round-trip into the Customize sheet relies + // on the value surviving. + let trimmedTitle = state.title.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedTitle = trimmedTitle.isEmpty ? nil : trimmedTitle return .send( .delegate( .submit( @@ -148,7 +160,9 @@ struct WorktreeCreationPromptFeature { placement: WorktreePlacementOverride( name: nameOverride.isEmpty ? nil : nameOverride, path: pathOverride.isEmpty ? nil : pathOverride - ) + ), + title: resolvedTitle, + color: state.color ) ) ) diff --git a/supacode/Features/Repositories/Reducer/WorktreeCustomizationFeature.swift b/supacode/Features/Repositories/Reducer/WorktreeCustomizationFeature.swift new file mode 100644 index 000000000..6e1ee534f --- /dev/null +++ b/supacode/Features/Repositories/Reducer/WorktreeCustomizationFeature.swift @@ -0,0 +1,65 @@ +import ComposableArchitecture +import Foundation +import SupacodeSettingsShared + +@Reducer +struct WorktreeCustomizationFeature { + @ObservableState + struct State: Equatable { + let worktreeID: Worktree.ID + let repositoryID: Repository.ID + let defaultName: String + var title: String + var color: RepositoryColor? + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case cancelButtonTapped + case saveButtonTapped + case delegate(Delegate) + } + + @CasePathable + enum Delegate: Equatable { + case cancel + case save( + worktreeID: Worktree.ID, + repositoryID: Repository.ID, + title: String?, + color: RepositoryColor?, + ) + } + + var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case .cancelButtonTapped: + return .send(.delegate(.cancel)) + + case .saveButtonTapped: + let trimmed = state.title.trimmingCharacters(in: .whitespacesAndNewlines) + // Preserve the user's input verbatim; typing the default name "locks in" the current name + // as a real override rather than silently collapsing to nil. + let resolvedTitle = trimmed.isEmpty ? nil : trimmed + return .send( + .delegate( + .save( + worktreeID: state.worktreeID, + repositoryID: state.repositoryID, + title: resolvedTitle, + color: state.color, + ) + ) + ) + + case .delegate: + return .none + } + } + } +} diff --git a/supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift b/supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift index f6c8638a9..6a5515c14 100644 --- a/supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift +++ b/supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift @@ -5,8 +5,13 @@ import SwiftUI struct ArchivedWorktreeRowView: View { let worktree: Worktree let pullRequest: GithubPullRequest? + let customTitle: String? + let customTint: RepositoryColor? let onUnarchive: () -> Void let onDelete: () -> Void + // The system paints selected rows with white-on-blue chrome; the custom tint must yield so the + // selected row stays readable (matches `SidebarItemView.TitleView`). + @Environment(\.backgroundProminence) private var backgroundProminence var body: some View { let display = WorktreePullRequestDisplay( @@ -15,6 +20,11 @@ struct ArchivedWorktreeRowView: View { ) let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display let bodyFontAscender = NSFont.preferredFont(forTextStyle: .body).ascender + // User override wins; fall back to the branch / folder name on whitespace + // or nil. Centralised so archive / sidebar / detail can't drift. + let displayName = + SidebarDisplayName.resolved(custom: customTitle, fallback: worktree.name) + ?? worktree.name VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 8) { Image(systemName: "archivebox") @@ -25,9 +35,14 @@ struct ArchivedWorktreeRowView: View { .alignmentGuide(.firstTextBaseline) { _ in bodyFontAscender } - Text(worktree.name) + let titleText = Text(displayName) .font(.body) .lineLimit(1) + if let customTint, backgroundProminence != .increased { + titleText.foregroundStyle(customTint.color) + } else { + titleText + } Spacer(minLength: 8) HStack(spacing: 8) { Button { diff --git a/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift b/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift index 356adff31..e497a75ec 100644 --- a/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift +++ b/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift @@ -43,6 +43,8 @@ struct ArchivedWorktreesDetailView: View { ArchivedWorktreeRowView( worktree: worktree, pullRequest: store.state.sidebarItems[id: worktree.id]?.pullRequest, + customTitle: store.state.sidebarItems[id: worktree.id]?.customTitle, + customTint: store.state.sidebarItems[id: worktree.id]?.customTint, onUnarchive: { store.send(.unarchiveWorktree(worktree.id)) }, diff --git a/supacode/Features/Repositories/Views/RepositoryCustomizationView.swift b/supacode/Features/Repositories/Views/RepositoryCustomizationView.swift index b9d08dc8b..cc0a69d46 100644 --- a/supacode/Features/Repositories/Views/RepositoryCustomizationView.swift +++ b/supacode/Features/Repositories/Views/RepositoryCustomizationView.swift @@ -19,7 +19,7 @@ struct RepositoryCustomizationView: View { ColorSwatchRow(color: $store.color) } } header: { - Text("Customize Repository") + Text("Customize Appearance") Text("Override the sidebar title and tint for `\(store.defaultName)`.") } .headerProminence(.increased) diff --git a/supacode/Features/Repositories/Views/SidebarItemView.swift b/supacode/Features/Repositories/Views/SidebarItemView.swift index 107923156..bce3607e2 100644 --- a/supacode/Features/Repositories/Views/SidebarItemView.swift +++ b/supacode/Features/Repositories/Views/SidebarItemView.swift @@ -42,7 +42,9 @@ struct SidebarItemView: View { isPinned: store.isPinned, hideSubtitle: hideSubtitle, hideSubtitleOnMatch: hideSubtitleOnMatch, - highlightSubtitle: highlightSubtitle + highlightSubtitle: highlightSubtitle, + customTitle: store.customTitle, + customTint: store.customTint ) Label { @@ -51,6 +53,7 @@ struct SidebarItemView: View { name: resolved.name, subtitle: resolved.subtitle, accent: resolved.accent, + customTint: store.customTint, isLifecycleBusy: store.lifecycle.isBusy, isTaskRunning: store.isTaskRunning ) @@ -102,13 +105,19 @@ struct ResolvedRowDisplay: Equatable { isPinned: Bool, hideSubtitle: Bool, hideSubtitleOnMatch: Bool, - highlightSubtitle: SidebarHighlightRepoTag? = nil + highlightSubtitle: SidebarHighlightRepoTag? = nil, + customTitle: String? = nil, + customTint: RepositoryColor? = nil ) { self.accent = if isMainWorktree { .main } else if isPinned { .pinned } else { .default } + // User override (trimmed) takes precedence over derived names. + let resolvedCustom = SidebarDisplayName.resolved(custom: customTitle, fallback: nil) + let hasCustomTitle = resolvedCustom != nil + if kind == .folder { - self.name = branchName + self.name = resolvedCustom ?? branchName // Folder rows ARE the repo, so a repo prefix would just repeat the title. self.subtitle = .none return @@ -116,16 +125,17 @@ struct ResolvedRowDisplay: Equatable { let resolvedWorktreeName = worktreeName ?? "Default" let effectiveWorktreeName = resolvedWorktreeName.isEmpty ? branchName : resolvedWorktreeName - self.name = branchName + self.name = resolvedCustom ?? branchName let branchLastComponent = branchName.split(separator: "/").last.map(String.init) ?? branchName let isMatch = effectiveWorktreeName == branchLastComponent + // Once a user types a custom title, they've lost the visual cue that the auto-derived name was + // providing, so we always render the subtitle even when it would otherwise collapse on match. + let shouldHideOnMatch = hideSubtitleOnMatch && !hasCustomTitle && isMatch if let highlightSubtitle { - // Hide-on-match drop mirrors the per-repo subtitle path so a row - // doesn't change its disambiguation policy across hoisting. let trail: String? - if hideSubtitleOnMatch && isMatch { + if shouldHideOnMatch { trail = nil } else if isMainWorktree { trail = "Default" @@ -142,7 +152,7 @@ struct ResolvedRowDisplay: Equatable { return } - if hideSubtitle || (hideSubtitleOnMatch && isMatch) { + if hideSubtitle || shouldHideOnMatch { self.subtitle = .none } else { self.subtitle = .plain(effectiveWorktreeName) @@ -235,6 +245,8 @@ private struct TitleView: View, Equatable { let name: String let subtitle: ResolvedRowDisplay.Subtitle let accent: WorktreeAccent + /// User-supplied row tint. When set, paints the title; otherwise the title uses the default. + let customTint: RepositoryColor? let isLifecycleBusy: Bool let isTaskRunning: Bool // `==` ignores @Environment; SwiftUI tracks env changes separately. @@ -244,6 +256,7 @@ private struct TitleView: View, Equatable { lhs.name == rhs.name && lhs.subtitle == rhs.subtitle && lhs.accent == rhs.accent + && lhs.customTint == rhs.customTint && lhs.isLifecycleBusy == rhs.isLifecycleBusy && lhs.isTaskRunning == rhs.isTaskRunning } @@ -253,10 +266,14 @@ private struct TitleView: View, Equatable { let isEmphasized = backgroundProminence == .increased let accentStyle = accent.shapeStyle(emphasized: isEmphasized) VStack(alignment: .leading, spacing: 0) { - Text(name) + let titleText = Text(name) .font(.body) .lineLimit(1) - .shimmer(isActive: isBusy) + if let customTint, !isEmphasized { + titleText.foregroundStyle(customTint.color).shimmer(isActive: isBusy) + } else { + titleText.shimmer(isActive: isBusy) + } switch subtitle { case .none: EmptyView() @@ -270,8 +287,7 @@ private struct TitleView: View, Equatable { isEmphasized ? AnyShapeStyle(.secondary) : repoColor.map { AnyShapeStyle($0.color) } ?? AnyShapeStyle(.secondary) - // `.layoutPriority(1)` on the repo makes the trail yield first under - // a narrow sidebar so the colored repo tag survives truncation. + // `.layoutPriority(1)` on the repo makes the trail yield first under a narrow sidebar. HStack(spacing: 0) { Text(repo) .foregroundStyle(repoStyle) diff --git a/supacode/Features/Repositories/Views/SidebarItemsView.swift b/supacode/Features/Repositories/Views/SidebarItemsView.swift index c317ed27a..50e09ba7b 100644 --- a/supacode/Features/Repositories/Views/SidebarItemsView.swift +++ b/supacode/Features/Repositories/Views/SidebarItemsView.swift @@ -640,32 +640,7 @@ private struct SidebarItemContextMenu: View { Divider() } - // Folder synthetic rows pass `isMainWorktree` by geometry but are - // pinnable; git "main" rows still aren't. - let pinnableRows = contextRows.filter { !$0.isMainWorktree || $0.isFolder } - if !pinnableRows.isEmpty { - let allPinned = pinnableRows.allSatisfy(\.isPinned) - let allFolders = pinnableRows.allSatisfy(\.isFolder) - // Folder-only selection reads "Pin Folder" / "Pin Folders"; mixed or - // git-only fall back to "Worktree" so the label stays accurate. - let noun = allFolders ? "Folder" : "Worktree" - if allPinned { - let label = isBulkSelection ? "Unpin \(noun)s" : "Unpin \(noun)" - Button(label, systemImage: "pin.slash") { - for pinnableRow in pinnableRows { - togglePin(for: pinnableRow.id, isPinned: true) - } - } - } else { - let label = isBulkSelection ? "Pin \(noun)s" : "Pin \(noun)" - Button(label, systemImage: "pin") { - for pinnableRow in pinnableRows where !pinnableRow.isPinned { - togglePin(for: pinnableRow.id, isPinned: false) - } - } - } - Divider() - } + pinActions(contextRows: contextRows, isBulkSelection: isBulkSelection) if !isBulkSelection { Button("Copy as Pathname", systemImage: "doc.on.doc") { @@ -691,12 +666,29 @@ private struct SidebarItemContextMenu: View { } Divider() if rowIsFolder { + // Folder rows render through SidebarItemRow, which reads customization from the per-row + // bucket Item. Route through the same worktree path so the folder row picks up the title + // / color the user picks (section.title would only tint a folder-section header, but + // folder sections render with an empty header). + Button("Customize Appearance…", systemImage: "paintbrush") { + store.send(.requestCustomizeWorktree(rowID, repositoryID)) + } + .help("Set a custom title or color") // Folder rows have no section ellipsis menu, so Settings lives here. Button("Folder Settings…", systemImage: "gear") { store.send(.openRepositorySettings(repositoryID)) } .help("Open folder settings") Divider() + } else if let row = contextRows.first, + !row.isMainWorktree, + !row.lifecycle.isPending + { + Button("Customize Appearance…", systemImage: "paintbrush") { + store.send(.requestCustomizeWorktree(rowID, repositoryID)) + } + .help("Set a custom title or color") + Divider() } } @@ -739,6 +731,38 @@ private struct SidebarItemContextMenu: View { } } + @ViewBuilder + private func pinActions(contextRows: [SidebarItemFeature.State], isBulkSelection: Bool) -> some View { + // Folder synthetic rows pass `isMainWorktree` by geometry but are pinnable; git "main" still + // aren't. Pending rows can't pin (reducer would no-op on the unresolved ID). + let pinnableRows = contextRows.filter { + (!$0.isMainWorktree || $0.isFolder) && !$0.lifecycle.isPending + } + if !pinnableRows.isEmpty { + let allPinned = pinnableRows.allSatisfy(\.isPinned) + let allFolders = pinnableRows.allSatisfy(\.isFolder) + // Folder-only selection reads "Pin Folder" / "Pin Folders"; mixed or + // git-only fall back to "Worktree" so the label stays accurate. + let noun = allFolders ? "Folder" : "Worktree" + if allPinned { + let label = isBulkSelection ? "Unpin \(noun)s" : "Unpin \(noun)" + Button(label, systemImage: "pin.slash") { + for pinnableRow in pinnableRows { + togglePin(for: pinnableRow.id, isPinned: true) + } + } + } else { + let label = isBulkSelection ? "Pin \(noun)s" : "Pin \(noun)" + Button(label, systemImage: "pin") { + for pinnableRow in pinnableRows where !pinnableRow.isPinned { + togglePin(for: pinnableRow.id, isPinned: false) + } + } + } + Divider() + } + } + @ViewBuilder private func openActions(overrides: [AppShortcutID: AppShortcutOverride]) -> some View { let availableActions = OpenWorktreeAction.availableCases.filter { $0 != .finder } diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index fdbba4d20..71efb9b6a 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -305,10 +305,10 @@ private struct SidebarSectionActionsView: View { var body: some View { Menu { - Button("Customize Repository…", systemImage: "paintbrush") { + Button("Customize Appearance…", systemImage: "paintbrush") { store.send(.requestCustomizeRepository(repositoryID)) } - .help("Customize sidebar title and color") + .help("Set a custom title or color") .disabled(isRemovingRepository) Button("Repository Settings…", systemImage: "gear") { store.send(.openRepositorySettings(repositoryID)) diff --git a/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift b/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift index 9586cc5be..5d28ae25c 100644 --- a/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift @@ -1,4 +1,6 @@ import ComposableArchitecture +import SupacodeSettingsFeature +import SupacodeSettingsShared import SwiftUI struct WorktreeCreationPromptView: View { @@ -35,6 +37,20 @@ struct WorktreeCreationPromptView: View { .disabled(store.isSelectedBaseRefLocal) } + Section { + TextField( + "Title", + text: $store.title, + prompt: Text("Leave blank to use branch name") + ) + LabeledContent("Color") { + ColorSwatchRow(color: $store.color) + } + } header: { + Text("Title & Color") + Text("Optional. Overrides the row title and tint. Leave blank to use the branch name.") + } + WorktreeOptionsSection(store: store) } .formStyle(.grouped) @@ -63,6 +79,7 @@ struct WorktreeCreationPromptView: View { } .frame(minWidth: 420) .task { isBranchFieldFocused = true } + .dismissSystemColorPanelOnDisappear() } } diff --git a/supacode/Features/Repositories/Views/WorktreeCustomizationView.swift b/supacode/Features/Repositories/Views/WorktreeCustomizationView.swift new file mode 100644 index 000000000..a86b3b6a9 --- /dev/null +++ b/supacode/Features/Repositories/Views/WorktreeCustomizationView.swift @@ -0,0 +1,50 @@ +import ComposableArchitecture +import SupacodeSettingsFeature +import SupacodeSettingsShared +import SwiftUI + +struct WorktreeCustomizationView: View { + @Bindable var store: StoreOf + @FocusState private var isTitleFocused: Bool + + var body: some View { + Form { + Section { + TextField("Title", text: $store.title, prompt: Text(store.defaultName)) + .focused($isTitleFocused) + .onSubmit { + store.send(.saveButtonTapped) + } + LabeledContent("Color") { + ColorSwatchRow(color: $store.color) + } + } header: { + Text("Customize Appearance") + Text("Override the sidebar title and tint for `\(store.defaultName)`.") + } + .headerProminence(.increased) + } + .formStyle(.grouped) + .scrollBounceBehavior(.basedOnSize) + .safeAreaInset(edge: .bottom, spacing: 0) { + HStack { + Spacer() + Button("Cancel") { + store.send(.cancelButtonTapped) + } + .keyboardShortcut(.cancelAction) + .help("Cancel (Esc)") + Button("Save") { + store.send(.saveButtonTapped) + } + .keyboardShortcut(.defaultAction) + .help("Save (↩)") + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + .frame(minWidth: 420) + .task { isTitleFocused = true } + .dismissSystemColorPanelOnDisappear() + } +} diff --git a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift index a5ccb6887..3f9306e0b 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift @@ -9,13 +9,18 @@ import SwiftUI enum WorktreeToolbarTitleContent: Hashable, Sendable { case git(GitPayload) - case folder(name: String) + case folder(name: String, tint: RepositoryColor?) struct GitPayload: Hashable, Sendable { + /// Text rendered as the top-line headline. May be the literal branch name or the user's custom + /// title override; never used for accessibility ("Branch X") since custom titles aren't refs. + let displayTitle: String + /// Actual git ref name. Used by accessibility so screen readers announce the real branch. let branchName: String let repositoryName: String let repositoryColor: RepositoryColor? let worktreeSubtitle: String? + let worktreeTint: RepositoryColor? let accent: WorktreeAccent let rootURL: URL } @@ -77,23 +82,26 @@ private struct WorktreeToolbarTitleBody: View { .frame(width: 24, height: 24) VStack(alignment: .leading, spacing: 0) { switch content { - case .folder(let name): + case .folder(let name, let tint): Text(name) .font(.callout.weight(.semibold)) + .foregroundStyle(tint?.color ?? .primary) .lineLimit(1) .truncationMode(.middle) case .git(let payload): - Text(payload.branchName) + Text(payload.displayTitle) .font(.callout.weight(.semibold)) + .foregroundStyle(payload.worktreeTint?.color ?? .primary) .lineLimit(1) .truncationMode(.middle) let repoText = Text(payload.repositoryName) .foregroundStyle(payload.repositoryColor?.color ?? .secondary) + let accentStyle = AnyShapeStyle(payload.accent.shapeStyle(emphasized: false)) let line: Text = if let worktreeSubtitle = payload.worktreeSubtitle { repoText + Text(" · ").foregroundStyle(.secondary) - + Text(worktreeSubtitle).foregroundStyle(payload.accent.shapeStyle(emphasized: false)) + + Text(worktreeSubtitle).foregroundStyle(accentStyle) } else { repoText } @@ -110,7 +118,7 @@ private struct WorktreeToolbarTitleBody: View { private var accessibilityLabel: String { switch content { - case .folder(let name): + case .folder(let name, _): return "Folder \(name)" case .git(let payload): let suffix = payload.worktreeSubtitle.map { ", worktree \($0)" } ?? "" @@ -165,10 +173,12 @@ enum GitHubOwnerAvatar { WorktreeToolbarTitleBody( content: .git( .init( + displayTitle: "sbertix/319-toolbar-details", branchName: "sbertix/319-toolbar-details", repositoryName: "supacode", repositoryColor: .blue, worktreeSubtitle: "319-toolbar-details", + worktreeTint: nil, accent: .pinned, rootURL: supacodeRepoRoot ) @@ -184,10 +194,12 @@ enum GitHubOwnerAvatar { WorktreeToolbarTitleBody( content: .git( .init( + displayTitle: "main", branchName: "main", repositoryName: "supacode", repositoryColor: .blue, worktreeSubtitle: "Default", + worktreeTint: nil, accent: .main, rootURL: URL(fileURLWithPath: "/tmp/preview") ) @@ -200,7 +212,7 @@ enum GitHubOwnerAvatar { #Preview("Folder") { Text("").toolbar { ToolbarItem { - WorktreeToolbarTitleBody(content: .folder(name: "Documents")) + WorktreeToolbarTitleBody(content: .folder(name: "Documents", tint: nil)) } }.frame(width: 600, height: 600) } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index d7c9d6d4a..015524983 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -558,12 +558,14 @@ struct WorktreeDetailView: View { let repositoryID = selectedRow?.repositoryID let repository = repositoryID.flatMap { repositories.repositories[id: $0] } let section = repositoryID.flatMap { repositories.sidebar.sections[$0] } - let customTitle = section?.title?.trimmingCharacters(in: .whitespacesAndNewlines) let defaultName = repository?.name ?? selectedWorktree.repositoryRootURL.lastPathComponent - let repositoryName = customTitle.flatMap { $0.isEmpty ? nil : $0 } ?? defaultName + let repositoryName = SidebarDisplayName.resolved(custom: section?.title, fallback: defaultName) ?? defaultName if selectedRow?.isFolder == true { - return .folder(name: repositoryName) + // Folders use the per-row custom title (matches the sidebar's folder title position). + let folderName = + SidebarDisplayName.resolved(custom: selectedRow?.customTitle, fallback: repositoryName) ?? repositoryName + return .folder(name: folderName, tint: selectedRow?.customTint) } let worktreeSubtitle: String? = { @@ -576,6 +578,8 @@ struct WorktreeDetailView: View { { return nil } + // Subtitle stays on the auto-derived disambiguator (sidebarDisplayName) so the chrome shows + // identity context even when the user picked a custom title for the row. let worktreeName = selectedRow.sidebarDisplayName ?? "Default" let branchName = selectedWorktree.name let branchLastComponent = branchName.split(separator: "/").last.map(String.init) ?? branchName @@ -583,12 +587,23 @@ struct WorktreeDetailView: View { return worktreeName }() + // Top text mirrors the sidebar title: custom override if set, else the literal branch name. + // `branchName` stays on the real ref so VoiceOver announces "Branch " instead of + // the user-typed override (which isn't a ref). + let displayTitle = + SidebarDisplayName.resolved( + custom: selectedRow?.customTitle, + fallback: selectedWorktree.name + ) ?? selectedWorktree.name + return .git( .init( + displayTitle: displayTitle, branchName: selectedWorktree.name, repositoryName: repositoryName, repositoryColor: section?.color, worktreeSubtitle: worktreeSubtitle, + worktreeTint: selectedRow?.customTint, accent: selectedRow?.accent ?? .default, rootURL: selectedWorktree.repositoryRootURL ) @@ -1092,10 +1107,12 @@ private struct WorktreeToolbarPreview: View { toolbarState = WorktreeDetailView.WorktreeToolbarState( titleContent: .git( .init( + displayTitle: "feature/toolbar-preview", branchName: "feature/toolbar-preview", repositoryName: "supacode", repositoryColor: .blue, worktreeSubtitle: "toolbar-preview", + worktreeTint: nil, accent: .pinned, rootURL: URL(fileURLWithPath: "/tmp/preview") ) diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index d926f1f43..2450f1b9b 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -949,7 +949,9 @@ struct RepositoriesFeatureTests { branchName: "feature/new", baseRef: nil, fetchOrigin: true, - placement: WorktreePlacementOverride(name: nil, path: nil) + placement: WorktreePlacementOverride(name: nil, path: nil), + title: nil, + color: nil ) ) ) @@ -6475,7 +6477,7 @@ struct RepositoriesFeatureTests { hideSubtitleOnMatch: true ) - guard case .folder(let name) = content else { + guard case .folder(let name, _) = content else { Issue.record("Expected .folder content, got \(content)") return } diff --git a/supacodeTests/RepositoryCustomizationFeatureTests.swift b/supacodeTests/RepositoryCustomizationFeatureTests.swift index cccbab010..ae1c0a153 100644 --- a/supacodeTests/RepositoryCustomizationFeatureTests.swift +++ b/supacodeTests/RepositoryCustomizationFeatureTests.swift @@ -32,8 +32,8 @@ struct RepositoryCustomizationFeatureTests { )) } - @Test func saveDropsTitleWhenEmptyOrMatchesDefault() async { - let store = TestStore(initialState: makeState(title: " repo ")) { + @Test func saveDropsTitleOnlyWhenEmptyAfterTrim() async { + let store = TestStore(initialState: makeState(title: " ")) { RepositoryCustomizationFeature() } @@ -43,6 +43,18 @@ struct RepositoryCustomizationFeatureTests { ) } + @Test func savePreservesTitleEvenWhenItMatchesDefault() async { + // Typing the default name locks it in as an explicit override (doesn't collapse to nil). + let store = TestStore(initialState: makeState(title: "repo")) { + RepositoryCustomizationFeature() + } + + await store.send(.saveButtonTapped) + await store.receive( + .delegate(.save(repositoryID: "/tmp/repo", title: "repo", color: nil)), + ) + } + @Test func cancelDelegatesCancel() async { let store = TestStore(initialState: makeState()) { RepositoryCustomizationFeature() diff --git a/supacodeTests/SidebarStateTests.swift b/supacodeTests/SidebarStateTests.swift index 9c471d249..a7278308a 100644 --- a/supacodeTests/SidebarStateTests.swift +++ b/supacodeTests/SidebarStateTests.swift @@ -388,4 +388,195 @@ struct SidebarStateTests { #expect(decoded.collapsedBranchPrefixes.isEmpty) #expect(decoded.items.isEmpty) } + + // MARK: - Item Codable round-trip. + + @Test func itemRoundTripPreservesTitleAndColor() throws { + let archivedAt = Date(timeIntervalSinceReferenceDate: 1_700_000_000) + let original = SidebarState.Item(archivedAt: archivedAt, title: "Spicy", color: .custom("#0A1B2C")) + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(SidebarState.Item.self, from: encoded) + + #expect(decoded.archivedAt == archivedAt) + #expect(decoded.title == "Spicy") + #expect(decoded.color == .custom("#0A1B2C")) + } + + @Test func itemDecodingDropsMalformedHexColorWithoutKillingRow() throws { + // Forward-compat: a hex value introduced by a downgrade-corrupted file (or hand-edit) must + // drop just the color, never crash the row decode. + let malformedJSON = """ + { "title": "Renamed", "color": "not-a-hex" } + """ + let data = Data(malformedJSON.utf8) + let decoded = try JSONDecoder().decode(SidebarState.Item.self, from: data) + #expect(decoded.title == "Renamed") + #expect(decoded.color == nil) + } + + // MARK: - Archive / unarchive customization carry. + + @Test func archiveCarriesTitleAndColorFromSourceBucket() { + var state = SidebarState() + state.insert( + worktree: "wt-1", + in: "repo", + bucket: .pinned, + item: .init(title: "Spicy", color: .red) + ) + + state.archive(worktree: "wt-1", in: "repo", from: .pinned, at: Date(timeIntervalSince1970: 1_000)) + + let archived = state.sections["repo"]?.buckets[.archived]?.items["wt-1"] + #expect(archived?.title == "Spicy") + #expect(archived?.color == .red) + #expect(archived?.archivedAt == Date(timeIntervalSince1970: 1_000)) + } + + @Test func unarchiveCarriesTitleAndColorBackAndClearsArchivedAt() { + var state = SidebarState() + state.insert( + worktree: "wt-1", + in: "repo", + bucket: .archived, + item: .init(archivedAt: Date(timeIntervalSince1970: 1_000), title: "Spicy", color: .red) + ) + + state.unarchive(worktree: "wt-1", in: "repo") + + let unpinned = state.sections["repo"]?.buckets[.unpinned]?.items["wt-1"] + #expect(unpinned?.title == "Spicy") + #expect(unpinned?.color == .red) + #expect(unpinned?.archivedAt == nil) + #expect(state.sections["repo"]?.buckets[.archived]?.items["wt-1"] == nil) + } + + // MARK: - mergeCustomization invariants. + + @Test func mergeCustomizationLandsInExistingBucketWhenRowAlreadySeeded() { + var state = SidebarState() + state.insert(worktree: "wt-1", in: "repo", bucket: .pinned) + + state.mergeCustomization(title: "Spicy", color: .red, worktree: "wt-1", in: "repo") + + #expect(state.sections["repo"]?.buckets[.pinned]?.items["wt-1"]?.title == "Spicy") + #expect(state.sections["repo"]?.buckets[.pinned]?.items["wt-1"]?.color == .red) + #expect(state.sections["repo"]?.buckets[.unpinned]?.items["wt-1"] == nil) + } + + @Test func mergeCustomizationFallsBackToUnpinnedWhenRowMissing() { + var state = SidebarState() + + state.mergeCustomization(title: "Spicy", color: .red, worktree: "wt-1", in: "repo") + + #expect(state.sections["repo"]?.buckets[.unpinned]?.items["wt-1"]?.title == "Spicy") + #expect(state.sections["repo"]?.buckets[.unpinned]?.items["wt-1"]?.color == .red) + } + + @Test func mergeCustomizationPreservesPreExistingNonNilFields() { + var state = SidebarState() + state.insert( + worktree: "wt-1", + in: "repo", + bucket: .pinned, + item: .init(title: "Manual", color: .blue) + ) + + state.mergeCustomization(title: "Stale", color: .red, worktree: "wt-1", in: "repo") + + // Manual customization wins against the re-seed payload. + #expect(state.sections["repo"]?.buckets[.pinned]?.items["wt-1"]?.title == "Manual") + #expect(state.sections["repo"]?.buckets[.pinned]?.items["wt-1"]?.color == .blue) + } + + // MARK: - setCustomization (save-intent overwrite). + + @Test func setCustomizationOverwritesPreExistingFields() { + var state = SidebarState() + state.insert( + worktree: "wt-1", + in: "repo", + bucket: .pinned, + item: .init(title: "Old", color: .blue) + ) + + state.setCustomization(title: "New", color: .red, worktree: "wt-1", in: "repo") + + let item = state.sections["repo"]?.buckets[.pinned]?.items["wt-1"] + #expect(item?.title == "New") + #expect(item?.color == .red) + } + + @Test func setCustomizationClearsFieldsWhenPassedNil() { + var state = SidebarState() + state.insert( + worktree: "wt-1", + in: "repo", + bucket: .pinned, + item: .init(title: "Spicy", color: .red) + ) + + state.setCustomization(title: nil, color: nil, worktree: "wt-1", in: "repo") + + let item = state.sections["repo"]?.buckets[.pinned]?.items["wt-1"] + #expect(item?.title == nil) + #expect(item?.color == nil) + } + + @Test func setCustomizationFallsBackToUnpinnedWhenRowMissing() { + var state = SidebarState() + + state.setCustomization(title: "Spicy", color: .red, worktree: "wt-1", in: "repo") + + let item = state.sections["repo"]?.buckets[.unpinned]?.items["wt-1"] + #expect(item?.title == "Spicy") + #expect(item?.color == .red) + } + + // MARK: - removeAnywhere(preferring:) ordering. + + @Test func removeAnywhereHonorsPreferringOrderWhenRowExistsInMultipleBuckets() { + var state = SidebarState() + state.insert( + worktree: "wt-1", + in: "repo", + bucket: .pinned, + item: .init(title: "Pinned-Payload", color: .red) + ) + state.insert( + worktree: "wt-1", + in: "repo", + bucket: .unpinned, + item: .init(title: "Unpinned-Payload", color: .blue) + ) + + let carried = state.removeAnywhere(worktree: "wt-1", in: "repo", preferring: [.pinned, .unpinned]) + + #expect(carried?.title == "Pinned-Payload") + #expect(carried?.color == .red) + #expect(state.sections["repo"]?.buckets[.pinned]?.items["wt-1"] == nil) + #expect(state.sections["repo"]?.buckets[.unpinned]?.items["wt-1"] == nil) + } + + @Test func removeAnywhereWithReversedPreferringPicksOppositeBucket() { + var state = SidebarState() + state.insert( + worktree: "wt-1", + in: "repo", + bucket: .pinned, + item: .init(title: "Pinned-Payload", color: .red) + ) + state.insert( + worktree: "wt-1", + in: "repo", + bucket: .unpinned, + item: .init(title: "Unpinned-Payload", color: .blue) + ) + + let carried = state.removeAnywhere(worktree: "wt-1", in: "repo", preferring: [.unpinned, .pinned]) + + #expect(carried?.title == "Unpinned-Payload") + #expect(carried?.color == .blue) + } } diff --git a/supacodeTests/ToolbarNotificationGroupingTests.swift b/supacodeTests/ToolbarNotificationGroupingTests.swift index 031f9a08c..650c51ef1 100644 --- a/supacodeTests/ToolbarNotificationGroupingTests.swift +++ b/supacodeTests/ToolbarNotificationGroupingTests.swift @@ -159,6 +159,32 @@ struct ToolbarNotificationGroupingTests { #expect(groups[0].unseenWorktreeCount == 0) } + @Test func usesResolvedSidebarTitleWhenCustomTitleIsSet() { + // A user-set custom title (from `WorktreeCustomizationFeature.save`) + // flows into `SidebarItemFeature.State.customTitle` via the reconcile + // pass; the notification popover must show that resolved title, not + // the raw branch name. + let repoPath = "/tmp/repo-customized" + let main = makeWorktree(id: repoPath, name: "main", repoRoot: repoPath) + let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature/x", repoRoot: repoPath) + + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [main, feature]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + state.sidebarItems[id: feature.id]?.customTitle = "Spicy" + + setRowNotifications( + &state, id: feature.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "T", body: "done", createdAt: .distantPast) + ]) + + let groups = state.computeToolbarNotificationGroups() + + #expect(groups.first?.worktrees.first?.name == "Spicy") + } + private func setRowNotifications( _ state: inout RepositoriesFeature.State, id: SidebarItemID, diff --git a/supacodeTests/WorktreeCreationPromptFeatureTests.swift b/supacodeTests/WorktreeCreationPromptFeatureTests.swift index 9c52236fe..0f90a7b40 100644 --- a/supacodeTests/WorktreeCreationPromptFeatureTests.swift +++ b/supacodeTests/WorktreeCreationPromptFeatureTests.swift @@ -2,9 +2,11 @@ import ComposableArchitecture import Foundation import Testing +@testable import SupacodeSettingsShared @testable import supacode @MainActor +@Suite(.serialized) struct WorktreeCreationPromptFeatureTests { private func makeState( automaticBaseRef: String = "origin/main", @@ -12,9 +14,11 @@ struct WorktreeCreationPromptFeatureTests { remoteNames: [String] = ["origin"], branchMenu: BaseRefBranchMenu? = nil, selectedBaseRef: String? = nil, - defaultWorktreeBaseDirectory: String = "/tmp/repo/.worktrees" + defaultWorktreeBaseDirectory: String = "/tmp/repo/.worktrees", + title: String = "", + color: RepositoryColor? = nil ) -> WorktreeCreationPromptFeature.State { - WorktreeCreationPromptFeature.State( + var state = WorktreeCreationPromptFeature.State( repositoryID: "/tmp/repo/", repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), repositoryName: "repo", @@ -28,6 +32,9 @@ struct WorktreeCreationPromptFeatureTests { defaultWorktreeBaseDirectory: defaultWorktreeBaseDirectory, validationMessage: nil ) + state.title = title + state.color = color + return state } @Test func baseRefSelectedUpdatesSelectionAndClearsValidation() async { @@ -82,7 +89,9 @@ struct WorktreeCreationPromptFeatureTests { branchName: "feature/new", baseRef: "origin/dev", fetchOrigin: true, - placement: WorktreePlacementOverride(name: nil, path: nil) + placement: WorktreePlacementOverride(name: nil, path: nil), + title: nil, + color: nil ) ) ) @@ -106,7 +115,9 @@ struct WorktreeCreationPromptFeatureTests { branchName: "feature/new", baseRef: "main", fetchOrigin: false, - placement: WorktreePlacementOverride(name: nil, path: nil) + placement: WorktreePlacementOverride(name: nil, path: nil), + title: nil, + color: nil ) ) ) @@ -137,7 +148,9 @@ struct WorktreeCreationPromptFeatureTests { placement: WorktreePlacementOverride( name: "feature_new", path: "~/Repos" - ) + ), + title: nil, + color: nil ) ) ) @@ -220,4 +233,85 @@ struct WorktreeCreationPromptFeatureTests { state.worktreeNameOverride = "feature_foo" #expect(state.resolvedWorktreeLocationPreview == "/tmp/repo/.worktrees/feature_foo/") } + + // MARK: - Title / Color customization + + @Test func submitTrimsBranchAndForwardsTitleAndColor() async { + var state = makeState(title: " Spicy ", color: .blue) + state.branchName = " feature/x " + let store = TestStore(initialState: state) { + WorktreeCreationPromptFeature() + } + + await store.send(.createButtonTapped) + await store.receive( + .delegate( + .submit( + repositoryID: "/tmp/repo/", + branchName: "feature/x", + baseRef: nil, + fetchOrigin: true, + placement: WorktreePlacementOverride(name: nil, path: nil), + title: "Spicy", + color: .blue + ) + ) + ) + } + + @Test func submitPreservesTitleWhenItMatchesBranch() async { + var state = makeState(title: "feature/x") + state.branchName = "feature/x" + let store = TestStore(initialState: state) { + WorktreeCreationPromptFeature() + } + + await store.send(.createButtonTapped) + await store.receive( + .delegate( + .submit( + repositoryID: "/tmp/repo/", + branchName: "feature/x", + baseRef: nil, + fetchOrigin: true, + placement: WorktreePlacementOverride(name: nil, path: nil), + title: "feature/x", + color: nil + ) + ) + ) + } + + @Test func submitWithNoCustomizationForwardsNilTitleAndColor() async { + var state = makeState() + state.branchName = "feature/x" + let store = TestStore(initialState: state) { + WorktreeCreationPromptFeature() + } + + await store.send(.createButtonTapped) + await store.receive( + .delegate( + .submit( + repositoryID: "/tmp/repo/", + branchName: "feature/x", + baseRef: nil, + fetchOrigin: true, + placement: WorktreePlacementOverride(name: nil, path: nil), + title: nil, + color: nil + ) + ) + ) + } + + @Test func emptyBranchNameBlocksSubmit() async { + let store = TestStore(initialState: makeState()) { + WorktreeCreationPromptFeature() + } + + await store.send(.createButtonTapped) { + $0.validationMessage = "Branch name required." + } + } } diff --git a/supacodeTests/WorktreeCreationPromptParentTests.swift b/supacodeTests/WorktreeCreationPromptParentTests.swift new file mode 100644 index 000000000..d69014ec5 --- /dev/null +++ b/supacodeTests/WorktreeCreationPromptParentTests.swift @@ -0,0 +1,427 @@ +import ComposableArchitecture +import Foundation +import IdentifiedCollections +import Testing + +@testable import SupacodeSettingsShared +@testable import supacode + +@MainActor +@Suite(.serialized) +struct WorktreeCreationPromptParentTests { + private let repoID = "/tmp/create-wt-repo" + + private func makeStateWithPrompt( + pendingFor branchNames: [String: PendingWorktree.Customization] = [:], + ) -> RepositoriesFeature.State { + let mainWorktree = Worktree( + id: "\(repoID)/main", + name: "main", + detail: "detail", + workingDirectory: URL(fileURLWithPath: repoID), + repositoryRootURL: URL(fileURLWithPath: repoID), + ) + let repository = Repository( + id: repoID, + rootURL: URL(fileURLWithPath: repoID), + name: "create-wt-repo", + worktrees: IdentifiedArray(uniqueElements: [mainWorktree]), + isGitRepository: true, + ) + var state = RepositoriesFeature.State() + state.repositories = IdentifiedArray(uniqueElements: [repository]) + state.repositoryRoots = [repository.rootURL] + state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( + repositoryID: repoID, + repositoryRootURL: URL(fileURLWithPath: repoID), + repositoryName: "create-wt-repo", + automaticBaseRef: "main", + defaultBranch: "main", + remoteNames: [], + branchMenu: nil, + branchName: "", + selectedBaseRef: nil, + fetchOrigin: false, + defaultWorktreeBaseDirectory: "/tmp/create-wt-repo/.worktrees", + validationMessage: nil, + ) + if !branchNames.isEmpty { + state.pendingCreationCustomizations[repoID] = branchNames + } + return state + } + + private func makeStore( + initialState: RepositoriesFeature.State + ) -> TestStoreOf { + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.uuid = .incrementing + } + store.exhaustivity = .off + return store + } + + @Test func cancelClearsPendingCreationCustomizationsForRepo() async { + let store = makeStore( + initialState: makeStateWithPrompt( + pendingFor: ["feature/x": .init(title: "Test", color: .blue)] + )) + + await store.send(.worktreeCreationPrompt(.presented(.delegate(.cancel)))) { + $0.worktreeCreationPrompt = nil + $0.pendingCreationCustomizations.removeValue(forKey: self.repoID) + } + } + + @Test func dismissPreservesPendingCreationCustomizations() async { + // .dismiss also fires when the parent nils the prompt on the success path. + let store = makeStore( + initialState: makeStateWithPrompt( + pendingFor: ["feature/x": .init(title: "Test", color: .blue)] + )) + + await store.send(.worktreeCreationPrompt(.dismiss)) { + $0.worktreeCreationPrompt = nil + } + } + + @Test func submitWithoutCustomizationClearsStaleEntryForSameBranch() async { + let store = makeStore( + initialState: makeStateWithPrompt( + pendingFor: ["feature/x": .init(title: "Stale", color: .blue)] + )) + + await store.send( + .worktreeCreationPrompt( + .presented( + .delegate( + .submit( + repositoryID: repoID, + branchName: "feature/x", + baseRef: nil, + fetchOrigin: false, + placement: WorktreePlacementOverride(name: nil, path: nil), + title: nil, + color: nil, + ) + ))) + ) { + $0.pendingCreationCustomizations.removeValue(forKey: self.repoID) + } + } + + @Test func duplicateValidationFailureDropsPendingCustomization() async { + let store = makeStore( + initialState: makeStateWithPrompt( + pendingFor: ["feature/x": .init(title: "Old", color: .blue)] + )) + + await store.send( + .promptedWorktreeCreationChecked( + repositoryID: repoID, + branchName: "feature/x", + baseRef: nil, + fetchOrigin: false, + placement: WorktreePlacementOverride(name: nil, path: nil), + duplicateMessage: "Branch name already exists.", + ) + ) { + $0.worktreeCreationPrompt?.isValidating = false + $0.worktreeCreationPrompt?.validationMessage = "Branch name already exists." + $0.pendingCreationCustomizations.removeValue(forKey: self.repoID) + } + } + + @Test func immediateDuplicateInStartPromptedClearsPendingCustomization() async { + // First-pass duplicate check (runs before the async branch-list fetch) is a normal + // prompt-flow rejection. The pending entry must be cleared. + var state = makeStateWithPrompt( + pendingFor: ["feature/x": .init(title: "Stale", color: .blue)] + ) + // Add an existing worktree with the same name so the synchronous check trips. + let existing = Worktree( + id: "\(repoID)/feature-x", + name: "feature/x", + detail: "detail", + workingDirectory: URL(fileURLWithPath: "\(repoID)/feature-x"), + repositoryRootURL: URL(fileURLWithPath: repoID), + ) + let repo = state.repositories[id: repoID]! + var worktrees = repo.worktrees + worktrees.append(existing) + state.repositories[id: repoID] = Repository( + id: repo.id, + rootURL: repo.rootURL, + name: repo.name, + worktrees: worktrees, + isGitRepository: true, + ) + let store = makeStore(initialState: state) + + await store.send( + .startPromptedWorktreeCreation( + repositoryID: repoID, + branchName: "feature/x", + baseRef: nil, + fetchOrigin: false, + placement: WorktreePlacementOverride(name: nil, path: nil), + ) + ) { + $0.worktreeCreationPrompt?.isValidating = false + $0.worktreeCreationPrompt?.validationMessage = "Branch name already exists." + $0.pendingCreationCustomizations.removeValue(forKey: self.repoID) + } + } + + @Test func createWorktreeForRemovingRepositoryClearsPendingCustomization() async { + var initial = makeStateWithPrompt( + pendingFor: ["feature/x": .init(title: "Stale", color: .blue)] + ) + initial.removingRepositoryIDs[repoID] = RepositoriesFeature.RepositoryRemovalRecord( + disposition: .gitWorktreeDelete, + batchID: UUID() + ) + let store = makeStore(initialState: initial) + + await store.send( + .createWorktreeInRepository( + repositoryID: repoID, + nameSource: .explicit("feature/x"), + baseRefSource: .explicit(nil), + fetchOrigin: false, + ) + ) { + $0.pendingCreationCustomizations.removeValue(forKey: self.repoID) + } + } + + @Test func createWorktreeTransfersCustomizationFromMapToPendingRow() async { + // Phase-1 → phase-2 transition: when the explicit-name creation begins, the map + // entry for the branch must move onto the new `PendingWorktree.customization`. + let store = makeStore( + initialState: makeStateWithPrompt( + pendingFor: ["feature/x": .init(title: "Fresh", color: .red)] + )) + + await store.send( + .createWorktreeInRepository( + repositoryID: repoID, + nameSource: .explicit("feature/x"), + baseRefSource: .explicit(nil), + fetchOrigin: false, + ) + ) { + $0.pendingCreationCustomizations.removeValue(forKey: self.repoID) + $0.pendingWorktrees = [ + PendingWorktree( + id: "pending:00000000-0000-0000-0000-000000000000", + repositoryID: self.repoID, + progress: WorktreeCreationProgress( + stage: .loadingLocalBranches, + worktreeName: "feature/x" + ), + customization: .init(title: "Fresh", color: .red) + ) + ] + } + } + + @Test func reloadThatPrunesPendingRowTransfersCustomizationToDiscoveredWorktree() async { + let pendingID = "pending:test" + var initial = makeStateWithPrompt() + initial.pendingWorktrees = [ + PendingWorktree( + id: pendingID, + repositoryID: repoID, + progress: WorktreeCreationProgress(stage: .creatingWorktree, worktreeName: "feature/x"), + customization: .init(title: "Fresh", color: .red) + ) + ] + let createdWorktreeID = "\(repoID)/feature-x" + let createdWorktree = Worktree( + id: createdWorktreeID, + name: "feature/x", + detail: "detail", + workingDirectory: URL(fileURLWithPath: createdWorktreeID), + repositoryRootURL: URL(fileURLWithPath: repoID), + ) + let existingRepository = initial.repositories[id: repoID]! + var reloadedWorktrees = existingRepository.worktrees + reloadedWorktrees.append(createdWorktree) + let reloadedRepository = Repository( + id: repoID, + rootURL: existingRepository.rootURL, + name: existingRepository.name, + worktrees: reloadedWorktrees, + isGitRepository: true, + ) + let store = makeStore(initialState: initial) + + await store.send( + .repositoriesLoaded( + [reloadedRepository], + failures: [], + roots: [existingRepository.rootURL], + animated: false + ) + ) { + $0.repositories = [reloadedRepository] + $0.repositoryRoots = [existingRepository.rootURL] + $0.pendingWorktrees.removeAll() + $0.isInitialLoadComplete = true + $0.$sidebar.withLock { sidebar in + sidebar.insert( + worktree: createdWorktreeID, + in: self.repoID, + bucket: .unpinned, + item: SidebarState.Item(title: "Fresh", color: .red), + position: nil, + ) + } + } + } + + @Test func reloadMatchesPendingRowsToDiscoveredWorktreesByName() async { + // Two concurrent pending creations, only one of the worktrees appears in the reload. + // The pending row whose `progress.worktreeName` matches the discovered worktree must + // be the one pruned; the other pending row must survive, and the matched one's + // customization must be seeded onto the discovered worktree's sidebar item. + let pendingAID = "pending:a" + let pendingBID = "pending:b" + var initial = makeStateWithPrompt() + initial.pendingWorktrees = [ + PendingWorktree( + id: pendingAID, + repositoryID: repoID, + progress: WorktreeCreationProgress(stage: .creatingWorktree, worktreeName: "feature/a"), + customization: .init(title: "Title A", color: .red), + ), + PendingWorktree( + id: pendingBID, + repositoryID: repoID, + progress: WorktreeCreationProgress(stage: .creatingWorktree, worktreeName: "feature/b"), + customization: .init(title: "Title B", color: .blue), + ), + ] + let bWorktreeID = "\(repoID)/feature-b" + let bWorktree = Worktree( + id: bWorktreeID, + name: "feature/b", + detail: "detail", + workingDirectory: URL(fileURLWithPath: bWorktreeID), + repositoryRootURL: URL(fileURLWithPath: repoID), + ) + let existingRepository = initial.repositories[id: repoID]! + var reloadedWorktrees = existingRepository.worktrees + reloadedWorktrees.append(bWorktree) + let reloadedRepository = Repository( + id: repoID, + rootURL: existingRepository.rootURL, + name: existingRepository.name, + worktrees: reloadedWorktrees, + isGitRepository: true, + ) + let store = makeStore(initialState: initial) + + await store.send( + .repositoriesLoaded( + [reloadedRepository], + failures: [], + roots: [existingRepository.rootURL], + animated: false, + ) + ) { + $0.repositories = [reloadedRepository] + $0.repositoryRoots = [existingRepository.rootURL] + // Pending A (no matching discovered worktree) survives; pending B is pruned. + $0.pendingWorktrees = [ + PendingWorktree( + id: pendingAID, + repositoryID: self.repoID, + progress: WorktreeCreationProgress(stage: .creatingWorktree, worktreeName: "feature/a"), + customization: .init(title: "Title A", color: .red), + ) + ] + $0.isInitialLoadComplete = true + $0.$sidebar.withLock { sidebar in + sidebar.insert( + worktree: bWorktreeID, + in: self.repoID, + bucket: .unpinned, + item: SidebarState.Item(title: "Title B", color: .blue), + position: nil, + ) + } + } + } + + @Test func successAppliesCustomizationFromPendingRowToSidebar() async { + let pendingID = "pending:test" + var initial = makeStateWithPrompt() + initial.pendingWorktrees = [ + PendingWorktree( + id: pendingID, + repositoryID: repoID, + progress: WorktreeCreationProgress(stage: .creatingWorktree, worktreeName: "feature/x"), + customization: .init(title: "Fresh", color: .red) + ) + ] + let createdWorktreeID = "\(repoID)/feature-x" + let createdWorktree = Worktree( + id: createdWorktreeID, + name: "feature/x", + detail: "detail", + workingDirectory: URL(fileURLWithPath: createdWorktreeID), + repositoryRootURL: URL(fileURLWithPath: repoID), + ) + let store = makeStore(initialState: initial) + + await store.send( + .createRandomWorktreeSucceeded( + createdWorktree, + repositoryID: repoID, + pendingID: pendingID, + ) + ) { + $0.pendingWorktrees.removeAll() + $0.$sidebar.withLock { sidebar in + sidebar.insert( + worktree: createdWorktreeID, + in: self.repoID, + bucket: .unpinned, + item: SidebarState.Item(title: "Fresh", color: .red), + position: nil, + ) + } + } + } + + @Test func submitWithCustomizationOverwritesStaleEntryForSameBranch() async { + let store = makeStore( + initialState: makeStateWithPrompt( + pendingFor: ["feature/x": .init(title: "Stale", color: .blue)] + )) + + await store.send( + .worktreeCreationPrompt( + .presented( + .delegate( + .submit( + repositoryID: repoID, + branchName: "feature/x", + baseRef: nil, + fetchOrigin: false, + placement: WorktreePlacementOverride(name: nil, path: nil), + title: "Fresh", + color: .red, + ) + ))) + ) { + $0.pendingCreationCustomizations[self.repoID] = [ + "feature/x": .init(title: "Fresh", color: .red) + ] + } + } +} diff --git a/supacodeTests/WorktreeCustomizationFeatureTests.swift b/supacodeTests/WorktreeCustomizationFeatureTests.swift new file mode 100644 index 000000000..62151d7fe --- /dev/null +++ b/supacodeTests/WorktreeCustomizationFeatureTests.swift @@ -0,0 +1,67 @@ +import ComposableArchitecture +import Foundation +import Testing + +@testable import SupacodeSettingsShared +@testable import supacode + +@MainActor +@Suite(.serialized) +struct WorktreeCustomizationFeatureTests { + private func makeState( + title: String = "", + color: RepositoryColor? = nil, + ) -> WorktreeCustomizationFeature.State { + WorktreeCustomizationFeature.State( + worktreeID: "wt-1", + repositoryID: "/tmp/repo", + defaultName: "feature/x", + title: title, + color: color, + ) + } + + @Test func saveTrimsTitleAndForwardsValues() async { + let store = TestStore(initialState: makeState(title: " Spicy ", color: .blue)) { + WorktreeCustomizationFeature() + } + + await store.send(.saveButtonTapped) + await store.receive( + .delegate( + .save(worktreeID: "wt-1", repositoryID: "/tmp/repo", title: "Spicy", color: .blue), + )) + } + + @Test func saveDropsTitleOnlyWhenEmptyAfterTrim() async { + let store = TestStore(initialState: makeState(title: " ")) { + WorktreeCustomizationFeature() + } + + await store.send(.saveButtonTapped) + await store.receive( + .delegate(.save(worktreeID: "wt-1", repositoryID: "/tmp/repo", title: nil, color: nil)), + ) + } + + @Test func savePreservesTitleEvenWhenItMatchesDefault() async { + // Typing the default name locks it in as an explicit override (doesn't collapse to nil). + let store = TestStore(initialState: makeState(title: "feature/x")) { + WorktreeCustomizationFeature() + } + + await store.send(.saveButtonTapped) + await store.receive( + .delegate(.save(worktreeID: "wt-1", repositoryID: "/tmp/repo", title: "feature/x", color: nil)), + ) + } + + @Test func cancelDelegatesCancel() async { + let store = TestStore(initialState: makeState()) { + WorktreeCustomizationFeature() + } + + await store.send(.cancelButtonTapped) + await store.receive(.delegate(.cancel)) + } +} diff --git a/supacodeTests/WorktreeCustomizationParentTests.swift b/supacodeTests/WorktreeCustomizationParentTests.swift new file mode 100644 index 000000000..f9f7c1870 --- /dev/null +++ b/supacodeTests/WorktreeCustomizationParentTests.swift @@ -0,0 +1,352 @@ +import ComposableArchitecture +import Foundation +import IdentifiedCollections +import OrderedCollections +import SupacodeSettingsShared +import Testing + +@testable import supacode + +@MainActor +@Suite(.serialized) +struct WorktreeCustomizationParentTests { + private let repoID = "/tmp/customize-wt-repo" + private let worktreeID = "/tmp/customize-wt-repo/feature-x" + + private func makeInitialState( + isGitRepository: Bool = true, + seedSidebarBucket: Bool = true, + ) -> RepositoriesFeature.State { + let mainWorktree = Worktree( + id: "\(repoID)/main", + name: "main", + detail: "detail", + workingDirectory: URL(fileURLWithPath: repoID), + repositoryRootURL: URL(fileURLWithPath: repoID), + ) + let featureWorktree = Worktree( + id: worktreeID, + name: "feature/x", + detail: "detail", + workingDirectory: URL(fileURLWithPath: worktreeID), + repositoryRootURL: URL(fileURLWithPath: repoID), + ) + let repository = Repository( + id: repoID, + rootURL: URL(fileURLWithPath: repoID), + name: "customize-wt-repo", + worktrees: IdentifiedArray(uniqueElements: [mainWorktree, featureWorktree]), + isGitRepository: isGitRepository, + ) + var state = RepositoriesFeature.State() + state.repositories = IdentifiedArray(uniqueElements: [repository]) + state.repositoryRoots = [repository.rootURL] + if seedSidebarBucket { + state.$sidebar.withLock { sidebar in + sidebar.insert(worktree: self.worktreeID, in: self.repoID, bucket: .unpinned) + } + } + // Pre-build `sidebarItems` so save / cancel tests can assert against an + // existing per-row state instead of forcing the reducer's `syncSidebar` + // to materialise rows mid-action and flag the test on unrelated diffs. + RepositoriesFeature.syncSidebar(&state) + // Pre-warm the post-reduce caches so the in-reducer recompute is a delta + // from a populated baseline (matches what every action would see in a + // real run) rather than a build-from-nil that registers as state churn. + state.applyPostReduceCacheRecomputes() + return state + } + + @Test func requestCustomizeWorktreeSeedsPromptFromStoredItem() async { + var initial = makeInitialState() + initial.$sidebar.withLock { sidebar in + sidebar.sections[self.repoID]?.buckets[.unpinned]?.items[self.worktreeID]?.title = "Spicy" + sidebar.sections[self.repoID]?.buckets[.unpinned]?.items[self.worktreeID]?.color = .blue + } + let store = TestStore(initialState: initial) { + RepositoriesFeature() + } + + await store.send(.requestCustomizeWorktree(worktreeID, repoID)) { + $0.worktreeCustomization = WorktreeCustomizationFeature.State( + worktreeID: self.worktreeID, + repositoryID: self.repoID, + defaultName: "feature/x", + title: "Spicy", + color: .blue, + ) + } + } + + @Test func requestCustomizeWorktreeSeedsEmptyPromptWhenNoStoredItem() async { + let store = TestStore(initialState: makeInitialState(seedSidebarBucket: false)) { + RepositoriesFeature() + } + + await store.send(.requestCustomizeWorktree(worktreeID, repoID)) { + $0.worktreeCustomization = WorktreeCustomizationFeature.State( + worktreeID: self.worktreeID, + repositoryID: self.repoID, + defaultName: "feature/x", + title: "", + color: nil, + ) + } + } + + @Test func requestCustomizeWorktreeOpensSheetForFolderSyntheticRow() async { + // Folder synthetic rows ARE the row the user customizes; they share the worktree path so the + // per-row title / color the picker writes lands on the visible folder row. + let store = TestStore( + initialState: makeInitialState(isGitRepository: false, seedSidebarBucket: false) + ) { + RepositoriesFeature() + } + + await store.send(.requestCustomizeWorktree(worktreeID, repoID)) { + $0.worktreeCustomization = WorktreeCustomizationFeature.State( + worktreeID: self.worktreeID, + repositoryID: self.repoID, + defaultName: "customize-wt-repo", + title: "", + color: nil, + ) + } + } + + @Test func requestCustomizeWorktreeNoOpsForMainWorktrees() async { + // The context menu hides the entry for the main worktree row, but a future palette / + // deeplink could still route here. The reducer guard is the backstop. + let store = TestStore(initialState: makeInitialState(seedSidebarBucket: false)) { + RepositoriesFeature() + } + + await store.send(.requestCustomizeWorktree("\(repoID)/main", repoID)) + #expect(store.state.worktreeCustomization == nil) + } + + @Test func saveDelegatePersistsTitleAndColorToBucketedItem() async { + var initial = makeInitialState() + initial.worktreeCustomization = WorktreeCustomizationFeature.State( + worktreeID: worktreeID, + repositoryID: repoID, + defaultName: "feature/x", + title: "", + color: nil, + ) + let store = TestStore(initialState: initial) { + RepositoriesFeature() + } + + await store.send( + .worktreeCustomization( + .presented( + .delegate( + .save( + worktreeID: worktreeID, + repositoryID: repoID, + title: "Renamed", + color: .red, + ) + ))) + ) { + $0.worktreeCustomization = nil + $0.$sidebar.withLock { sidebar in + sidebar.sections[self.repoID]?.buckets[.unpinned]?.items[self.worktreeID]?.title = + "Renamed" + sidebar.sections[self.repoID]?.buckets[.unpinned]?.items[self.worktreeID]?.color = .red + } + // syncSidebar fans the bucketed Item write into the per-row mirror. + $0.sidebarItems[id: self.worktreeID]?.customTitle = "Renamed" + $0.sidebarItems[id: self.worktreeID]?.customTint = .red + // The save action invalidates every cache; mirror the post-reduce hook + // so the test diff only contains intentional state changes. + $0.applyPostReduceCacheRecomputes() + } + } + + @Test func saveDelegateRefreshesSelectedWorktreeSlice() async { + var initial = makeInitialState() + initial.setSingleWorktreeSelection(worktreeID) + initial.applyPostReduceCacheRecomputes(.selectedWorktreeSlice) + initial.worktreeCustomization = WorktreeCustomizationFeature.State( + worktreeID: worktreeID, + repositoryID: repoID, + defaultName: "feature/x", + title: "", + color: nil, + ) + let store = TestStore(initialState: initial) { + RepositoriesFeature() + } + + await store.send( + .worktreeCustomization( + .presented( + .delegate( + .save( + worktreeID: worktreeID, + repositoryID: repoID, + title: "Renamed", + color: .red, + ) + ))) + ) { + $0.worktreeCustomization = nil + $0.$sidebar.withLock { sidebar in + sidebar.sections[self.repoID]?.buckets[.unpinned]?.items[self.worktreeID]?.title = + "Renamed" + sidebar.sections[self.repoID]?.buckets[.unpinned]?.items[self.worktreeID]?.color = .red + } + $0.sidebarItems[id: self.worktreeID]?.customTitle = "Renamed" + $0.sidebarItems[id: self.worktreeID]?.customTint = .red + $0.applyPostReduceCacheRecomputes() + } + #expect(store.state.selectedWorktreeSlice?.resolvedSidebarTitle == "Renamed") + #expect(store.state.selectedWorktreeSlice?.customTint == .red) + } + + @Test func pinWorktreePreservesCustomTitleAndColor() async { + var initial = makeInitialState() + initial.$sidebar.withLock { sidebar in + sidebar.sections[self.repoID]?.buckets[.unpinned]?.items[self.worktreeID]?.title = + "Renamed" + sidebar.sections[self.repoID]?.buckets[.unpinned]?.items[self.worktreeID]?.color = .red + } + RepositoriesFeature.syncSidebar(&initial) + initial.applyPostReduceCacheRecomputes() + let store = TestStore(initialState: initial) { + RepositoriesFeature() + } withDependencies: { + $0.analyticsClient.capture = { _, _ in } + } + store.exhaustivity = .off + + await store.send(.pinWorktree(worktreeID)) + + #expect(store.state.sidebar.sections[repoID]?.buckets[.unpinned]?.items[worktreeID] == nil) + #expect(store.state.sidebar.sections[repoID]?.buckets[.pinned]?.items[worktreeID]?.title == "Renamed") + #expect(store.state.sidebar.sections[repoID]?.buckets[.pinned]?.items[worktreeID]?.color == .red) + #expect(store.state.sidebarItems[id: worktreeID]?.customTitle == "Renamed") + #expect(store.state.sidebarItems[id: worktreeID]?.customTint == .red) + } + + @Test func unpinWorktreePreservesCustomTitleAndColor() async { + var initial = makeInitialState(seedSidebarBucket: false) + initial.$sidebar.withLock { sidebar in + sidebar.insert( + worktree: self.worktreeID, + in: self.repoID, + bucket: .pinned, + item: .init(title: "Renamed", color: .red) + ) + } + RepositoriesFeature.syncSidebar(&initial) + initial.applyPostReduceCacheRecomputes() + let store = TestStore(initialState: initial) { + RepositoriesFeature() + } withDependencies: { + $0.analyticsClient.capture = { _, _ in } + } + store.exhaustivity = .off + + await store.send(.unpinWorktree(worktreeID)) + + #expect(store.state.sidebar.sections[repoID]?.buckets[.pinned]?.items[worktreeID] == nil) + #expect(store.state.sidebar.sections[repoID]?.buckets[.unpinned]?.items[worktreeID]?.title == "Renamed") + #expect(store.state.sidebar.sections[repoID]?.buckets[.unpinned]?.items[worktreeID]?.color == .red) + #expect(store.state.sidebarItems[id: worktreeID]?.customTitle == "Renamed") + #expect(store.state.sidebarItems[id: worktreeID]?.customTint == .red) + } + + @Test func unpinWorktreePrefersPinnedPayloadOverStaleUnpinnedSibling() async { + // Defensive coverage for a corrupted double-bucket pre-state (hand-edit, + // migrator race) where the same row exists in both `.pinned` and + // `.unpinned` with different payloads. The pinned entry is the live + // row the user sees; unpin must carry its `title` / `color` forward, + // not the stale unpinned sibling's. + var initial = makeInitialState(seedSidebarBucket: false) + initial.$sidebar.withLock { sidebar in + sidebar.insert( + worktree: self.worktreeID, + in: self.repoID, + bucket: .pinned, + item: .init(title: "Live", color: .red) + ) + sidebar.insert( + worktree: self.worktreeID, + in: self.repoID, + bucket: .unpinned, + item: .init(title: "Stale", color: .blue) + ) + } + RepositoriesFeature.syncSidebar(&initial) + initial.applyPostReduceCacheRecomputes() + let store = TestStore(initialState: initial) { + RepositoriesFeature() + } withDependencies: { + $0.analyticsClient.capture = { _, _ in } + } + store.exhaustivity = .off + + await store.send(.unpinWorktree(worktreeID)) + + #expect(store.state.sidebar.sections[repoID]?.buckets[.pinned]?.items[worktreeID] == nil) + #expect(store.state.sidebar.sections[repoID]?.buckets[.unpinned]?.items[worktreeID]?.title == "Live") + #expect(store.state.sidebar.sections[repoID]?.buckets[.unpinned]?.items[worktreeID]?.color == .red) + } + + @Test func pinWorktreePrefersUnpinnedPayloadOverStalePinnedSibling() async { + // Symmetric to the unpin case: when the same row appears in both buckets, `.unpinned` is the + // logical source for a pin, so its payload (not a stale `.pinned` sibling's) must round-trip. + var initial = makeInitialState(seedSidebarBucket: false) + initial.$sidebar.withLock { sidebar in + sidebar.insert( + worktree: self.worktreeID, + in: self.repoID, + bucket: .unpinned, + item: .init(title: "Live", color: .red) + ) + sidebar.insert( + worktree: self.worktreeID, + in: self.repoID, + bucket: .pinned, + item: .init(title: "Stale", color: .blue) + ) + } + RepositoriesFeature.syncSidebar(&initial) + initial.applyPostReduceCacheRecomputes() + let store = TestStore(initialState: initial) { + RepositoriesFeature() + } withDependencies: { + $0.analyticsClient.capture = { _, _ in } + } + store.exhaustivity = .off + + await store.send(.pinWorktree(worktreeID)) + + #expect(store.state.sidebar.sections[repoID]?.buckets[.unpinned]?.items[worktreeID] == nil) + #expect(store.state.sidebar.sections[repoID]?.buckets[.pinned]?.items[worktreeID]?.title == "Live") + #expect(store.state.sidebar.sections[repoID]?.buckets[.pinned]?.items[worktreeID]?.color == .red) + } + + @Test func cancelDelegateClearsPresentedState() async { + var initial = makeInitialState() + initial.worktreeCustomization = WorktreeCustomizationFeature.State( + worktreeID: worktreeID, + repositoryID: repoID, + defaultName: "feature/x", + title: "", + color: nil, + ) + let store = TestStore(initialState: initial) { + RepositoriesFeature() + } + + await store.send( + .worktreeCustomization(.presented(.delegate(.cancel))) + ) { + $0.worktreeCustomization = nil + } + } +} From 563e6913514c9a3a50609951c32a7578fe7e846b Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Sat, 30 May 2026 02:42:39 +0200 Subject: [PATCH 02/56] Collapse the new-worktree appearance section and mirror the title placeholder (#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" --- .../WorktreeCreationPromptFeature.swift | 2 ++ .../Views/WorktreeCreationPromptView.swift | 27 ++++++++++--------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift index db9b81834..02d0b30bc 100644 --- a/supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift @@ -31,6 +31,8 @@ struct WorktreeCreationPromptFeature { var worktreePathOverride: String = "" /// Disclosure state for the advanced placement section. Collapsed by default. var showAdvancedOptions: Bool = false + /// Disclosure state for the title / color appearance section. Collapsed by default. + var showAppearanceOptions: Bool = false var validationMessage: String? var isValidating = false /// Optional sidebar customization captured by the new Title / Color diff --git a/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift b/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift index 5d28ae25c..99e634dd7 100644 --- a/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift @@ -37,19 +37,7 @@ struct WorktreeCreationPromptView: View { .disabled(store.isSelectedBaseRefLocal) } - Section { - TextField( - "Title", - text: $store.title, - prompt: Text("Leave blank to use branch name") - ) - LabeledContent("Color") { - ColorSwatchRow(color: $store.color) - } - } header: { - Text("Title & Color") - Text("Optional. Overrides the row title and tint. Leave blank to use the branch name.") - } + WorktreeAppearanceSection(store: store) WorktreeOptionsSection(store: store) } @@ -83,6 +71,19 @@ struct WorktreeCreationPromptView: View { } } +private struct WorktreeAppearanceSection: View { + @Bindable var store: StoreOf + + var body: some View { + Section("Appearance", isExpanded: $store.showAppearanceOptions) { + TextField("Title", text: $store.title, prompt: Text(store.worktreeNamePlaceholder)) + LabeledContent("Color") { + ColorSwatchRow(color: $store.color) + } + } + } +} + private struct WorktreeOptionsSection: View { @Bindable var store: StoreOf From a536175fd9aa5a84d4dff7b3485728caa3c3cc1a Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Sat, 30 May 2026 17:50:56 +0200 Subject: [PATCH 03/56] Persist terminal layouts incrementally and reap zmx sessions by attach state (#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- 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. --- .../SettingsFilePersistence.swift | 8 +- .../Support/SupaLogger.swift | 8 + supacode/App/supacodeApp.swift | 14 +- supacode/Clients/Zmx/ZmxClient.swift | 62 ++- .../LayoutsIncrementalWriter.swift | 145 +++++++ .../BusinessLogic/LayoutsPersistenceKey.swift | 16 +- .../WorktreeTerminalManager.swift | 156 +++++++- .../Models/WorktreeTerminalState.swift | 15 + .../Views/WorktreeTerminalTabsView.swift | 2 +- supacodeTests/AgentPresence+TestHelpers.swift | 51 ++- .../LayoutsIncrementalWriterTests.swift | 138 +++++++ ...erminalManagerLayoutPersistenceTests.swift | 358 ++++++++++++++++++ .../WorktreeTerminalManagerReaperTests.swift | 120 ++++++ .../WorktreeTerminalManagerTests.swift | 3 + supacodeTests/ZmxClientTests.swift | 52 +++ 15 files changed, 1091 insertions(+), 57 deletions(-) create mode 100644 supacode/Features/Terminal/BusinessLogic/LayoutsIncrementalWriter.swift create mode 100644 supacodeTests/LayoutsIncrementalWriterTests.swift create mode 100644 supacodeTests/WorktreeTerminalManagerLayoutPersistenceTests.swift create mode 100644 supacodeTests/WorktreeTerminalManagerReaperTests.swift diff --git a/SupacodeSettingsShared/BusinessLogic/SettingsFilePersistence.swift b/SupacodeSettingsShared/BusinessLogic/SettingsFilePersistence.swift index 81c111c7a..6c5987533 100644 --- a/SupacodeSettingsShared/BusinessLogic/SettingsFilePersistence.swift +++ b/SupacodeSettingsShared/BusinessLogic/SettingsFilePersistence.swift @@ -58,10 +58,6 @@ extension SettingsFileStorage { } } -nonisolated enum SettingsFileStorageError: Error { - case missing -} - nonisolated final class InMemorySettingsFileStorage: @unchecked Sendable { private let lock = NSLock() private var dataByURL: [URL: Data] = [:] @@ -70,7 +66,9 @@ nonisolated final class InMemorySettingsFileStorage: @unchecked Sendable { lock.lock() defer { lock.unlock() } guard let data = dataByURL[url] else { - throw SettingsFileStorageError.missing + // Mirror real-disk semantics so callers that distinguish "file absent" + // (fresh start) from a read failure see the same `fileReadNoSuchFile`. + throw CocoaError(.fileReadNoSuchFile) } return data } diff --git a/SupacodeSettingsShared/Support/SupaLogger.swift b/SupacodeSettingsShared/Support/SupaLogger.swift index 8a97d3827..3d00385c9 100644 --- a/SupacodeSettingsShared/Support/SupaLogger.swift +++ b/SupacodeSettingsShared/Support/SupaLogger.swift @@ -36,4 +36,12 @@ public nonisolated struct SupaLogger: Sendable { logger.warning("\(message, privacy: .public)") #endif } + + public func error(_ message: String) { + #if DEBUG + print("[\(category)] \(message)") + #else + logger.error("\(message, privacy: .public)") + #endif + } } diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index af7eb2e20..21a9e4abd 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -51,8 +51,13 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { private var bufferedDeeplinkURLs: [URL] = [] func applicationWillTerminate(_ notification: Notification) { - // Embed agent records so badges survive relaunch (agents only emit - // session_start once per process lifetime). + // Drop the queued debounce timers; an already-started async flush has no + // cancellation checkpoint and still completes, but the writer's lock plus the + // atomic temp+rename keep this terminal write from tearing. The on-quit save + // embeds agent records so badges survive relaunch (agents only emit + // session_start once per process lifetime), and a second concurrent instance + // overwriting the file is an accepted dev-only last-writer-wins window. + terminalManager?.cancelPendingLayoutSaves() let agentsBySurface = appStore?.state.agentPresence.agentsBySurface() ?? [:] terminalManager?.saveAllLayoutSnapshots(agentsBySurface: agentsBySurface) } @@ -168,6 +173,11 @@ struct SupacodeApp: App { _store = State(initialValue: appStore) appDelegate.appStore = appStore appDelegate.terminalManager = terminalManager + // Source live agent badge records for incremental layout captures; the [:] + // default would clobber badges that share a surface key on every save. + terminalManager.currentAgentsBySurface = { [weak appStore] in + appStore?.state.agentPresence.agentsBySurface() ?? [:] + } Self.configureSocketHandlers(terminalManager: terminalManager, store: appStore) } diff --git a/supacode/Clients/Zmx/ZmxClient.swift b/supacode/Clients/Zmx/ZmxClient.swift index 2b4a3d6fd..2c1384637 100644 --- a/supacode/Clients/Zmx/ZmxClient.swift +++ b/supacode/Clients/Zmx/ZmxClient.swift @@ -29,10 +29,11 @@ struct ZmxClient: Sendable { /// Tear down a session. No-op on missing. Bounded by a 5-second timeout so a /// stuck daemon can't hold the close path indefinitely. var killSession: @Sendable (_ sessionID: String) async -> Void - /// Returns all live Supacode session names (`supa-`) the daemon currently - /// hosts. Empty when zmx is unbundled or the daemon is unreachable. Used at - /// launch to reap sessions whose owning surface no longer exists. - var listSessions: @Sendable () async -> [String] + /// Returns each live Supacode session with its attached-client count, or nil + /// when the probe failed/timed out. nil means UNKNOWN (never reap); `[]` means + /// a successful empty listing. A `clients` of nil marks a session whose count + /// is unknown (err/status line), which the reaper must also spare. + var listSessionsWithClients: @Sendable () async -> [ZmxSessionListParser.Entry]? } /// Cached probe result so we log the bypass reason exactly once per process @@ -202,13 +203,11 @@ extension ZmxClient { killSession: { sessionID in _ = await runZmx(["kill", sessionID]) }, - listSessions: { - guard let stdout = await runZmx(["ls", "--short"], captureStdout: true) else { return [] } - return - stdout - .split(whereSeparator: \.isNewline) - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { $0.hasPrefix(ZmxSessionID.prefix) && !$0.isEmpty } + listSessionsWithClients: { + // nil from runZmx is the UNKNOWN signal (spawn error / timeout / non-zero + // exit); preserve it so the reaper never kills against a failed probe. + guard let stdout = await runZmx(["ls"], captureStdout: true) else { return nil } + return ZmxSessionListParser.parse(stdout) } ) }() @@ -218,7 +217,7 @@ extension ZmxClient { isBundled: { false }, wrapCommand: { _, _ in nil }, killSession: { _ in }, - listSessions: { [] } + listSessionsWithClients: { [] } ) } @@ -234,6 +233,45 @@ extension DependencyValues { } } +/// Pure parser for zmx's full (`ls`, non-`--short`) tab-delimited listing. +/// Each line is `[→ | ]name=\tk=v\t...`; a healthy session carries +/// `clients=`, an unreachable one carries `err=`/`status=` (no count). +nonisolated enum ZmxSessionListParser { + struct Entry: Equatable, Sendable { + var name: String + /// nil when the count is unknown (err/status line); the reaper spares these. + var clients: Int? + } + + static func parse(_ stdout: String) -> [Entry] { + stdout + .split(whereSeparator: \.isNewline) + .compactMap { line -> Entry? in + // Strip the current-session arrow / leading indent before tokenizing. + var trimmed = Substring(line) + if trimmed.hasPrefix("→ ") { + trimmed = trimmed.dropFirst(2) + } + // Non-current sessions are indented with a literal leading space run. + while trimmed.first?.isWhitespace == true { + trimmed = trimmed.dropFirst() + } + let fields = trimmed.split(separator: "\t") + var values: [Substring: Substring] = [:] + for field in fields { + guard let separator = field.firstIndex(of: "=") else { continue } + let key = field[field.startIndex..` lands at 41, leaving /// headroom for a longer custom `ZMX_DIR`. diff --git a/supacode/Features/Terminal/BusinessLogic/LayoutsIncrementalWriter.swift b/supacode/Features/Terminal/BusinessLogic/LayoutsIncrementalWriter.swift new file mode 100644 index 000000000..bb739fd7e --- /dev/null +++ b/supacode/Features/Terminal/BusinessLogic/LayoutsIncrementalWriter.swift @@ -0,0 +1,145 @@ +import Dependencies +import Foundation +import SupacodeSettingsShared + +/// Serialized off-main writer for incremental layout persistence. Every flush +/// re-reads `layouts.json` from disk, splices in only the per-worktree keys it +/// carries, then writes the whole dict back through the atomic temp+rename +/// `settingsFileStorage.save`. Being an actor makes the read-modify-write a +/// FIFO critical section: a positive snapshot and a delete tombstone for the +/// same key can't interleave, and concurrent keys from separate flushes both +/// survive (last-writer-wins per key, not whole-dict). +/// +/// There is no flock / NSFileCoordinator: a second Supacode instance writing +/// the same file concurrently is a dev-only scenario and accepted as +/// last-writer-wins. The in-memory `@Shared(.layouts)` dict stays the source of +/// truth on main; this actor only owns the encode + disk merge. +actor LayoutsIncrementalWriter { + /// One per-worktree change to splice into the on-disk dict. `.delete` is an + /// explicit tombstone: absence from a flush means "leave the disk key alone", + /// so a pruned worktree must be carried as `.delete`, never as omission. + enum Change: Sendable { + case snapshot(TerminalLayoutSnapshot) + case delete + } + + private static let logger = SupaLogger("Layouts") + private let storage: SettingsFileStorage + private let url: URL + /// Guards the read-modify-write so the off-actor `flushSync` (on-quit) and the + /// actor-routed flush/delete paths mutually exclude. The actor still owns FIFO + /// ordering of the live path; this only prevents a lost update against the + /// single off-actor entrant. + private let writeLock = NSLock() + + init( + storage: SettingsFileStorage, + url: URL = SupacodePaths.layoutsURL + ) { + self.storage = storage + self.url = url + } + + /// Re-reads the on-disk dict, applies `changes`, and writes the result. + /// Keys not present in `changes` are preserved from disk untouched. + func flush(_ changes: [String: Change]) { + applyAndWrite(changes) + } + + /// Synchronous variant for the on-quit terminal write, where the run loop is + /// tearing down and there's no chance to await the actor. The atomic temp+rename + /// `storage.save` makes the off-actor write safe as the process's final flush. + nonisolated func flushSync(_ changes: [String: Change]) { + applyAndWrite(changes) + } + + private nonisolated func applyAndWrite(_ changes: [String: Change]) { + guard !changes.isEmpty else { return } + writeLock.lock() + defer { writeLock.unlock() } + guard var dict = readFromDisk() else { + // Abort rather than splice our keys into an empty dict and clobber every other worktree's layout. + Self.logger.error( + "Aborting incremental layout flush: on-disk layouts failed to decode; preserving file for recovery.") + return + } + let original = dict + for (key, change) in changes { + switch change { + case .snapshot(let snapshot): + dict[key] = snapshot + case .delete: + dict.removeValue(forKey: key) + } + } + // Skip the write when the splice is a no-op. onTabProjectionChanged fires on + // notification / focus / zoom deltas that aren't part of the snapshot, so an + // agent tool-call storm would otherwise churn the file with identical bytes. + guard dict != original else { return } + write(dict) + } + + /// Returns the on-disk dict, `[:]` when the file is absent (fresh start) or + /// when corrupt bytes were rotated aside, or `nil` on a present-but-unreadable + /// file (transient/permission error) so the caller aborts rather than clobbers it. + private nonisolated func readFromDisk() -> [String: TerminalLayoutSnapshot]? { + let data: Data + do { + data = try storage.load(url) + } catch { + // Only an absent file is a fresh start; a present-but-unreadable file must abort so we don't clobber siblings. + guard Self.isFileAbsent(error) else { + Self.logger.error("Failed to read layouts during incremental merge: \(error)") + return nil + } + return [:] + } + do { + return try JSONDecoder().decode([String: TerminalLayoutSnapshot].self, from: data) + } catch { + // Corrupt bytes: rotate aside and start fresh rather than refuse to save + // forever. Mirrors SidebarPersistenceKey; the bytes are kept for recovery. + Self.logger.error("Failed to decode layouts during incremental merge: \(error)") + Self.renameCorruptFile(at: url) + return [:] + } + } + + /// True only when the read failed because the file does not exist. + private static func isFileAbsent(_ error: Error) -> Bool { + if let cocoa = error as? CocoaError, cocoa.code == .fileReadNoSuchFile { return true } + if let posix = error as? POSIXError, posix.code == .ENOENT { return true } + return false + } + + /// Moves a corrupt `layouts.json` aside to `layouts.json.corrupt-` so + /// the next save starts fresh instead of aborting forever. The storage dep only + /// exposes load/save, so the rename goes through FileManager; a missing or + /// already-renamed file returns quietly and the caller proceeds to the fresh dict. + private nonisolated static func renameCorruptFile(at url: URL) { + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: url.path(percentEncoded: false)) else { return } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let timestamp = formatter.string(from: Date()).replacing(":", with: "-") + let destination = url.deletingLastPathComponent() + .appending(path: "\(url.lastPathComponent).corrupt-\(timestamp)", directoryHint: .notDirectory) + do { + try fileManager.moveItem(at: url, to: destination) + } catch { + Self.logger.warning( + "Failed to rename corrupt layouts file to \(destination.lastPathComponent): \(error).") + } + } + + private nonisolated func write(_ dict: [String: TerminalLayoutSnapshot]) { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(dict) + try storage.save(data, url) + } catch { + Self.logger.warning("Failed to write incremental layouts: \(error)") + } + } +} diff --git a/supacode/Features/Terminal/BusinessLogic/LayoutsPersistenceKey.swift b/supacode/Features/Terminal/BusinessLogic/LayoutsPersistenceKey.swift index 764c8285b..15f38dce5 100644 --- a/supacode/Features/Terminal/BusinessLogic/LayoutsPersistenceKey.swift +++ b/supacode/Features/Terminal/BusinessLogic/LayoutsPersistenceKey.swift @@ -42,20 +42,14 @@ nonisolated struct LayoutsKey: SharedKey { } func save( - _ value: [String: TerminalLayoutSnapshot], + _: [String: TerminalLayoutSnapshot], context _: SaveContext, continuation: SaveContinuation ) { - @Dependency(\.settingsFileStorage) var storage - do { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(value) - try storage.save(data, SupacodePaths.layoutsURL) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } + // No-op: `LayoutsIncrementalWriter` is the sole disk writer for `layouts.json`. + // `@Shared(.layouts)` stays the in-memory source of truth; persisting here too + // would race the actor's per-key merge with a whole-dict last-writer-wins clobber. + continuation.resume() } } diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 74a669727..a92ea2e7f 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -30,6 +30,24 @@ final class WorktreeTerminalManager { private let hookEventSleep: @Sendable (Duration) async throws -> Void @ObservationIgnored @Dependency(\.zmxClient) private var zmxClient @ObservationIgnored @Dependency(\.analyticsClient) private var analyticsClient + /// Serialized off-main writer that merges per-worktree layout changes into + /// `layouts.json` without clobbering keys it isn't carrying. Built from the + /// dependency context at init so async flushes use the same storage the test + /// or app configured, not whatever context happens to be current at flush. + @ObservationIgnored private let layoutsWriter: LayoutsIncrementalWriter + /// Per-worktree debounce timers for incremental layout saves. + @ObservationIgnored private var layoutDirtyTasks: [Worktree.ID: Task] = [:] + /// Per-worktree in-flight positive flush Tasks. A delete awaits the live one + /// for its key so `.delete` always lands on the writer after the `.snapshot`, + /// preventing a stale positive flush from resurrecting a pruned worktree. + @ObservationIgnored private var layoutFlushTasks: [Worktree.ID: Task] = [:] + /// Sleeps the incremental-save debounce window; injected so tests drive it. + @ObservationIgnored private let layoutDebounceSleep: @Sendable (Duration) async throws -> Void + /// Debounce window before an incremental layout snapshot is flushed. + private static let layoutDebounceDuration: Duration = .seconds(1) + /// Reads the freshest `agentsBySurface` at flush time so incremental captures + /// embed live badge records instead of the empty default. + var currentAgentsBySurface: (() -> [UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]])? /// Holds `.idle` long enough to collapse PostToolUse/PreToolUse busy/idle alternation /// into a sustained busy; stays sub-perceptible for the badge clearing at end-of-session. private static let idleHookDebounceDuration: Duration = .milliseconds(400) @@ -54,6 +72,9 @@ final class WorktreeTerminalManager { ) { self.runtime = runtime self.hookEventSleep = { duration in try await clock.sleep(for: duration) } + self.layoutDebounceSleep = { duration in try await clock.sleep(for: duration) } + @Dependency(\.settingsFileStorage) var settingsFileStorage + self.layoutsWriter = LayoutsIncrementalWriter(storage: settingsFileStorage) let resolvedServer = socketServer ?? AgentHookSocketServer() guard resolvedServer.socketPath != nil else { self.socketServer = nil @@ -66,6 +87,8 @@ final class WorktreeTerminalManager { isolated deinit { for task in pendingIdleHookEvents.values { task.cancel() } + for task in layoutDirtyTasks.values { task.cancel() } + for task in layoutFlushTasks.values { task.cancel() } } private func configureSocketServer(_ server: AgentHookSocketServer) { @@ -300,7 +323,7 @@ final class WorktreeTerminalManager { guard id != selectedWorktreeID else { return } if let previousID = selectedWorktreeID, let previousState = states[previousID] { previousState.setAllSurfacesOccluded() - saveLayoutSnapshot?(previousID, previousState.captureLayoutSnapshot()) + markLayoutDirty(worktreeID: previousID) } selectedWorktreeID = id terminalLogger.info("Selected worktree \(id ?? "nil")") @@ -408,10 +431,15 @@ final class WorktreeTerminalManager { state.onTabCreated = { [weak self] in self?.emit(.tabCreated(worktreeID: worktree.id)) self?.emitProjection(for: worktree.id) + self?.markLayoutDirty(worktreeID: worktree.id) } state.onTabClosed = { [weak self] in self?.emit(.tabClosed(worktreeID: worktree.id)) self?.emitProjection(for: worktree.id) + self?.markLayoutDirty(worktreeID: worktree.id) + } + state.onTabRenamed = { [weak self] in + self?.markLayoutDirty(worktreeID: worktree.id) } state.onFocusChanged = { [weak self] surfaceID in self?.emit(.focusChanged(worktreeID: worktree.id, surfaceID: surfaceID)) @@ -431,9 +459,11 @@ final class WorktreeTerminalManager { } state.onTabProjectionChanged = { [weak self] projection in self?.emit(.tabProjectionChanged(worktreeID: worktree.id, projection)) + self?.markLayoutDirty(worktreeID: worktree.id) } state.onTabRemoved = { [weak self] tabID in self?.emit(.tabRemoved(worktreeID: worktree.id, tabID: tabID)) + self?.markLayoutDirty(worktreeID: worktree.id) } state.onTabProgressDisplayChanged = { [weak self] tabID, display in self?.emit(.tabProgressDisplayChanged(worktreeID: worktree.id, tabID: tabID, display: display)) @@ -484,8 +514,10 @@ final class WorktreeTerminalManager { } for (id, state) in removed { // Clear instead of resaving: archived / deleted worktrees should leave - // no trace in `layouts.json`. - saveLayoutSnapshot?(id, nil) + // no trace in `layouts.json`. The explicit delete bypasses the debounce + // and cancels any queued positive save so a pruned worktree can't be + // resurrected by an in-flight snapshot. + deleteLayoutSnapshot(worktreeID: id) state.closeAllSurfaces() // Signals the reducer to drop any orphan `terminalTabs` entries and // recently-removed-tab records for this worktree so a same-session @@ -503,6 +535,73 @@ final class WorktreeTerminalManager { killZmxSessions(prunedSessionIDs) } + /// Schedules a debounced incremental layout save for `worktreeID`. Coalesces + /// a burst of mutations into one write; the snapshot is captured at fire time + /// (freshest tree + agent records), mutated into the in-memory `@Shared` dict + /// on main, then merged into `layouts.json` off main. + func markLayoutDirty(worktreeID: Worktree.ID) { + layoutDirtyTasks[worktreeID]?.cancel() + layoutDirtyTasks[worktreeID] = Task { [weak self, layoutDebounceSleep] in + try? await layoutDebounceSleep(Self.layoutDebounceDuration) + guard !Task.isCancelled else { return } + self?.flushLayoutSnapshot(worktreeID: worktreeID) + } + } + + /// Fires after the debounce window: captures the freshest snapshot for + /// `worktreeID`, updates the in-memory `@Shared` dict on main, then queues the + /// off-main per-key merge. Its only caller is `markLayoutDirty`. + private func flushLayoutSnapshot(worktreeID: Worktree.ID) { + layoutDirtyTasks[worktreeID] = nil + guard let state = states[worktreeID] else { return } + let agents = currentAgentsBySurface?() ?? [:] + // A nil snapshot (no remaining tabs) clears the key rather than persisting + // an empty layout, matching the on-disk "no trace" semantics for emptiness. + let snapshot = state.captureLayoutSnapshot(agentsBySurface: agents) + saveLayoutSnapshot?(worktreeID, snapshot) + let change: LayoutsIncrementalWriter.Change = snapshot.map { .snapshot($0) } ?? .delete + let writer = layoutsWriter + let task = Task { [weak self] in + await writer.flush([worktreeID: change]) + self?.layoutFlushTasks[worktreeID] = nil + } + layoutFlushTasks[worktreeID] = task + } + + /// Removes `worktreeID` from disk immediately, bypassing the debounce and + /// cancelling any queued positive save so a stale snapshot can't resurrect a + /// removed worktree. Awaits any in-flight positive flush for the key first so + /// the `.delete` always reaches the writer after the `.snapshot`. + private func deleteLayoutSnapshot(worktreeID: Worktree.ID) { + layoutDirtyTasks[worktreeID]?.cancel() + layoutDirtyTasks[worktreeID] = nil + saveLayoutSnapshot?(worktreeID, nil) + let inflightFlush = layoutFlushTasks[worktreeID] + let writer = layoutsWriter + // We await inflightFlush so the .delete lands after any in-flight positive + // flush; prune also drops the id from states synchronously before any later + // saveAllLayoutSnapshots, so no positive snapshot is re-emitted. + let task = Task { [weak self] in + await inflightFlush?.value + await writer.flush([worktreeID: .delete]) + self?.layoutFlushTasks[worktreeID] = nil + } + layoutFlushTasks[worktreeID] = task + } + + /// Cancels every queued incremental save. Called before the on-quit + /// synchronous flush becomes the terminal write. + func cancelPendingLayoutSaves() { + for task in layoutDirtyTasks.values { task.cancel() } + layoutDirtyTasks.removeAll() + // Best-effort cancel: an already-started flush has no cancellation + // checkpoint in `applyAndWrite`, so it runs to completion. The writer's lock + // plus the atomic temp+rename keep the on-quit write from tearing; the worst + // case is a stale-but-valid key set on the next launch, never a corrupt file. + for task in layoutFlushTasks.values { task.cancel() } + layoutFlushTasks.removeAll() + } + /// Tears down persistent zmx sessions for worktrees that just left the keep set. /// Parallel kill so a single stuck daemon doesn't pin the executor for /// `subprocessTimeout * N` (the bound is now one timeout regardless of N). @@ -567,18 +666,33 @@ final class WorktreeTerminalManager { /// would survive forever. func terminateAllSessions() async { let trackedSurfaceIDs = states.values.flatMap(\.allSurfaceIDs) - let trackedSessionIDs = trackedSurfaceIDs.map(ZmxSessionID.make(surfaceID:)) + let trackedSessionIDs = Set(trackedSurfaceIDs.map(ZmxSessionID.make(surfaceID:))) for state in states.values { state.closeAllSurfaces() } emitHasAnyTerminalSurfaceIfNeeded() - let liveSessions = await zmxClient.listSessions() - let allSessions = Array(Set(trackedSessionIDs).union(liveSessions)) + // This instance's tracked sessions are always killed. The orphan subset + // (live and untracked) is attach-aware: spared when a client is attached or + // the count is unknown, so a concurrently-running instance keeps its + // sessions. Orphan reaping is therefore eventually consistent: the last + // instance to quit with no live clients sweeps what remains. + let liveSessions = await zmxClient.listSessionsWithClients() + let orphanSessions: [String] + if let liveSessions { + orphanSessions = liveSessions.filter { entry in + !trackedSessionIDs.contains(entry.name) && entry.clients == 0 + } + .map(\.name) + } else { + // nil = UNKNOWN probe; still force-kill tracked, but skip the orphan sweep. + terminalLogger.info("Skipping quit-time orphan sweep: zmx session probe unavailable") + orphanSessions = [] + } + let allSessions = Array(trackedSessionIDs.union(orphanSessions)) guard !allSessions.isEmpty else { return } - let orphanCount = Set(allSessions).subtracting(trackedSessionIDs).count analyticsClient.capture( "terminal_persistence_session_killed", - ["reason": "user_quit", "count": allSessions.count, "orphan_count": orphanCount] + ["reason": "user_quit", "count": allSessions.count, "orphan_count": orphanSessions.count] ) let client = zmxClient await withTaskGroup(of: Void.self) { group in @@ -589,11 +703,22 @@ final class WorktreeTerminalManager { } /// Reaps `supa-*` sessions zmx hosts that no persisted layout claims; - /// catches orphans from crashes / force-quits. + /// catches orphans from crashes / force-quits. Attach-aware: a session with + /// a live client (another Supacode instance or a manual `zmx attach`) is + /// spared, and a failed probe reaps nothing. func reapOrphanSessions(knownSurfaceIDs: Set) async { - let liveSessions = await zmxClient.listSessions() + guard let liveSessions = await zmxClient.listSessionsWithClients() else { + // nil = UNKNOWN (probe failed / timed out); never reap on no signal. + terminalLogger.info("Skipping orphan reap: zmx session probe unavailable") + return + } let knownSessionIDs = Set(knownSurfaceIDs.map(ZmxSessionID.make(surfaceID:))) - let orphans = Set(liveSessions).subtracting(knownSessionIDs) + // Only reap orphans we positively know have zero attached clients; spare + // clients>0 (in use) and clients==nil (unknown count). + let orphans = liveSessions.filter { entry in + !knownSessionIDs.contains(entry.name) && entry.clients == 0 + } + .map(\.name) guard !orphans.isEmpty else { return } terminalLogger.info("Reaping \(orphans.count) orphan zmx session(s)") analyticsClient.capture( @@ -672,9 +797,16 @@ final class WorktreeTerminalManager { assertionFailure("saveLayoutSnapshot closure not configured.") return } + // The actor is the sole disk writer (`LayoutsKey.save` is a no-op), so the + // on-quit terminal write goes through `flushSync` while still updating the + // in-memory `@Shared` dict via `saveLayoutSnapshot` for any live readers. + var changes: [Worktree.ID: LayoutsIncrementalWriter.Change] = [:] for (id, state) in states { - saveLayoutSnapshot(id, state.captureLayoutSnapshot(agentsBySurface: agentsBySurface)) + let snapshot = state.captureLayoutSnapshot(agentsBySurface: agentsBySurface) + saveLayoutSnapshot(id, snapshot) + changes[id] = snapshot.map { .snapshot($0) } ?? .delete } + layoutsWriter.flushSync(changes) } func surfaceBackgroundColorScheme() -> ColorScheme { diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 79988c656..58af7eb5b 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -127,6 +127,9 @@ final class WorktreeTerminalState { var onNotificationIndicatorChanged: (() -> Void)? var onTabCreated: (() -> Void)? var onTabClosed: (() -> Void)? + /// Fires when the user renames a tab. Manager forwards to the layout-persist + /// sink so a custom title survives relaunch without waiting for quit. + var onTabRenamed: (() -> Void)? var onFocusChanged: ((UUID) -> Void)? var onTaskStatusChanged: ((WorktreeTaskStatus) -> Void)? var onBlockingScriptCompleted: ((BlockingScriptKind, Int?, TerminalTabID?) -> Void)? @@ -638,6 +641,14 @@ final class WorktreeTerminalState { onTabClosed?() } + /// User-initiated rename. Routes through the manager so the new title (or its + /// removal on an empty commit) persists incrementally, unlike the restore path + /// which seeds `setCustomTitle` directly from a snapshot. + func renameTab(_ tabId: TerminalTabID, title: String) { + tabManager.setCustomTitle(tabId, title: title) + onTabRenamed?() + } + func closeOtherTabs(keeping tabId: TerminalTabID) { let ids = tabManager.tabs.map(\.id).filter { $0 != tabId } for id in ids { @@ -1930,6 +1941,10 @@ final class WorktreeTerminalState { } } emitTaskStatusIfChanged() + // Closing the last surface via `close_surface` removes the tab here but + // skips the `closeTab` projection path; emit one so `onTabRemoved` fires + // and the layout persistence sink observes the tab going away. + emitTabProjection(for: tabId) return } updateTree(newTree, for: tabId) diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 5bb94f67e..692846697 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -52,7 +52,7 @@ struct WorktreeTerminalTabsView: View { state.dismissSplitZoom(for: tabId) }, renameTab: { tabId, newTitle in - state.tabManager.setCustomTitle(tabId, title: newTitle) + state.renameTab(tabId, title: newTitle) }, ) .transition(.move(edge: .top).combined(with: .opacity)) diff --git a/supacodeTests/AgentPresence+TestHelpers.swift b/supacodeTests/AgentPresence+TestHelpers.swift index 0e7e634f1..07c64b92b 100644 --- a/supacodeTests/AgentPresence+TestHelpers.swift +++ b/supacodeTests/AgentPresence+TestHelpers.swift @@ -3,20 +3,28 @@ import Foundation @testable import supacode -/// Test-only harness around an `AgentPresenceFeature.State`. Drains the -/// manager's event stream and routes `agentHookEventReceived` / +/// Test-only harness around an `AgentPresenceFeature.State`. A background task +/// drains the manager's event stream and routes `agentHookEventReceived` / /// `surfacesClosed` events into the reducer so callers can drive the manager /// via `server.onEvent(...)` and then await `harness.drain()` to settle -/// presence on the same loop tick. +/// presence before asserting. @MainActor final class PresenceTestHarness { var state = AgentPresenceFeature.State() private let reducer = AgentPresenceFeature() - private var continuation: AsyncStream.Continuation? private var stream: AsyncStream? private var consumeTask: Task? + /// Bumped each time the consume task reduces a stream event. + private var processedCount = 0 + /// Bumped each time the consume task is about to wait for the next event, i.e. + /// it has drained everything buffered so far. + private var parkCount = 0 func send(_ action: AgentPresenceFeature.Action) { + reduce(action) + } + + private func reduce(_ action: AgentPresenceFeature.Action) { _ = reducer.reduce(into: &state, action: action) } @@ -29,13 +37,24 @@ final class PresenceTestHarness { send(.livenessSweepResult(snapshot: snapshot, alive: alive)) } - /// Pumps any events buffered on the manager's stream into the reducer and - /// returns. Tests call this after `server.onEvent(...)` so presence state - /// settles before assertions. + /// Settles presence after `server.onEvent(...)` / `clock.advance(...)`. Each + /// pass runs `megaYield` (flushing the consume task plus any clock-awoken + /// manager emit, e.g. an idle debounce resuming after `clock.advance`) and + /// returns once the consumer has parked again with no reduction in the final + /// pass, i.e. it observed and drained everything this call produced. The cap + /// keeps a genuinely quiet stream from looping forever. func drain() async { - // Yield repeatedly so the consume task drains every buffered event before - // returning to the test thread. - for _ in 0..<16 { await Task.yield() } + guard consumeTask != nil else { return } + var settled = 0 + for _ in 0..<64 { + let parksBefore = parkCount + let processedBefore = processedCount + await Task.megaYield(count: 10_000) + let parkedAgain = parkCount > parksBefore + let quiet = processedCount == processedBefore + settled = parkedAgain && quiet ? settled + 1 : 0 + if settled >= 2 { return } + } } func attach(to manager: WorktreeTerminalManager) { @@ -43,19 +62,23 @@ final class PresenceTestHarness { self.stream = stream consumeTask?.cancel() consumeTask = Task { - for await event in stream { + var iterator = stream.makeAsyncIterator() + while true { + self.parkCount += 1 + guard let event = await iterator.next() else { return } switch event { case .agentHookEventReceived(let payload): - self.send(.hookEventReceived(payload)) + self.reduce(.hookEventReceived(payload)) case .surfacesClosed(let ids): if ids.count == 1, let id = ids.first { - self.send(.surfaceClosed(id)) + self.reduce(.surfaceClosed(id)) } else { - self.send(.surfacesClosed(ids)) + self.reduce(.surfacesClosed(ids)) } default: continue } + self.processedCount += 1 } } } diff --git a/supacodeTests/LayoutsIncrementalWriterTests.swift b/supacodeTests/LayoutsIncrementalWriterTests.swift new file mode 100644 index 000000000..333f11821 --- /dev/null +++ b/supacodeTests/LayoutsIncrementalWriterTests.swift @@ -0,0 +1,138 @@ +import Dependencies +import Foundation +import SupacodeSettingsShared +import Testing + +@testable import supacode + +@MainActor +struct LayoutsIncrementalWriterTests { + private func snapshot(dir: String) -> TerminalLayoutSnapshot { + TerminalLayoutSnapshot( + tabs: [ + TerminalLayoutSnapshot.TabSnapshot( + id: nil, + title: "Terminal 1", + customTitle: nil, + icon: nil, + tintColor: nil, + layout: .leaf( + TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: dir) + ), + focusedLeafIndex: 0 + ) + ], + selectedTabIndex: 0 + ) + } + + private func readDict(_ storage: SettingsFileStorage, _ url: URL) -> [String: TerminalLayoutSnapshot] { + guard let data = try? storage.load(url) else { return [:] } + return (try? JSONDecoder().decode([String: TerminalLayoutSnapshot].self, from: data)) ?? [:] + } + + @Test func separateFlushesBothSurvive() async { + let storage = SettingsFileStorage.inMemory() + let url = SupacodePaths.layoutsURL + let writer = LayoutsIncrementalWriter(storage: storage, url: url) + + await writer.flush(["w1": .snapshot(snapshot(dir: "/w1"))]) + await writer.flush(["w2": .snapshot(snapshot(dir: "/w2"))]) + + let dict = readDict(storage, url) + #expect(Set(dict.keys) == ["w1", "w2"]) + } + + @Test func deleteRemovesOnlyTargetKey() async { + let storage = SettingsFileStorage.inMemory() + let url = SupacodePaths.layoutsURL + let writer = LayoutsIncrementalWriter(storage: storage, url: url) + + await writer.flush([ + "w1": .snapshot(snapshot(dir: "/w1")), + "w2": .snapshot(snapshot(dir: "/w2")), + ]) + await writer.flush(["w1": .delete]) + + let dict = readDict(storage, url) + #expect(Set(dict.keys) == ["w2"]) + } + + @Test func snapshotOverwritesSameKeyButPreservesOthers() async { + let storage = SettingsFileStorage.inMemory() + let url = SupacodePaths.layoutsURL + let writer = LayoutsIncrementalWriter(storage: storage, url: url) + + await writer.flush([ + "w1": .snapshot(snapshot(dir: "/old")), + "w2": .snapshot(snapshot(dir: "/w2")), + ]) + await writer.flush(["w1": .snapshot(snapshot(dir: "/new"))]) + + let dict = readDict(storage, url) + #expect(dict["w2"] != nil) + let leaf = dict["w1"]?.tabs.first?.layout + if case .leaf(let surface) = leaf { + #expect(surface.workingDirectory == "/new") + } else { + Issue.record("Expected a leaf layout for w1") + } + } + + @Test func identicalReflushSkipsTheWrite() async { + let inner = SettingsFileStorage.inMemory() + let url = SupacodePaths.layoutsURL + let saveCount = LockIsolated(0) + let storage = SettingsFileStorage( + load: { try inner.load($0) }, + save: { data, target in + if target == url { saveCount.withValue { $0 += 1 } } + try inner.save(data, target) + } + ) + let writer = LayoutsIncrementalWriter(storage: storage, url: url) + + await writer.flush(["w1": .snapshot(snapshot(dir: "/w1"))]) + // Re-splicing the same snapshot is a no-op; the second flush must not write. + await writer.flush(["w1": .snapshot(snapshot(dir: "/w1"))]) + + #expect(saveCount.value == 1) + #expect(Set(readDict(storage, url).keys) == ["w1"]) + } + + @Test func corruptFileIsRotatedAsideAndPersistenceRecovers() async throws { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appending(path: "LayoutsWriterCorrupt-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appending(path: "layouts.json", directoryHint: .notDirectory) + // Seed garbage so the decode fails on the next merge read. + try Data("not json".utf8).write(to: url) + + let storage = SettingsFileStorage( + load: { try Data(contentsOf: $0) }, + save: { data, target in try data.write(to: target, options: .atomic) } + ) + let writer = LayoutsIncrementalWriter(storage: storage, url: url) + await writer.flush(["w1": .snapshot(snapshot(dir: "/w1"))]) + + // Self-healed: the new key persisted instead of the flush aborting forever. + #expect(readDict(storage, url)["w1"] != nil) + // The corrupt bytes were preserved under a rotated name, not overwritten. + let rotated = try FileManager.default + .contentsOfDirectory(atPath: dir.path(percentEncoded: false)) + .filter { $0.hasPrefix("layouts.json.corrupt-") } + #expect(rotated.count == 1) + } + + @Test func emptyChangesIsNoOp() async { + let storage = SettingsFileStorage.inMemory() + let url = SupacodePaths.layoutsURL + let writer = LayoutsIncrementalWriter(storage: storage, url: url) + + await writer.flush(["w1": .snapshot(snapshot(dir: "/w1"))]) + await writer.flush([:]) + + #expect(Set(readDict(storage, url).keys) == ["w1"]) + } +} diff --git a/supacodeTests/WorktreeTerminalManagerLayoutPersistenceTests.swift b/supacodeTests/WorktreeTerminalManagerLayoutPersistenceTests.swift new file mode 100644 index 000000000..c2581627c --- /dev/null +++ b/supacodeTests/WorktreeTerminalManagerLayoutPersistenceTests.swift @@ -0,0 +1,358 @@ +import Clocks +import Dependencies +import Foundation +import SupacodeSettingsShared +import Testing + +@testable import supacode + +@MainActor +@Suite(.serialized) +struct LayoutPersistenceManagerTests { + /// Counts writer saves and mirrors the in-memory `@Shared(.layouts)` mutation + /// the app performs, so a test can assert both coalescing and final on-disk + /// state from one storage. + private struct Harness { + let manager: WorktreeTerminalManager + let clock: TestClock + let saveCount: LockIsolated + let storage: SettingsFileStorage + let url: URL + /// When non-nil, the next save whose payload still contains `worktreeID` + /// (a positive snapshot) blocks on this semaphore so the test can hold the + /// positive flush Task in-flight while it triggers the prune. + let gate: LockIsolated<(worktreeID: String, semaphore: DispatchSemaphore)?> + /// Flips true once the gated positive save is blocked, proving the flush + /// Task is suspended inside `writer.flush` (i.e. `layoutFlushTasks[id]` is + /// still non-nil, before the spawning Task can clear it). + let gateEngaged: LockIsolated + } + + private func makeHarness() -> Harness { + let clock = TestClock() + let saveCount = LockIsolated(0) + let gate = LockIsolated<(worktreeID: String, semaphore: DispatchSemaphore)?>(nil) + let gateEngaged = LockIsolated(false) + let inner = SettingsFileStorage.inMemory() + let url = SupacodePaths.layoutsURL + let storage = SettingsFileStorage( + load: { try inner.load($0) }, + save: { data, target in + if target == url { saveCount.withValue { $0 += 1 } } + // Block a positive snapshot write so the spawning flush Task stays + // in-flight; a delete payload (key absent) passes straight through. + if target == url, + let active = gate.value, + let dict = try? JSONDecoder().decode([String: TerminalLayoutSnapshot].self, from: data), + dict[active.worktreeID] != nil + { + gate.setValue(nil) + gateEngaged.setValue(true) + active.semaphore.wait() + } + try inner.save(data, target) + } + ) + let manager = withDependencies { + $0.settingsFileStorage = storage + } operation: { + WorktreeTerminalManager(runtime: GhosttyRuntime(), clock: clock) + } + // Mirror the app's in-memory dict mutation; the writer's storage is the + // on-disk source of truth these tests assert against. + manager.saveLayoutSnapshot = { _, _ in } + return Harness( + manager: manager, + clock: clock, + saveCount: saveCount, + storage: storage, + url: url, + gate: gate, + gateEngaged: gateEngaged + ) + } + + private func readDict(_ harness: Harness) -> [String: TerminalLayoutSnapshot] { + guard let data = try? harness.storage.load(harness.url) else { return [:] } + return (try? JSONDecoder().decode([String: TerminalLayoutSnapshot].self, from: data)) ?? [:] + } + + /// Awaits a condition by pumping the off-main actor flush onto the executor; + /// bounded so a never-true predicate can't hang the suite. No `Task.sleep`. + /// `megaYield` runs detached background work between checks so the writer + /// actor's flush actually lands even under a saturated cooperative pool. + private func waitUntil(_ predicate: () -> Bool) async { + for _ in 0..<200 { + if predicate() { return } + await Task.megaYield() + } + } + + /// Yields enough for the latest debounce task to register its sleep with the + /// TestClock, then advances past the window so the flush fires. + private func settleThenAdvance(_ clock: TestClock) async { + await Task.megaYield() + await clock.advance(by: .seconds(1)) + } + + private func makeWorktree(id: String = "/tmp/repo/wt-1") -> Worktree { + Worktree( + id: id, + name: URL(fileURLWithPath: id).lastPathComponent, + detail: "detail", + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + } + + @Test func debounceCoalescesBurstIntoOneWrite() async { + let harness = makeHarness() + let worktree = makeWorktree() + let state = harness.manager.state(for: worktree) + + // Several structural mutations within the window must coalesce to one flush. + _ = state.createTab(focusing: false) + _ = state.createTab(focusing: false) + _ = state.createTab(focusing: false) + + #expect(harness.saveCount.value == 0) + await settleThenAdvance(harness.clock) + await waitUntil { harness.saveCount.value == 1 } + #expect(harness.saveCount.value == 1) + #expect(readDict(harness)[worktree.id] != nil) + } + + @Test func pruneDeletesAndCancelsQueuedSave() async { + let harness = makeHarness() + let worktree = makeWorktree() + let state = harness.manager.state(for: worktree) + _ = state.createTab(focusing: false) + + // Seed disk with a prior snapshot so the delete has something to remove. + await settleThenAdvance(harness.clock) + await waitUntil { readDict(harness)[worktree.id] != nil } + + // Queue a positive save, then prune before the window elapses. + _ = state.createTab(focusing: false) + harness.manager.prune(keeping: []) + await waitUntil { readDict(harness)[worktree.id] == nil } + + // Pin the save count once the delete has flushed; a resurrecting positive + // write would bump it, so gating on a positive increment proves whether the + // queued save was actually cancelled rather than merely lagging. + let savesAfterDelete = harness.saveCount.value + await harness.clock.advance(by: .seconds(1)) + await waitUntil { harness.saveCount.value > savesAfterDelete } + #expect(harness.saveCount.value == savesAfterDelete) + #expect(readDict(harness)[worktree.id] == nil) + } + + @Test func pruneAfterDebounceFiresDoesNotResurrect() async { + let harness = makeHarness() + let worktree = makeWorktree() + let state = harness.manager.state(for: worktree) + _ = state.createTab(focusing: false) + + // Seed disk with a prior snapshot so the delete has something to remove. + await settleThenAdvance(harness.clock) + await waitUntil { readDict(harness)[worktree.id] != nil } + + // Arm the gate so the positive snapshot write blocks mid-flush, pinning the + // flush Task in-flight. Then queue the positive save and fire the debounce. + let release = DispatchSemaphore(value: 0) + let gatedID = worktree.id + harness.gate.setValue((worktreeID: gatedID, semaphore: release)) + _ = state.createTab(focusing: false) + await settleThenAdvance(harness.clock) + + // The positive flush Task is provably suspended inside `writer.flush` (save + // blocked), so `layoutFlushTasks[id]` is still non-nil. Prune now captures + // that in-flight Task and must await it before deleting. + await waitUntil { harness.gateEngaged.value } + #expect(harness.gateEngaged.value) + harness.manager.prune(keeping: []) + + // Release the positive flush; the delete chained behind it must win. + release.signal() + await waitUntil { readDict(harness)[worktree.id] == nil } + #expect(readDict(harness)[worktree.id] == nil) + } + + @Test func cancelPendingLayoutSavesDropsQueuedFlush() async { + let harness = makeHarness() + let worktree = makeWorktree() + let state = harness.manager.state(for: worktree) + _ = state.createTab(focusing: false) + + harness.manager.cancelPendingLayoutSaves() + await harness.clock.advance(by: .seconds(1)) + + // Queue and flush a control worktree's save AFTER the cancel. Once the + // control write lands, the executor has run long enough that the cancelled + // save would have fired too, so its continued absence proves suppression + // rather than mere lag. + let control = makeWorktree(id: "/tmp/repo/wt-control") + let controlState = harness.manager.state(for: control) + _ = controlState.createTab(focusing: false) + await settleThenAdvance(harness.clock) + await waitUntil { readDict(harness)[control.id] != nil } + + #expect(readDict(harness)[control.id] != nil) + #expect(harness.saveCount.value == 1) + #expect(readDict(harness)[worktree.id] == nil) + } + + @Test func mergePreservesSiblingKeyWrittenByAnotherFlush() async { + let harness = makeHarness() + let wt1 = makeWorktree(id: "/tmp/repo/wt-1") + let wt2 = makeWorktree(id: "/tmp/repo/wt-2") + + let state1 = harness.manager.state(for: wt1) + _ = state1.createTab(focusing: false) + await settleThenAdvance(harness.clock) + await waitUntil { readDict(harness)[wt1.id] != nil } + + let state2 = harness.manager.state(for: wt2) + _ = state2.createTab(focusing: false) + await settleThenAdvance(harness.clock) + await waitUntil { readDict(harness)[wt2.id] != nil } + + let dict = readDict(harness) + #expect(Set(dict.keys) == [wt1.id, wt2.id]) + } + + @Test func incrementalCaptureEmbedsLiveAgentRecords() async { + let harness = makeHarness() + let worktree = makeWorktree() + let state = harness.manager.state(for: worktree) + guard let tabID = state.createTab(focusing: false), + let surface = state.splitTree(for: tabID).root?.leftmostLeaf() + else { + Issue.record("Expected a tab and surface") + return + } + let record = TerminalLayoutSnapshot.SurfaceAgentRecord(agent: "claude", pids: [42], activity: "busy") + harness.manager.currentAgentsBySurface = { [surface.id: [record]] } + // Mark dirty again now that the agent reader is wired. + harness.manager.markLayoutDirty(worktreeID: worktree.id) + + await settleThenAdvance(harness.clock) + await waitUntil { readDict(harness)[worktree.id]?.tabs.first?.layout != nil } + + let leaf = readDict(harness)[worktree.id]?.tabs.first?.layout + guard case .leaf(let persisted) = leaf else { + Issue.record("Expected a leaf layout") + return + } + #expect(persisted.agents == [record]) + } + + @Test func presentButUnreadableFileAbortsAndPreservesSiblings() async { + let clock = TestClock() + let inner = SettingsFileStorage.inMemory() + let url = SupacodePaths.layoutsURL + // Flips on after the two siblings are seeded so the third flush's read fails + // with a non-absent error, exercising the abort branch. + let failLoad = LockIsolated(false) + let loadFailCount = LockIsolated(0) + let storage = SettingsFileStorage( + load: { target in + if target == url, failLoad.value { + loadFailCount.withValue { $0 += 1 } + throw CocoaError(.fileReadCorruptFile) + } + return try inner.load(target) + }, + save: { data, target in try inner.save(data, target) } + ) + let manager = withDependencies { + $0.settingsFileStorage = storage + } operation: { + WorktreeTerminalManager(runtime: GhosttyRuntime(), clock: clock) + } + manager.saveLayoutSnapshot = { _, _ in } + + // Raw reader bypasses the throwing flag so assertions see the real disk state. + let readRaw: () -> [String: TerminalLayoutSnapshot] = { + guard let data = try? inner.load(url) else { return [:] } + return (try? JSONDecoder().decode([String: TerminalLayoutSnapshot].self, from: data)) ?? [:] + } + + let wt1 = makeWorktree(id: "/tmp/repo/wt-1") + let wt2 = makeWorktree(id: "/tmp/repo/wt-2") + let state1 = manager.state(for: wt1) + _ = state1.createTab(focusing: false) + await Task.megaYield() + await clock.advance(by: .seconds(1)) + await waitUntil { readRaw()[wt1.id] != nil } + let state2 = manager.state(for: wt2) + _ = state2.createTab(focusing: false) + await Task.megaYield() + await clock.advance(by: .seconds(1)) + await waitUntil { readRaw()[wt2.id] != nil } + #expect(Set(readRaw().keys) == [wt1.id, wt2.id]) + + // Make the merge read fail, then flush a third key. The writer must abort + // rather than splice into an empty dict and clobber the two siblings. + failLoad.setValue(true) + let wt3 = makeWorktree(id: "/tmp/repo/wt-3") + let state3 = manager.state(for: wt3) + _ = state3.createTab(focusing: false) + await Task.megaYield() + await clock.advance(by: .seconds(1)) + await waitUntil { loadFailCount.value > 0 } + #expect(loadFailCount.value > 0) + + let dict = readRaw() + #expect(Set(dict.keys) == [wt1.id, wt2.id]) + #expect(dict[wt3.id] == nil) + } + + @Test func renamePersistsCustomTitleIncrementally() async { + let harness = makeHarness() + let worktree = makeWorktree() + let state = harness.manager.state(for: worktree) + guard let tabID = state.createTab(focusing: false) else { + Issue.record("Expected a tab") + return + } + await settleThenAdvance(harness.clock) + await waitUntil { readDict(harness)[worktree.id] != nil } + + state.renameTab(tabID, title: "Renamed") + await settleThenAdvance(harness.clock) + await waitUntil { readDict(harness)[worktree.id]?.tabs.first?.customTitle == "Renamed" } + + #expect(readDict(harness)[worktree.id]?.tabs.first?.customTitle == "Renamed") + } + + @Test func onQuitFlushSyncPersistsLiveStatesAsTerminalWrite() { + let harness = makeHarness() + let wt1 = makeWorktree(id: "/tmp/repo/wt-1") + let wt2 = makeWorktree(id: "/tmp/repo/wt-2") + _ = harness.manager.state(for: wt1).createTab(focusing: false) + _ = harness.manager.state(for: wt2).createTab(focusing: false) + + // Mirror the quit path: drop queued debounce saves, then the synchronous + // on-quit flush must land every live state as the terminal on-disk write. + harness.manager.cancelPendingLayoutSaves() + harness.manager.saveAllLayoutSnapshots() + + #expect(Set(readDict(harness).keys) == [wt1.id, wt2.id]) + } + + @Test func capturedSnapshotRoundTripsThroughDisk() async { + let harness = makeHarness() + let worktree = makeWorktree() + let state = harness.manager.state(for: worktree) + _ = state.createTab(focusing: false) + _ = state.createTab(focusing: false) + + await settleThenAdvance(harness.clock) + await waitUntil { readDict(harness)[worktree.id] != nil } + + let persisted = readDict(harness)[worktree.id] + let inMemory = state.captureLayoutSnapshot() + #expect(persisted == inMemory) + } +} diff --git a/supacodeTests/WorktreeTerminalManagerReaperTests.swift b/supacodeTests/WorktreeTerminalManagerReaperTests.swift new file mode 100644 index 000000000..b4226e1e4 --- /dev/null +++ b/supacodeTests/WorktreeTerminalManagerReaperTests.swift @@ -0,0 +1,120 @@ +import Dependencies +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct WorktreeTerminalManagerReaperTests { + /// Builds a manager whose injected zmx client records every kill and serves + /// the supplied `ls` listing (nil = probe failed). + private func makeManager( + listing: [ZmxSessionListParser.Entry]?, + killed: LockIsolated<[String]> + ) -> WorktreeTerminalManager { + withDependencies { + $0.zmxClient = ZmxClient( + executableURL: { nil }, + isBundled: { true }, + wrapCommand: { _, _ in nil }, + killSession: { id in killed.withValue { $0.append(id) } }, + listSessionsWithClients: { listing } + ) + } operation: { + WorktreeTerminalManager(runtime: GhosttyRuntime()) + } + } + + private func session(for surfaceID: UUID) -> String { + ZmxSessionID.make(surfaceID: surfaceID) + } + + @Test func reapSparesAttachedOrphanEvenWhenUnknown() async { + let attached = UUID() + let idle = UUID() + let killed = LockIsolated<[String]>([]) + let manager = makeManager( + listing: [ + .init(name: session(for: attached), clients: 1), + .init(name: session(for: idle), clients: 0), + ], + killed: killed + ) + + await manager.reapOrphanSessions(knownSurfaceIDs: []) + + #expect(killed.value == [session(for: idle)]) + } + + @Test func reapSkipsErrAndUnreachableSessions() async { + let unreachable = UUID() + let killed = LockIsolated<[String]>([]) + let manager = makeManager( + listing: [.init(name: session(for: unreachable), clients: nil)], + killed: killed + ) + + await manager.reapOrphanSessions(knownSurfaceIDs: []) + + #expect(killed.value.isEmpty) + } + + @Test func reapKillsNothingWhenProbeUnavailable() async { + let idle = UUID() + let killed = LockIsolated<[String]>([]) + let manager = makeManager(listing: nil, killed: killed) + + await manager.reapOrphanSessions(knownSurfaceIDs: [idle]) + + #expect(killed.value.isEmpty) + } + + @Test func terminateKillsTrackedEvenWithLiveClientsButSparesUntrackedInUse() async { + let killed = LockIsolated<[String]>([]) + let listing = LockIsolated<[ZmxSessionListParser.Entry]?>([]) + let manager = withDependencies { + $0.zmxClient = ZmxClient( + executableURL: { nil }, + isBundled: { true }, + wrapCommand: { _, _ in nil }, + killSession: { id in killed.withValue { $0.append(id) } }, + listSessionsWithClients: { listing.value } + ) + } operation: { + WorktreeTerminalManager(runtime: GhosttyRuntime()) + } + + let worktree = makeWorktree() + let state = manager.state(for: worktree) + guard let tabID = state.createTab(focusing: false), + let surface = state.splitTree(for: tabID).root?.leftmostLeaf() + else { + Issue.record("Expected a tab and surface") + return + } + let trackedSurfaceID = surface.id + let trackedSession = session(for: trackedSurfaceID) + let untrackedSession = session(for: UUID()) + // The tracked session reports clients>0 (must still die) and an untracked + // session is in use (must be spared). + listing.setValue([ + .init(name: trackedSession, clients: 2), + .init(name: untrackedSession, clients: 3), + ]) + + await manager.terminateAllSessions() + + #expect(killed.value == [trackedSession]) + } + + private func makeWorktree(id: String = "/tmp/repo/wt-1") -> Worktree { + let name = URL(fileURLWithPath: id).lastPathComponent + return Worktree( + id: id, + name: name, + detail: "detail", + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + } +} diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index bb8e0b999..51a1bdc5a 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -303,6 +303,9 @@ struct WorktreeTerminalManagerTests { server.onEvent?(makeHookEvent(.busy, surfaceID: surface.id)) server.onEvent?(makeHookEvent(.idle, surfaceID: surface.id)) + // Settle the stream-delivered events before the direct close so a buffered + // busy can't resurrect activity after the surface is gone. + await presence.drain() presence.send(.surfaceClosed(surface.id)) await clock.advance(by: .milliseconds(500)) await presence.drain() diff --git a/supacodeTests/ZmxClientTests.swift b/supacodeTests/ZmxClientTests.swift index c51e91120..48e720a99 100644 --- a/supacodeTests/ZmxClientTests.swift +++ b/supacodeTests/ZmxClientTests.swift @@ -129,6 +129,58 @@ struct ZmxSocketBudgetTests { } } +@MainActor +struct ZmxSessionListParserTests { + @Test func parsesClientsZero() { + let entries = ZmxSessionListParser.parse("name=supa-abc\tpid=123\tclients=0\tcreated=0\n") + #expect(entries == [.init(name: "supa-abc", clients: 0)]) + } + + @Test func parsesClientsPositive() { + let entries = ZmxSessionListParser.parse("name=supa-abc\tpid=123\tclients=2\tcreated=0\n") + #expect(entries == [.init(name: "supa-abc", clients: 2)]) + } + + @Test func errOrStatusLineYieldsNilClients() { + let entries = ZmxSessionListParser.parse( + "name=supa-abc\terr=ConnectionRefused\tstatus=cleaning up\n" + ) + #expect(entries == [.init(name: "supa-abc", clients: nil)]) + } + + @Test func stripsCurrentSessionArrowPrefix() { + let entries = ZmxSessionListParser.parse("→ name=supa-abc\tpid=1\tclients=1\tcreated=0\n") + #expect(entries == [.init(name: "supa-abc", clients: 1)]) + } + + @Test func stripsLeadingIndentOnNonCurrentSessions() { + let entries = ZmxSessionListParser.parse(" name=supa-abc\tclients=0\tpid=1\tcreated=0\n") + #expect(entries == [.init(name: "supa-abc", clients: 0)]) + } + + @Test func filtersNonSupaSessions() { + let entries = ZmxSessionListParser.parse( + """ + name=dev\tpid=1\tclients=2\tcreated=0 + name=supa-abc\tpid=2\tclients=0\tcreated=0 + """ + ) + #expect(entries == [.init(name: "supa-abc", clients: 0)]) + } + + @Test func dropsBlankAndMalformedLines() { + let entries = ZmxSessionListParser.parse( + """ + + garbage with no equals + name=supa-keep\tpid=9\tclients=3\tcreated=0 + + """ + ) + #expect(entries == [.init(name: "supa-keep", clients: 3)]) + } +} + @MainActor struct ZmxClientNoopTests { /// The default test impl is a no-op so existing TestStore tests are unaffected From 65c87e3047a621649d202e2b581287285903e975 Mon Sep 17 00:00:00 2001 From: COCPORN Date: Sun, 31 May 2026 18:24:44 +0200 Subject: [PATCH 04/56] Always focus the terminal after worktree navigation (#371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 #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 --- .../Reducer/RepositoriesFeature.swift | 16 ++++++-- .../Repositories/Views/SidebarListView.swift | 2 +- supacodeTests/RepositoriesFeatureTests.swift | 39 +++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 3d0cdde01..f2b8125b8 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -765,19 +765,19 @@ struct RepositoriesFeature { guard slots.indices.contains(index) else { return .run { _ in NSSound.beep() } } - return .send(.selectWorktree(slots[index].id)) + return .send(.selectWorktree(slots[index].id, focusTerminal: true)) case .selectNextWorktree: guard let id = state.worktreeID(byOffset: 1) else { return .run { _ in NSSound.beep() } } - return .send(.selectWorktree(id)) + return .send(.selectWorktree(id, focusTerminal: true)) case .selectPreviousWorktree: guard let id = state.worktreeID(byOffset: -1) else { return .run { _ in NSSound.beep() } } - return .send(.selectWorktree(id)) + return .send(.selectWorktree(id, focusTerminal: true)) case .worktreeHistoryBack: return state.navigateWorktreeHistoryEffect(direction: .back) @@ -5001,7 +5001,15 @@ extension RepositoriesFeature.State { } } setSingleWorktreeSelection(candidate, recordHistory: false) - return .send(.delegate(.selectedWorktreeChanged(worktree(for: candidate)))) + var effects: [Effect] = [ + .send(.delegate(.selectedWorktreeChanged(worktree(for: candidate)))) + ] + if sidebarItems[id: candidate] != nil { + effects.append( + .send(.sidebarItems(.element(id: candidate, action: .focusTerminalRequested))) + ) + } + return .merge(effects) } } diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 71efb9b6a..d70d151ef 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -27,7 +27,7 @@ struct SidebarListView: View { get: { currentSelections }, set: { newValue in guard newValue != currentSelections else { return } - store.send(.selectionChanged(newValue)) + store.send(.selectionChanged(newValue, focusTerminal: true)) } ) let pendingSidebarReveal = state.pendingSidebarReveal diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 2450f1b9b..472db5d81 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -5602,6 +5602,9 @@ struct RepositoriesFeatureTests { $0.applyPostReduceCacheRecomputes(.selectedWorktreeSlice) } await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.sidebarItems[id: wt1.id].focusTerminalRequested) { + $0.sidebarItems[id: wt1.id]?.shouldFocusTerminal = true + } } @Test func selectPreviousWorktreeWrapsBackward() async { @@ -5623,6 +5626,9 @@ struct RepositoriesFeatureTests { $0.applyPostReduceCacheRecomputes(.selectedWorktreeSlice) } await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.sidebarItems[id: wt2.id].focusTerminalRequested) { + $0.sidebarItems[id: wt2.id]?.shouldFocusTerminal = true + } } @Test func selectNextWorktreeWithNoSelectionSelectsFirst() async { @@ -5642,6 +5648,9 @@ struct RepositoriesFeatureTests { $0.applyPostReduceCacheRecomputes(.selectedWorktreeSlice) } await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.sidebarItems[id: wt1.id].focusTerminalRequested) { + $0.sidebarItems[id: wt1.id]?.shouldFocusTerminal = true + } } @Test func selectNextWorktreeCollapsesSidebarSelectionToSingleWorktree() async { @@ -5665,6 +5674,9 @@ struct RepositoriesFeatureTests { $0.applyPostReduceCacheRecomputes(.selectedWorktreeSlice) } await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.sidebarItems[id: wt2.id].focusTerminalRequested) { + $0.sidebarItems[id: wt2.id]?.shouldFocusTerminal = true + } } @Test func selectPreviousWorktreeWithNoSelectionSelectsLast() async { @@ -5684,6 +5696,9 @@ struct RepositoriesFeatureTests { $0.applyPostReduceCacheRecomputes(.selectedWorktreeSlice) } await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.sidebarItems[id: wt2.id].focusTerminalRequested) { + $0.sidebarItems[id: wt2.id]?.shouldFocusTerminal = true + } } @Test func selectNextWorktreeFollowsSidebarOrderNotRawWorktreeList() async { @@ -5713,6 +5728,9 @@ struct RepositoriesFeatureTests { $0.applyPostReduceCacheRecomputes(.selectedWorktreeSlice) } await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.sidebarItems[id: feature.id].focusTerminalRequested) { + $0.sidebarItems[id: feature.id]?.shouldFocusTerminal = true + } } @Test func selectNextWorktreeWithEmptyRowsIsNoOp() async { @@ -5740,6 +5758,9 @@ struct RepositoriesFeatureTests { $0.applyPostReduceCacheRecomputes(.selectedWorktreeSlice) } await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.sidebarItems[id: worktree.id].focusTerminalRequested) { + $0.sidebarItems[id: worktree.id]?.shouldFocusTerminal = true + } } @Test func selectNextWorktreeSkipsCollapsedRepository() async { @@ -5767,6 +5788,9 @@ struct RepositoriesFeatureTests { $0.applyPostReduceCacheRecomputes(.selectedWorktreeSlice) } await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.sidebarItems[id: wt3.id].focusTerminalRequested) { + $0.sidebarItems[id: wt3.id]?.shouldFocusTerminal = true + } } @Test func selectPreviousWorktreeSkipsCollapsedRepository() async { @@ -5794,6 +5818,9 @@ struct RepositoriesFeatureTests { $0.applyPostReduceCacheRecomputes(.selectedWorktreeSlice) } await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.sidebarItems[id: wt1.id].focusTerminalRequested) { + $0.sidebarItems[id: wt1.id]?.shouldFocusTerminal = true + } } @Test func selectNextWorktreeAllCollapsedIsNoOp() async { @@ -5853,6 +5880,9 @@ struct RepositoriesFeatureTests { $0.applyPostReduceCacheRecomputes(.selectedWorktreeSlice) } await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.sidebarItems[id: wt1.id].focusTerminalRequested) { + $0.sidebarItems[id: wt1.id]?.shouldFocusTerminal = true + } } // MARK: - Worktree History Back/Forward. @@ -5914,6 +5944,9 @@ struct RepositoriesFeatureTests { $0.applyPostReduceCacheRecomputes(.selectedWorktreeSlice) } await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.sidebarItems[id: wt1.id].focusTerminalRequested) { + $0.sidebarItems[id: wt1.id]?.shouldFocusTerminal = true + } } @Test func worktreeHistoryForwardPopsNextAndPushesCurrentToBack() async { @@ -5936,6 +5969,9 @@ struct RepositoriesFeatureTests { $0.applyPostReduceCacheRecomputes(.selectedWorktreeSlice) } await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.sidebarItems[id: wt2.id].focusTerminalRequested) { + $0.sidebarItems[id: wt2.id]?.shouldFocusTerminal = true + } } @Test func worktreeHistoryBackWithEmptyStackIsNoOp() async { @@ -5987,6 +6023,9 @@ struct RepositoriesFeatureTests { $0.applyPostReduceCacheRecomputes(.selectedWorktreeSlice) } await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.sidebarItems[id: wt1.id].focusTerminalRequested) { + $0.sidebarItems[id: wt1.id]?.shouldFocusTerminal = true + } } @Test func worktreeHistoryBackWithOnlyStaleEntriesIsNoOp() async { From 96392760980db735c188a221eca5c34e02d87890 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Mon, 1 Jun 2026 03:51:40 +0200 Subject: [PATCH 05/56] Run surfaces under zmx via a Ghostty command-wrapper so shells keep full integration (#368) Surfaces used to be launched by handing Ghostty `command = "zmx attach "`, 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 ` 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. --- AGENTS.md | 2 +- patches/ghostty-command-wrapper.patch | 149 ++++++++++++++++++ scripts/build-ghostty.sh | 86 ++++++++++ supacode/Clients/Zmx/ZmxClient.swift | 40 +++-- .../Models/WorktreeTerminalState.swift | 51 ++++-- .../Ghostty/GhosttyRuntime.swift | 8 +- .../Ghostty/GhosttySurfaceView.swift | 35 +++- .../GhosttyRuntimeBundledOverridesTests.swift | 12 +- supacodeTests/WorktreeEnvironmentTests.swift | 8 +- .../WorktreeTerminalManagerReaperTests.swift | 2 - supacodeTests/ZmxClientTests.swift | 66 +++++++- 11 files changed, 403 insertions(+), 56 deletions(-) create mode 100644 patches/ghostty-command-wrapper.patch diff --git a/AGENTS.md b/AGENTS.md index ec09b2092..ba8ae5d72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,5 +181,5 @@ Reducer ← .repositories(.worktreeInfoEvent(Event)) ← AsyncStream ## Submodules -- `ThirdParty/ghostty` (`https://github.com/ghostty-org/ghostty`): Source dependency used to build `Frameworks/GhosttyKit.xcframework` and terminal resources. +- `ThirdParty/ghostty` (`https://github.com/ghostty-org/ghostty`): Source dependency used to build `Frameworks/GhosttyKit.xcframework` and terminal resources. The pin tracks upstream; local changes live as out-of-tree patches in `patches/*.patch`, applied to the working tree by `scripts/build-ghostty.sh` before `zig build` and reverted on exit (the pin is never moved, no fork). On a ghostty bump a patch may stop applying and the build fails loudly: refresh the patch, and prefer upstreaming it to retire the carry cost. Run one ghostty build at a time (the apply/revert shares the submodule working tree). - `Resources/git-wt` (`https://github.com/khoi/git-wt.git`): Bundled `wt` CLI used by Supacode Git worktree flows at runtime. diff --git a/patches/ghostty-command-wrapper.patch b/patches/ghostty-command-wrapper.patch new file mode 100644 index 000000000..1a499f87d --- /dev/null +++ b/patches/ghostty-command-wrapper.patch @@ -0,0 +1,149 @@ +diff --git a/include/ghostty.h b/include/ghostty.h +index 3c4002abc..362fb232d 100644 +--- a/include/ghostty.h ++++ b/include/ghostty.h +@@ -455,6 +455,9 @@ typedef struct { + const char* initial_input; + bool wait_after_command; + ghostty_surface_context_e context; ++ const char* const* command_wrapper; ++ size_t command_wrapper_count; ++ bool disable_shell_integration; + } ghostty_surface_config_s; + + typedef struct { +diff --git a/src/Surface.zig b/src/Surface.zig +index c8055cfee..def042ba0 100644 +--- a/src/Surface.zig ++++ b/src/Surface.zig +@@ -635,6 +635,7 @@ pub fn init( + // Initialize our IO backend + var io_exec = try termio.Exec.init(alloc, .{ + .command = command, ++ .command_wrapper = config.@"command-wrapper", + .env = env, + .env_override = config.env, + .shell_integration = config.@"shell-integration", +diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig +index 0d5a4f8da..ba385db02 100644 +--- a/src/apprt/embedded.zig ++++ b/src/apprt/embedded.zig +@@ -460,6 +460,19 @@ pub const Surface = struct { + + /// Context for the new surface + context: apprt.surface.NewSurfaceContext = .window, ++ ++ /// A wrapper command (argv) prepended to the final resolved command. ++ /// See the `command-wrapper` config docs. Each element is a NUL ++ /// terminated argument; `command_wrapper_count` is the element count. ++ /// When null/empty, no wrapper is applied. ++ command_wrapper: ?[*]const [*:0]const u8 = null, ++ command_wrapper_count: usize = 0, ++ ++ /// Force `shell-integration = none` for this surface only, regardless of ++ /// the global config. For surfaces that run a self-managing command ++ /// (e.g. a script runner that emits its own OSC sequences) and must not ++ /// have Ghostty's integration injected into the shell. ++ disable_shell_integration: bool = false, + }; + + pub fn init(self: *Surface, app: *App, opts: Options) !void { +@@ -534,6 +547,25 @@ pub const Surface = struct { + } + } + ++ // If we have a command wrapper from the options then we set it. This ++ // is always a direct (already-tokenized) command so paths with spaces ++ // survive without shell re-splitting. ++ if (opts.command_wrapper) |c_wrapper| { ++ if (opts.command_wrapper_count > 0) { ++ const alloc = config.arenaAlloc(); ++ const argv = try alloc.alloc([:0]const u8, opts.command_wrapper_count); ++ for (c_wrapper[0..opts.command_wrapper_count], 0..) |arg, i| { ++ argv[i] = try alloc.dupeZ(u8, std.mem.sliceTo(arg, 0)); ++ } ++ config.@"command-wrapper" = .{ .direct = argv }; ++ } ++ } ++ ++ // Opt this surface out of shell integration regardless of global config. ++ if (opts.disable_shell_integration) { ++ config.@"shell-integration" = .none; ++ } ++ + // Apply any environment variables that were requested. + if (opts.env_var_count > 0) { + const alloc = config.arenaAlloc(); +diff --git a/src/config/Config.zig b/src/config/Config.zig +index 13f78eea6..fb13433e5 100644 +--- a/src/config/Config.zig ++++ b/src/config/Config.zig +@@ -1197,6 +1197,22 @@ command: ?Command = null, + /// manually. + @"initial-command": ?Command = null, + ++/// A wrapper command that is prepended to the final command Ghostty would ++/// otherwise execute, after shell resolution, shell integration, and (on ++/// macOS) the `login(1)` wrapping have all been applied. The wrapper's ++/// arguments are placed before the resolved argv, so the resolved command ++/// runs as a child of the wrapper. ++/// ++/// This exists so an embedder can run the shell under a supervisor such as a ++/// session-persistence multiplexer while keeping Ghostty's normal shell ++/// resolution and shell integration fully intact. Without this, an embedder ++/// would have to replace `command` with the wrapper, which loses the user's ++/// configured `command`, shell detection, and integration. ++/// ++/// Specified like `command`, e.g. `direct:zmx attach my-session`. Use the ++/// `direct:` prefix to avoid a `/bin/sh -c` roundtrip for the wrapper. ++@"command-wrapper": ?Command = null, ++ + /// Controls when command finished notifications are sent. There are + /// three options: + /// +diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig +index 0f35b5787..06aff655f 100644 +--- a/src/termio/Exec.zig ++++ b/src/termio/Exec.zig +@@ -559,6 +559,7 @@ pub const ThreadData = struct { + + pub const Config = struct { + command: ?configpkg.Command = null, ++ command_wrapper: ?configpkg.Command = null, + env: EnvMap, + env_override: configpkg.RepeatableStringMap = .{}, + shell_integration: configpkg.Config.ShellIntegration = .detect, +@@ -815,7 +816,7 @@ const Subprocess = struct { + } + + // Build our args list +- const args: []const [:0]const u8 = execCommand( ++ const base_args: []const [:0]const u8 = execCommand( + alloc, + shell_command, + internal_os.passwd, +@@ -840,6 +841,24 @@ const Subprocess = struct { + error.SystemError => return err, + }; + ++ // If a command wrapper is configured, prepend its arguments to the ++ // resolved command so the resolved argv (including the macOS login(1) ++ // wrapping and any shell integration) runs as a child of the wrapper. ++ const args: []const [:0]const u8 = if (cfg.command_wrapper) |wrapper| wrap: { ++ var list: std.ArrayList([:0]const u8) = try .initCapacity( ++ alloc, ++ base_args.len + 4, ++ ); ++ defer list.deinit(alloc); ++ ++ var it = try wrapper.argIterator(alloc); ++ defer it.deinit(); ++ while (it.next()) |arg| try list.append(alloc, try alloc.dupeZ(u8, arg)); ++ ++ try list.appendSlice(alloc, base_args); ++ break :wrap try list.toOwnedSlice(alloc); ++ } else base_args; ++ + // We have to copy the cwd because there is no guarantee that + // pointers in full_config remain valid. + const cwd: ?[:0]u8 = if (cfg.working_directory) |cwd| diff --git a/scripts/build-ghostty.sh b/scripts/build-ghostty.sh index d667ad476..e89ce033a 100755 --- a/scripts/build-ghostty.sh +++ b/scripts/build-ghostty.sh @@ -16,6 +16,9 @@ ghostty_legacy_share_path="${ghostty_legacy_prefix_path}/share" xcframework_path="${ghostty_build_root}/GhosttyKit.xcframework" ghostty_resources_path="${ghostty_build_root}/share/ghostty" ghostty_terminfo_path="${ghostty_build_root}/share/terminfo" +# Out-of-tree patches applied to the pinned ghostty submodule at build time. +# The submodule pointer stays on upstream; we never fork or commit into it. +ghostty_patches_dir="${srcroot}/patches" print_fingerprint() { ( @@ -56,8 +59,91 @@ ensure_ghostty_checkout() { fi } +# Apply our out-of-tree patches to the submodule working tree. Idempotent: a +# patch that already applies in reverse is treated as present. Fails loudly if +# a patch no longer applies (e.g. after an upstream bump) so it's never silently +# skipped. The submodule's committed SHA is untouched; `revert_ghostty_patches` +# restores a pristine working tree on exit. +# Repo-relative paths a patch touches. Parses the patch itself, not the tree, so +# it works even when the working tree is dirty. +ghostty_patch_files() { + git -C "${ghostty_dir}" apply --numstat "$1" 2>/dev/null | awk '{ print $3 }' +} + +# Reset just the files a patch touches back to the pinned SHA. Reads the file +# list line by line (bash 3.2 compatible) so a path with spaces stays intact. +reset_ghostty_patch_files() { + local f + local files=() + while IFS= read -r f; do + [ -n "${f}" ] && files+=("${f}") + done < <(ghostty_patch_files "$1") + [ "${#files[@]}" -eq 0 ] || git -C "${ghostty_dir}" checkout -- "${files[@]}" 2>/dev/null || true +} + +apply_ghostty_patches() { + [ -d "${ghostty_patches_dir}" ] || return 0 + local patch + for patch in "${ghostty_patches_dir}"/*.patch; do + [ -e "${patch}" ] || continue + if git -C "${ghostty_dir}" apply --reverse --check "${patch}" 2>/dev/null; then + continue # already fully applied + fi + if ! git -C "${ghostty_dir}" apply --check "${patch}" 2>/dev/null; then + # Neither pristine nor cleanly applied: most likely a prior build was killed + # (SIGKILL / power loss) mid-apply and left the patched files dirty. Reset + # just those files to the pinned SHA and retry before blaming an upstream bump. + reset_ghostty_patch_files "${patch}" + if ! git -C "${ghostty_dir}" apply --check "${patch}" 2>/dev/null; then + echo "error: ${patch} does not apply cleanly to ${ghostty_submodule_path}." >&2 + echo " The submodule may have been bumped (refresh the patch), or a" >&2 + echo " previous build left it dirty: git -C ${ghostty_submodule_path} checkout . && retry." >&2 + exit 1 + fi + fi + git -C "${ghostty_dir}" apply "${patch}" + done +} + +revert_ghostty_patches() { + [ -d "${ghostty_patches_dir}" ] || return 0 + local patch + for patch in "${ghostty_patches_dir}"/*.patch; do + [ -e "${patch}" ] || continue + # Prefer a clean reverse-apply; fall back to resetting just the patched files. + # The fallback also guards against `set -e` aborting the trap mid-revert if the + # reverse-apply fails (e.g. a partially-applied tree). + if git -C "${ghostty_dir}" apply --reverse --check "${patch}" 2>/dev/null; then + git -C "${ghostty_dir}" apply --reverse "${patch}" 2>/dev/null || reset_ghostty_patch_files "${patch}" + else + reset_ghostty_patch_files "${patch}" + fi + done +} + ensure_ghostty_checkout +# Patch the pinned submodule in place for this build only, restoring it on exit +# so `git status` stays clean and the pin is never disturbed. Applied before the +# fingerprint so patched source is reflected in the rebuild trigger. +# +# Revert on signals too (not just EXIT): a cancelled Xcode build or Ctrl-C sends +# SIGINT/SIGTERM, which would otherwise skip the EXIT trap and leave the tree +# patched (dirty status + poisoned fingerprint). On signal we revert, clear the +# traps to avoid a double revert, and exit with the conventional 128+signal code. +revert_and_signal_exit() { + revert_ghostty_patches + trap - EXIT INT TERM + case "$1" in + TERM) exit 143 ;; + *) exit 130 ;; + esac +} +trap revert_ghostty_patches EXIT +trap 'revert_and_signal_exit INT' INT +trap 'revert_and_signal_exit TERM' TERM +apply_ghostty_patches + if [ "${1:-}" = "--print-fingerprint" ]; then print_fingerprint exit 0 diff --git a/supacode/Clients/Zmx/ZmxClient.swift b/supacode/Clients/Zmx/ZmxClient.swift index 2c1384637..198a4bb8e 100644 --- a/supacode/Clients/Zmx/ZmxClient.swift +++ b/supacode/Clients/Zmx/ZmxClient.swift @@ -23,9 +23,6 @@ struct ZmxClient: Sendable { /// Use for kill paths against sessions persisted from earlier launches: probe /// bypass only means "don't wrap a new session", not "don't kill an old one". var isBundled: @Sendable () -> Bool - /// Wrap a surface command in `zmx attach `. Pure; no side effects. - /// Returns nil when zmx is unbundled, so callers fall through to the raw `command`. - var wrapCommand: @Sendable (_ sessionID: String, _ userCommand: String?) -> String? /// Tear down a session. No-op on missing. Bounded by a 5-second timeout so a /// stuck daemon can't hold the close path indefinitely. var killSession: @Sendable (_ sessionID: String) async -> Void @@ -192,14 +189,6 @@ extension ZmxClient { return ZmxClient( executableURL: resolveExecutable, isBundled: { bundledExecutable() != nil }, - wrapCommand: { sessionID, userCommand in - guard let executable = resolveExecutable() else { return nil } - return ZmxAttach.buildCommand( - executablePath: executable.path(percentEncoded: false), - sessionID: sessionID, - userCommand: userCommand - ) - }, killSession: { sessionID in _ = await runZmx(["kill", sessionID]) }, @@ -215,7 +204,6 @@ extension ZmxClient { nonisolated static let noop = ZmxClient( executableURL: { nil }, isBundled: { false }, - wrapCommand: { _, _ in nil }, killSession: { _ in }, listSessionsWithClients: { [] } ) @@ -349,6 +337,34 @@ nonisolated enum ZmxAttach { return "\(attach) /bin/sh -c \(shellQuote(command))" } + /// Argv that launches an interactive surface under zmx, passed to Ghostty as a + /// `command-wrapper` (prepended to the resolved shell argv). Each element is a + /// separate arg, so no shell quoting is needed even when the path has spaces. + static func buildWrapperArgv(executablePath: String, sessionID: String) -> [String] { + [executablePath, "attach", sessionID] + } + + /// Resolves how a surface launches under zmx, given the budget-gated executable + /// path (nil when zmx is unbundled or over budget). Interactive surfaces + /// (`command == nil`) keep a nil command and get an argv `command-wrapper`, so + /// Ghostty resolves + integrates the real shell and zmx wraps the result. + /// Explicit commands (scripts) get a `/bin/sh -c` wrapped command string and no + /// wrapper. A nil `executablePath` falls through to the raw command with no zmx. + static func resolveLaunch( + executablePath: String?, + sessionID: String, + command: String? + ) -> (command: String?, commandWrapper: [String]) { + // A blank command is "no command" (interactive); normalize so an empty + // string can't slip into the script path and launch a bare shell uninteg. + let command = command.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 } + guard let executablePath else { return (command, []) } + if command == nil { + return (nil, buildWrapperArgv(executablePath: executablePath, sessionID: sessionID)) + } + return (buildCommand(executablePath: executablePath, sessionID: sessionID, userCommand: command), []) + } + static func shellQuote(_ value: String) -> String { let escaped = value.replacing("'", with: "'\\''") return "'\(escaped)'" diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 58af7eb5b..957d99fee 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -1343,7 +1343,7 @@ final class WorktreeTerminalState { let surfaceID = resolvedID terminalStateLogger.info("createSurface: resolved=\(surfaceID)") let inherited = inheritedSurfaceConfig(fromSurfaceId: inheritingFromSurfaceId, context: context) - let (resolvedCommand, resolvedInitialInput) = resolveZmxWrapping( + let launch = resolveLaunch( surfaceID: surfaceID, command: command, initialInput: initialInput, @@ -1353,9 +1353,13 @@ final class WorktreeTerminalState { id: surfaceID, runtime: runtime, workingDirectory: workingDirectoryOverride ?? inherited.workingDirectory ?? worktree.workingDirectory, - command: resolvedCommand, - initialInput: resolvedInitialInput, + command: launch.command, + initialInput: launch.initialInput, environmentVariables: surfaceEnvironment(tabId: tabId, surfaceID: surfaceID), + commandWrapper: launch.commandWrapper, + // Blocking-script runners (bypassZmx) emit their own OSC 133/7 and must + // not get Ghostty's shell integration injected into the host shell. + disableShellIntegration: bypassZmx, fontSize: inherited.fontSize, context: context ) @@ -1435,24 +1439,41 @@ final class WorktreeTerminalState { } } - /// Wraps the surface command in `zmx attach ` so the underlying shell - /// survives app quit. `initialInput` is always passed through; zmx itself is - /// authoritative for attach-vs-create, so we never gate setup-script firing on - /// a stale snapshot of daemon state. - private func resolveZmxWrapping( + struct ResolvedLaunch { + var command: String? + var initialInput: String? + var commandWrapper: [String] + } + + /// Routes a surface through zmx so the underlying shell survives app quit. + /// + /// Interactive surfaces (no explicit `command`) keep `command` nil and inject + /// `zmx attach ` as a Ghostty `command-wrapper`, so Ghostty resolves and + /// integrates the user's real shell exactly as it would without zmx, with zmx + /// wrapping the whole resolved (login + integrated) argv. + /// + /// Explicit commands (scripts) instead wrap the command string itself, since + /// they don't want shell resolution / integration. `initialInput` is always + /// passed through; zmx is authoritative for attach-vs-create. + private func resolveLaunch( surfaceID: UUID, command: String?, initialInput: String?, bypassZmx: Bool - ) -> (command: String?, initialInput: String?) { + ) -> ResolvedLaunch { if bypassZmx { - return (command, initialInput) - } - let sessionID = ZmxSessionID.make(surfaceID: surfaceID) - guard let wrapped = zmxClient.wrapCommand(sessionID, command) else { - return (command, initialInput) + return ResolvedLaunch(command: command, initialInput: initialInput, commandWrapper: []) } - return (wrapped, initialInput) + let resolved = ZmxAttach.resolveLaunch( + executablePath: zmxClient.executableURL()?.path(percentEncoded: false), + sessionID: ZmxSessionID.make(surfaceID: surfaceID), + command: command + ) + return ResolvedLaunch( + command: resolved.command, + initialInput: initialInput, + commandWrapper: resolved.commandWrapper + ) } private struct InheritedSurfaceConfig: Equatable { diff --git a/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift b/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift index 5f0e670d0..f86e29d67 100644 --- a/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift +++ b/supacode/Infrastructure/Ghostty/GhosttyRuntime.swift @@ -542,14 +542,14 @@ final class GhosttyRuntime { /// the window's tint is the only visual layer; the user's intended value is /// captured separately in `loadConfig` for window-level use. /// - /// `shell-integration = none` short-circuits Ghostty's `setupBash` call - /// before it can prepend `--posix` to the surface command (#356); zmx - /// rejects `--posix` as an unknown arg and the surface fails to launch. + /// Shell integration is intentionally left untouched (no `shell-integration` + /// override): surfaces run the real shell with zmx injected as a Ghostty + /// `command-wrapper`, so Ghostty resolves and integrates the shell exactly as + /// it would without zmx, honoring the user's `command` / `shell-integration`. internal static let bundledOverridesString = """ window-padding-x = 14 window-padding-y = 12,0 background-opacity = 0 - shell-integration = none """ private static func loadBundledOverrides(into config: ghostty_config_t) { diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 46e7d42ef..3b763abda 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -77,6 +77,13 @@ final class GhosttySurfaceView: NSView, Identifiable { private let commandCString: UnsafeMutablePointer? private let initialInputCString: UnsafeMutablePointer? private let environmentVariables: [String: String] + /// Argv prepended to Ghostty's resolved command (e.g. `zmx attach `), so + /// the real shell runs as a child of the wrapper. Empty means no wrapper. + private let commandWrapper: [String] + /// Forces `shell-integration = none` for this surface only. Used by + /// self-managing surfaces (blocking-script runners) that emit their own OSC + /// sequences and must not have Ghostty's integration injected. + private let disableShellIntegration: Bool private let fontSize: Float32 private let context: ghostty_surface_context_e private var trackingArea: NSTrackingArea? @@ -198,6 +205,8 @@ final class GhosttySurfaceView: NSView, Identifiable { command: String? = nil, initialInput: String? = nil, environmentVariables: [String: String] = [:], + commandWrapper: [String] = [], + disableShellIntegration: Bool = false, fontSize: Float32? = nil, context: ghostty_surface_context_e ) { @@ -207,6 +216,8 @@ final class GhosttySurfaceView: NSView, Identifiable { self.fontSize = fontSize ?? 0 self.context = context self.environmentVariables = environmentVariables + self.commandWrapper = commandWrapper + self.disableShellIntegration = disableShellIntegration if let workingDirectory { let path = Self.normalizedWorkingDirectoryPath( workingDirectory.path(percentEncoded: false) @@ -977,6 +988,7 @@ final class GhosttySurfaceView: NSView, Identifiable { config.command = commandCString.map { UnsafePointer($0) } config.initial_input = initialInputCString.map { UnsafePointer($0) } config.context = context + config.disable_shell_integration = disableShellIntegration // Ghostty copies env vars into its arena allocator, so // the C strings only need to live through this call. var envVars = environmentVariables.map { key, value in @@ -991,12 +1003,27 @@ final class GhosttySurfaceView: NSView, Identifiable { free(.init(mutating: envVar.value)) } } - envVars.withUnsafeMutableBufferPointer { buffer in - if let baseAddress = buffer.baseAddress { + // Wrapper argv C strings must also outlive the surface_new call. + var wrapperCStrings: [UnsafePointer?] = commandWrapper.map { arg in + UnsafePointer(arg.withCString { strdup($0)! }) + } + defer { + for ptr in wrapperCStrings { + free(UnsafeMutablePointer(mutating: ptr)) + } + } + envVars.withUnsafeMutableBufferPointer { envBuffer in + if let baseAddress = envBuffer.baseAddress, !envBuffer.isEmpty { config.env_vars = baseAddress - config.env_var_count = buffer.count + config.env_var_count = envBuffer.count + } + wrapperCStrings.withUnsafeBufferPointer { wrapperBuffer in + if let baseAddress = wrapperBuffer.baseAddress, !wrapperBuffer.isEmpty { + config.command_wrapper = baseAddress + config.command_wrapper_count = wrapperBuffer.count + } + surface = ghostty_surface_new(app, &config) } - surface = ghostty_surface_new(app, &config) } bridge.surface = surface lastOcclusion = nil diff --git a/supacodeTests/GhosttyRuntimeBundledOverridesTests.swift b/supacodeTests/GhosttyRuntimeBundledOverridesTests.swift index b0d6ac56a..825cec664 100644 --- a/supacodeTests/GhosttyRuntimeBundledOverridesTests.swift +++ b/supacodeTests/GhosttyRuntimeBundledOverridesTests.swift @@ -5,12 +5,12 @@ import Testing @MainActor struct GhosttyRuntimeBundledOverridesTests { - /// `shell-integration = none` is the actual #356 fix; it must stay in the - /// bundled overrides so Ghostty's `setupBash` is unreachable and `--posix` - /// can never be prepended to the surface command. A future refactor that - /// drops this line reintroduces the crash. - @Test func bundledOverridesDisableShellIntegration() { - #expect(GhosttyRuntime.bundledOverridesString.contains("shell-integration = none")) + /// Shell integration must NOT be disabled in the bundled overrides: surfaces + /// run the real shell with zmx injected as a `command-wrapper`, so Ghostty + /// integrates the shell exactly as without zmx. Forcing `none` here would + /// regress OSC 7 cwd reporting (the whole point of the wrapper approach). + @Test func bundledOverridesDoNotTouchShellIntegration() { + #expect(!GhosttyRuntime.bundledOverridesString.contains("shell-integration")) } /// Each line in the heredoc is parsed as a Ghostty `key = value` directive diff --git a/supacodeTests/WorktreeEnvironmentTests.swift b/supacodeTests/WorktreeEnvironmentTests.swift index 8c9b72f59..dbeeda04c 100644 --- a/supacodeTests/WorktreeEnvironmentTests.swift +++ b/supacodeTests/WorktreeEnvironmentTests.swift @@ -55,10 +55,10 @@ struct WorktreeEnvironmentTests { #expect(runnerScript.contains("\"$SUPACODE_SHELL_PATH\" -l \(quotedScriptPath)") == true) // The runner exec-tails after emitting OSC 133;D so the outer shell // stays blocked and no new prompt prints in the readonly tab. Both - // 133;C and 133;D matter: `shell-integration = none` is on globally - // (GhosttyRuntime.bundledOverridesString) so Ghostty's shell shim - // won't emit prompt markers; the runner must self-emit so the - // tab-bar progress / `onCommandFinished` path still fires. + // 133;C and 133;D matter: blocking-script surfaces launch with + // `disableShellIntegration` so Ghostty injects no integration / prompt + // markers into the host shell; the runner must self-emit so the tab-bar + // progress / `onCommandFinished` path still fires. #expect(runnerScript.contains("exec tail -f /dev/null") == true) #expect(runnerScript.contains("133;C") == true) #expect(runnerScript.contains("133;D") == true) diff --git a/supacodeTests/WorktreeTerminalManagerReaperTests.swift b/supacodeTests/WorktreeTerminalManagerReaperTests.swift index b4226e1e4..d6d1d2b6c 100644 --- a/supacodeTests/WorktreeTerminalManagerReaperTests.swift +++ b/supacodeTests/WorktreeTerminalManagerReaperTests.swift @@ -16,7 +16,6 @@ struct WorktreeTerminalManagerReaperTests { $0.zmxClient = ZmxClient( executableURL: { nil }, isBundled: { true }, - wrapCommand: { _, _ in nil }, killSession: { id in killed.withValue { $0.append(id) } }, listSessionsWithClients: { listing } ) @@ -76,7 +75,6 @@ struct WorktreeTerminalManagerReaperTests { $0.zmxClient = ZmxClient( executableURL: { nil }, isBundled: { true }, - wrapCommand: { _, _ in nil }, killSession: { id in killed.withValue { $0.append(id) } }, listSessionsWithClients: { listing.value } ) diff --git a/supacodeTests/ZmxClientTests.swift b/supacodeTests/ZmxClientTests.swift index 48e720a99..29515c7de 100644 --- a/supacodeTests/ZmxClientTests.swift +++ b/supacodeTests/ZmxClientTests.swift @@ -78,6 +78,26 @@ struct ZmxAttachTests { ) #expect(cmd == "'/zmx' attach s /bin/sh -c 'echo '\\''hi'\\'''") } + + /// The interactive wrapper argv is exactly `[exe, "attach", sessionID]`, each a + /// separate element (no shell string), so Ghostty execs it directly. + @Test func buildWrapperArgvIsAttachWithSession() { + let argv = ZmxAttach.buildWrapperArgv(executablePath: "/path/to/zmx", sessionID: "supa-abc") + #expect(argv == ["/path/to/zmx", "attach", "supa-abc"]) + } + + /// A path with spaces stays a single argv element (no quoting / re-splitting), + /// since Ghostty receives a tokenized `direct:` command. + @Test func buildWrapperArgvKeepsSpacedPathAsOneElement() { + let argv = ZmxAttach.buildWrapperArgv( + executablePath: "/Applications/Supacode Dev.app/Contents/Resources/zmx/zmx", + sessionID: "supa-1" + ) + #expect(argv.count == 3) + #expect(argv[0] == "/Applications/Supacode Dev.app/Contents/Resources/zmx/zmx") + #expect(argv[1] == "attach") + #expect(argv[2] == "supa-1") + } } @MainActor @@ -129,6 +149,44 @@ struct ZmxSocketBudgetTests { } } +@MainActor +struct ZmxResolveLaunchTests { + /// Interactive surface (nil command): nil command + `[exe, attach, id]` wrapper, + /// so Ghostty resolves + integrates the real shell and zmx wraps the result. + @Test func interactiveSurfaceGetsWrapperAndNilCommand() { + let resolved = ZmxAttach.resolveLaunch(executablePath: "/zmx", sessionID: "supa-abc", command: nil) + #expect(resolved.command == nil) + #expect(resolved.commandWrapper == ["/zmx", "attach", "supa-abc"]) + } + + /// Explicit command (script): wrapped command string and an EMPTY wrapper, so a + /// script is never double-wrapped via both `command` and a `command-wrapper`. + @Test func explicitCommandIsStringWrappedWithNoWrapper() { + let resolved = ZmxAttach.resolveLaunch(executablePath: "/zmx", sessionID: "s", command: "echo hi") + #expect(resolved.command == "'/zmx' attach s /bin/sh -c 'echo hi'") + #expect(resolved.commandWrapper.isEmpty) + } + + /// A blank/whitespace command is normalized to interactive, so it can't slip + /// into the script path and launch a bare uninteg. shell. + @Test func blankCommandIsTreatedAsInteractive() { + let resolved = ZmxAttach.resolveLaunch(executablePath: "/zmx", sessionID: "s", command: " \n") + #expect(resolved.command == nil) + #expect(resolved.commandWrapper == ["/zmx", "attach", "s"]) + } + + /// zmx unbundled / over budget (nil executable): raw command, no zmx at all. + @Test func nilExecutableFallsThroughToRawCommand() { + let interactive = ZmxAttach.resolveLaunch(executablePath: nil, sessionID: "s", command: nil) + #expect(interactive.command == nil) + #expect(interactive.commandWrapper.isEmpty) + + let script = ZmxAttach.resolveLaunch(executablePath: nil, sessionID: "s", command: "echo hi") + #expect(script.command == "echo hi") + #expect(script.commandWrapper.isEmpty) + } +} + @MainActor struct ZmxSessionListParserTests { @Test func parsesClientsZero() { @@ -183,14 +241,6 @@ struct ZmxSessionListParserTests { @MainActor struct ZmxClientNoopTests { - /// The default test impl is a no-op so existing TestStore tests are unaffected - /// by the wrap path. wrapCommand returning nil means callers fall through to - /// the raw command unchanged. - @Test func noopWrapCommandReturnsNil() { - let cmd = ZmxClient.noop.wrapCommand("any-id", "echo hi") - #expect(cmd == nil) - } - @Test func noopExecutableURLReturnsNil() { #expect(ZmxClient.noop.executableURL() == nil) } From 7e3ddb9857b4e70719440846a231c08b981ab7d4 Mon Sep 17 00:00:00 2001 From: Richard Lee <14349+dlackty@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:54:41 +0800 Subject: [PATCH 06/56] Fix Kiro CLI version detection to use kiro-cli instead of kiro (#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. --- .../BusinessLogic/KiroSettingsInstaller.swift | 26 +++++++++---------- .../KiroSettingsInstallerTests.swift | 17 ++++++------ 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/SupacodeSettingsShared/BusinessLogic/KiroSettingsInstaller.swift b/SupacodeSettingsShared/BusinessLogic/KiroSettingsInstaller.swift index de5ff8d17..7b97456b0 100644 --- a/SupacodeSettingsShared/BusinessLogic/KiroSettingsInstaller.swift +++ b/SupacodeSettingsShared/BusinessLogic/KiroSettingsInstaller.swift @@ -14,9 +14,9 @@ nonisolated struct KiroSettingsInstaller { /// defaults in `ensureDefaultAgentConfig` may no longer match upstream and would /// silently override a legitimately different config — gate on this prefix to /// fail loudly instead. - static let supportedVersionPrefix = "1." + static let supportedVersionPrefix = "2." - /// Maximum time to wait on `kiro --version`. A misconfigured login shell (e.g. + /// Maximum time to wait on `kiro-cli --version`. A misconfigured login shell (e.g. /// an rc file blocking on stdin) can hang the child indefinitely; when that /// happens we terminate the process so `waitUntilExit` cannot pin the /// cooperative pool thread. @@ -28,19 +28,19 @@ nonisolated struct KiroSettingsInstaller { init( homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, - fileManager: FileManager = .default + fileManager: FileManager = .default, ) { self.init( homeDirectoryURL: homeDirectoryURL, fileManager: fileManager, - runKiroVersionCommand: Self.runKiroVersionCommand + runKiroVersionCommand: Self.runKiroVersionCommand, ) } init( homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, fileManager: FileManager = .default, - runKiroVersionCommand: @escaping @Sendable () async throws -> CommandResult + runKiroVersionCommand: @escaping @Sendable () async throws -> CommandResult, ) { self.homeDirectoryURL = homeDirectoryURL self.fileManager = fileManager @@ -68,14 +68,14 @@ nonisolated struct KiroSettingsInstaller { try await ensureDefaultAgentConfig() try fileInstaller.install( settingsURL: settingsURL, - hookEntriesByEvent: try KiroHookSettings.hooksByEvent() + hookEntriesByEvent: try KiroHookSettings.hooksByEvent(), ) } func uninstallAllHooks() throws { try fileInstaller.uninstall( settingsURL: settingsURL, - hookEntriesByEvent: try KiroHookSettings.hooksByEvent() + hookEntriesByEvent: try KiroHookSettings.hooksByEvent(), ) } @@ -90,7 +90,7 @@ nonisolated struct KiroSettingsInstaller { try await validateSupportedKiroVersion() try fileManager.createDirectory( at: settingsURL.deletingLastPathComponent(), - withIntermediateDirectories: true + withIntermediateDirectories: true, ) let defaultConfig: [String: JSONValue] = [ "name": .string("kiro_default"), @@ -186,7 +186,7 @@ nonisolated struct KiroSettingsInstaller { static func runKiroVersionCommand() async throws -> CommandResult { let process = Process() process.executableURL = CodexSettingsInstaller.loginShellURL() - process.arguments = ["-l", "-c", "kiro --version"] + process.arguments = ["-l", "-c", "kiro-cli --version"] let outputPipe = Pipe() let errorPipe = Pipe() process.standardOutput = outputPipe @@ -197,7 +197,7 @@ nonisolated struct KiroSettingsInstaller { try? await Task.sleep(nanoseconds: versionCommandTimeoutSeconds * 1_000_000_000) if process.isRunning { kiroVersionLogger.warning( - "kiro --version exceeded \(versionCommandTimeoutSeconds)s; terminating.") + "kiro-cli --version exceeded \(versionCommandTimeoutSeconds)s; terminating.") process.terminate() } } @@ -217,7 +217,7 @@ nonisolated struct KiroSettingsInstaller { return .init( status: process.terminationStatus, standardOutput: standardOutput, - standardError: standardError.trimmingCharacters(in: .whitespacesAndNewlines) + standardError: standardError.trimmingCharacters(in: .whitespacesAndNewlines), ) } @@ -257,8 +257,8 @@ nonisolated struct KiroSettingsInstaller { invalidEventHooks: { KiroSettingsInstallerError.invalidEventHooks($0) }, invalidHooksObject: { KiroSettingsInstallerError.invalidHooksObject }, invalidJSON: { KiroSettingsInstallerError.invalidJSON($0) }, - invalidRootObject: { KiroSettingsInstallerError.invalidRootObject } - ) + invalidRootObject: { KiroSettingsInstallerError.invalidRootObject }, + ), ) } } diff --git a/supacodeTests/KiroSettingsInstallerTests.swift b/supacodeTests/KiroSettingsInstallerTests.swift index 7cdf9e51f..e7c3290c3 100644 --- a/supacodeTests/KiroSettingsInstallerTests.swift +++ b/supacodeTests/KiroSettingsInstallerTests.swift @@ -14,9 +14,9 @@ struct KiroSettingsInstallerTests { private func makeInstaller( homeURL: URL, - versionOutput: String = "kiro 1.0.0", + versionOutput: String = "kiro-cli 2.0.0", versionStatus: Int32 = 0, - versionError: Error? = nil + versionError: Error? = nil, ) -> KiroSettingsInstaller { KiroSettingsInstaller( homeDirectoryURL: homeURL, @@ -168,8 +168,8 @@ struct KiroSettingsInstallerTests { let homeURL = makeTempHomeURL() defer { try? fileManager.removeItem(at: homeURL) } - let installer = makeInstaller(homeURL: homeURL, versionOutput: "kiro 2.0.0") - await #expect(throws: KiroSettingsInstallerError.unsupportedKiroVersion("2.0.0")) { + let installer = makeInstaller(homeURL: homeURL, versionOutput: "kiro-cli 3.0.0") + await #expect(throws: KiroSettingsInstallerError.unsupportedKiroVersion("3.0.0")) { try await installer.installAllHooks() } } @@ -206,9 +206,9 @@ struct KiroSettingsInstallerTests { let homeURL = makeTempHomeURL() defer { try? fileManager.removeItem(at: homeURL) } - let installer = makeInstaller(homeURL: homeURL, versionOutput: "kiro 10.0.0") + let installer = makeInstaller(homeURL: homeURL, versionOutput: "kiro-cli 20.0.0") await #expect( - throws: KiroSettingsInstallerError.unsupportedKiroVersion("10.0.0") + throws: KiroSettingsInstallerError.unsupportedKiroVersion("20.0.0") ) { try await installer.installAllHooks() } @@ -219,14 +219,14 @@ struct KiroSettingsInstallerTests { defer { try? fileManager.removeItem(at: homeURL) } // Login shells can print their own banners to stderr; parsing must prefer - // stdout so a stderr "Python 3.11" banner does not reject valid Kiro 1.x. + // stdout so a stderr "Python 3.11" banner does not reject valid Kiro 2.x. let installer = KiroSettingsInstaller( homeDirectoryURL: homeURL, fileManager: fileManager, runKiroVersionCommand: { .init( status: 0, - standardOutput: "kiro 1.2.3\n", + standardOutput: "kiro-cli 2.5.0\n", standardError: "Python 3.11.0 (banner)", ) }, @@ -256,6 +256,7 @@ struct KiroSettingsInstallerTests { } @Test func extractVersionHandlesCommonFormats() { + #expect(KiroSettingsInstaller.extractVersion(from: "kiro-cli 2.5.0") == "2.5.0") #expect(KiroSettingsInstaller.extractVersion(from: "kiro 1.2.3") == "1.2.3") #expect(KiroSettingsInstaller.extractVersion(from: "Kiro CLI v1.0.0 (build abcd)") == "1.0.0") #expect(KiroSettingsInstaller.extractVersion(from: "1.4") == "1.4") From b1ecdf3d5ab367ce139da5ffe5ea3e5dba015f21 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Tue, 2 Jun 2026 18:32:09 +0200 Subject: [PATCH 07/56] Cap and coalesce terminal event streams to reduce memory growth (#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. --- .../WorktreeInfoWatcherManager.swift | 35 +++- .../WorktreeTerminalManager.swift | 148 +++++++++++++- supacodeTests/AgentPresence+TestHelpers.swift | 22 +- .../WorktreeInfoWatcherManagerTests.swift | 23 +++ .../WorktreeTerminalManagerTests.swift | 188 ++++++++++++++++++ 5 files changed, 401 insertions(+), 15 deletions(-) diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index 2613c2506..944d7fdb4 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -1,9 +1,18 @@ import Darwin import Dispatch import Foundation +import SupacodeSettingsShared + +private let watcherLogger = SupaLogger("WorktreeInfoWatcher") @MainActor final class WorktreeInfoWatcherManager { + /// Hard cap on the live event buffer. These events are refresh signals (not + /// coalescable state), so the stream is capped rather than deduped: a wedged + /// consumer drops the oldest signals instead of letting the buffer grow + /// without bound. + static let eventBufferCap = 2048 + private struct HeadWatcher { let headURL: URL let source: DispatchSourceFileSystemObject @@ -80,7 +89,10 @@ final class WorktreeInfoWatcherManager { func eventStream() -> AsyncStream { eventContinuation?.finish() - let (stream, continuation) = AsyncStream.makeStream(of: WorktreeInfoWatcherClient.Event.self) + let (stream, continuation) = AsyncStream.makeStream( + of: WorktreeInfoWatcherClient.Event.self, + bufferingPolicy: .bufferingNewest(Self.eventBufferCap) + ) eventContinuation = continuation return stream } @@ -441,6 +453,9 @@ final class WorktreeInfoWatcherManager { do { try await sleep(request.interval) } catch { + if !(error is CancellationError) { + watcherLogger.error("Worktree refresh loop for \(worktreeID) ended: \(error).") + } break } guard !Task.isCancelled else { @@ -460,7 +475,23 @@ final class WorktreeInfoWatcherManager { { return } - eventContinuation?.yield(event) + let result = eventContinuation?.yield(event) + if case .dropped(let shed)? = result { + let cap = Self.eventBufferCap + watcherLogger.error( + "Worktree info event buffer full (cap \(cap)); shed oldest refresh signal: \(Self.label(for: shed)).") + } + } + + /// Compact identity for a backpressure-drop log. Strips the pull-request + /// refresh's worktree-id list to a count so a drop storm can't flood the log; + /// the single-id signals carry small payloads and describe themselves. + private static func label(for event: WorktreeInfoWatcherClient.Event) -> String { + switch event { + case .repositoryPullRequestRefresh(let rootURL, let worktreeIDs): + "repositoryPullRequestRefresh(\(rootURL.lastPathComponent), \(worktreeIDs.count) worktrees)" + default: String(describing: event) + } } private func cancelPullRequestSelectionCooldown(for repositoryRootURL: URL) { diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index a92ea2e7f..5dc2df244 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -24,6 +24,19 @@ final class WorktreeTerminalManager { private var lastEmittedProjections: [Worktree.ID: WorktreeRowProjection] = [:] private var eventContinuation: AsyncStream.Continuation? private var pendingEvents: [TerminalClient.Event] = [] + /// Latest-wins events deduped by identity: drops a value equal to the + /// immediately-previous one per key (a burst of distinct values still passes), + /// so per-tab projection / progress / task-status / focus repeats don't flood + /// the stream. Cleared on resubscribe and purged on tab / worktree teardown. + private var lastEmittedCoalescable: [CoalesceKey: TerminalClient.Event] = [:] + /// Hard cap on the live event buffer. Source coalescing keeps it near-empty in + /// practice; this backstops a wedged consumer so memory stays bounded instead + /// of growing without limit. + static let eventBufferCap = 2048 + /// Cap for lifecycle events buffered before the first subscriber attaches. + /// Coalescable state collapses per key and doesn't count, so this only bounds + /// one-shot events; the sole consumer attaches at launch, well under the cap. + static let pendingEventCap = 1024 @ObservationIgnored private var pendingIdleHookEvents: [IdleDebounceKey: Task] = [:] @ObservationIgnored @@ -57,6 +70,50 @@ final class WorktreeTerminalManager { let agent: SkillAgent } + /// Identity for a latest-wins event. Two events sharing a key carry the same + /// piece of state, so an identical repeat is a no-op and is dropped. + private enum CoalesceKey: Hashable { + case worktreeProjection(Worktree.ID) + case tabProjection(TerminalTabID) + case tabProgress(TerminalTabID) + case taskStatus(Worktree.ID) + case focus(Worktree.ID) + case notificationIndicator + case hasAnySurface + } + + /// Non-nil for state events that are safe to coalesce by identity. Lifecycle / + /// one-shot events (tab create / close / remove, notifications, script + /// completion, command-palette, teardown) return nil and are never dropped. + private static func coalesceKey(for event: TerminalClient.Event) -> CoalesceKey? { + switch event { + case .worktreeProjectionChanged(let worktreeID, _): .worktreeProjection(worktreeID) + case .tabProjectionChanged(_, let projection): .tabProjection(projection.tabID) + case .tabProgressDisplayChanged(_, let tabID, _): .tabProgress(tabID) + case .taskStatusChanged(let worktreeID, _): .taskStatus(worktreeID) + case .focusChanged(let worktreeID, _): .focus(worktreeID) + case .notificationIndicatorChanged: .notificationIndicator + case .terminalHasAnySurfaceChanged: .hasAnySurface + default: nil + } + } + + /// Compact identity for a backpressure-drop log. Strips the payload-heavy + /// cases (projections / notification bodies) to their key ids so a drop storm + /// can't flood the log; the rest carry small payloads and describe themselves. + private static func label(for event: TerminalClient.Event) -> String { + switch event { + case .worktreeProjectionChanged(let worktreeID, _): "worktreeProjectionChanged(\(worktreeID))" + case .tabProjectionChanged(let worktreeID, let projection): + "tabProjectionChanged(\(worktreeID), tab: \(projection.tabID))" + case .tabProgressDisplayChanged(let worktreeID, let tabID, _): + "tabProgressDisplayChanged(\(worktreeID), tab: \(tabID))" + case .notificationReceived(let worktreeID, let surfaceID, _, _): + "notificationReceived(\(worktreeID), surface: \(surfaceID))" + default: String(describing: event) + } + } + var selectedWorktreeID: Worktree.ID? var saveLayoutSnapshot: ((Worktree.ID, TerminalLayoutSnapshot?) -> Void)? var loadLayoutSnapshot: ((Worktree.ID) -> TerminalLayoutSnapshot?)? @@ -164,6 +221,13 @@ final class WorktreeTerminalManager { emit(.agentHookEventReceived(event)) } + #if DEBUG + /// Count of idle-hook debounce tasks still scheduled (test-only). A clock-awoken + /// resume removes its key only after it emits, so a non-zero count means a + /// pending idle event has not yet landed in the stream. + var pendingIdleHookCountForTesting: Int { pendingIdleHookEvents.count } + #endif + // MARK: - CLI queries. func listTabs(worktreeID: String) -> [[String: String]]? { @@ -338,17 +402,27 @@ final class WorktreeTerminalManager { func eventStream() -> AsyncStream { eventContinuation?.finish() - let (stream, continuation) = AsyncStream.makeStream(of: TerminalClient.Event.self) + let (stream, continuation) = AsyncStream.makeStream( + of: TerminalClient.Event.self, + bufferingPolicy: .bufferingNewest(Self.eventBufferCap) + ) eventContinuation = continuation lastNotificationIndicatorCount = nil + // Reset dedup state before replaying so the replay re-seeds both caches; a + // fresh subscriber then has the latest value recorded for every key. + lastEmittedProjections.removeAll() + lastEmittedCoalescable.removeAll() if !pendingEvents.isEmpty { let bufferedEvents = pendingEvents pendingEvents.removeAll() for event in bufferedEvents { + // Re-emitted fresh below, so drop the buffered copy. if case .notificationIndicatorChanged = event { continue } - continuation.yield(event) + // Route through emit() (not a raw yield) so a coalescable buffered event + // seeds lastEmittedCoalescable and the first identical live event dedups. + emit(event) } } emitNotificationIndicatorCountIfNeeded() @@ -358,19 +432,16 @@ final class WorktreeTerminalManager { // Seed each worktree's projection so rows attached after the stream start // pick up the current snapshot (otherwise they'd stay default until the // next mutation). - lastEmittedProjections.removeAll() for id in states.keys { emitProjection(for: id) } // Replay per-tab projections / stripe-progress displays for the same reason: // a new subscriber needs the existing `terminalTabs[id:]` rows seeded so // tab-bar leaves don't render empty until the next per-tab mutation. for (worktreeID, state) in states { for projection in state.currentTabProjections() { - continuation.yield(.tabProjectionChanged(worktreeID: worktreeID, projection)) + emit(.tabProjectionChanged(worktreeID: worktreeID, projection)) } for (tabID, display) in state.currentTabProgressDisplays() { - continuation.yield( - .tabProgressDisplayChanged(worktreeID: worktreeID, tabID: tabID, display: display) - ) + emit(.tabProgressDisplayChanged(worktreeID: worktreeID, tabID: tabID, display: display)) } } return stream @@ -529,7 +600,7 @@ final class WorktreeTerminalManager { } states = states.filter { worktreeIDs.contains($0.key) } cancelPendingIdleHooks(forSurfaceIDs: prunedSurfaceIDs) - for (id, _) in removed { lastEmittedProjections.removeValue(forKey: id) } + for (id, _) in removed { invalidateCaches(forPrunedWorktree: id) } emitNotificationIndicatorCountIfNeeded() emitHasAnyTerminalSurfaceIfNeeded() killZmxSessions(prunedSessionIDs) @@ -821,10 +892,69 @@ final class WorktreeTerminalManager { private func emit(_ event: TerminalClient.Event) { guard let eventContinuation else { + bufferPendingEvent(event) + return + } + if let key = Self.coalesceKey(for: event) { + guard lastEmittedCoalescable[key] != event else { return } + lastEmittedCoalescable[key] = event + } + // During prune this fires first and clears the coalesce keys; invalidateCaches + // then runs second only to clear the worktree-keyed lastEmittedProjections. + for key in Self.invalidatedCoalesceKeys(by: event) { + lastEmittedCoalescable.removeValue(forKey: key) + } + let result = eventContinuation.yield(event) + if case .dropped(let shed) = result { + terminalLogger.error( + "Terminal event buffer full (cap \(Self.eventBufferCap)); shed oldest buffered event: \(Self.label(for: shed))." + ) + } + } + + /// Buffers an event emitted before a subscriber attaches. Coalescable state + /// keeps only its latest value per key; lifecycle events accumulate up to a + /// cap, dropping the oldest so the pre-subscription buffer stays bounded. + private func bufferPendingEvent(_ event: TerminalClient.Event) { + if let key = Self.coalesceKey(for: event) { + pendingEvents.removeAll { Self.coalesceKey(for: $0) == key } pendingEvents.append(event) return } - eventContinuation.yield(event) + // Mirror the live-path teardown purge so a buffered projection for a + // torn-down id can't replay ahead of its teardown on resubscribe. + let invalidated = Set(Self.invalidatedCoalesceKeys(by: event)) + if !invalidated.isEmpty { + pendingEvents.removeAll { Self.coalesceKey(for: $0).map(invalidated.contains) ?? false } + } + if pendingEvents.count >= Self.pendingEventCap { + let dropped = pendingEvents.removeFirst() + terminalLogger.error( + "Pending terminal event buffer full (cap \(Self.pendingEventCap)); dropped oldest: \(Self.label(for: dropped))." + ) + } + pendingEvents.append(event) + } + + /// Coalesce keys a teardown event invalidates. A coalesced value for a removed + /// tab / worktree must not linger: a same-id reuse (snapshot restore reuses + /// persisted tab UUIDs) would otherwise be wrongly deduped and dropped. + private static func invalidatedCoalesceKeys(by event: TerminalClient.Event) -> [CoalesceKey] { + switch event { + case .tabRemoved(_, let tabID): [.tabProjection(tabID), .tabProgress(tabID)] + case .worktreeStateTornDown(let worktreeID): + [.worktreeProjection(worktreeID), .taskStatus(worktreeID), .focus(worktreeID)] + default: [] + } + } + + /// Clears the worktree-keyed lastEmittedProjections during prune; emit's purge has + /// already cleared the coalesce keys, which this re-clears as a guard against drift. + private func invalidateCaches(forPrunedWorktree id: Worktree.ID) { + lastEmittedProjections.removeValue(forKey: id) + for key in Self.invalidatedCoalesceKeys(by: .worktreeStateTornDown(worktreeID: id)) { + lastEmittedCoalescable.removeValue(forKey: key) + } } private func emitNotificationIndicatorCountIfNeeded() { diff --git a/supacodeTests/AgentPresence+TestHelpers.swift b/supacodeTests/AgentPresence+TestHelpers.swift index 07c64b92b..d2f87f4e8 100644 --- a/supacodeTests/AgentPresence+TestHelpers.swift +++ b/supacodeTests/AgentPresence+TestHelpers.swift @@ -14,6 +14,7 @@ final class PresenceTestHarness { private let reducer = AgentPresenceFeature() private var stream: AsyncStream? private var consumeTask: Task? + private weak var manager: WorktreeTerminalManager? /// Bumped each time the consume task reduces a stream event. private var processedCount = 0 /// Bumped each time the consume task is about to wait for the next event, i.e. @@ -49,15 +50,28 @@ final class PresenceTestHarness { for _ in 0..<64 { let parksBefore = parkCount let processedBefore = processedCount - await Task.megaYield(count: 10_000) - let parkedAgain = parkCount > parksBefore - let quiet = processedCount == processedBefore - settled = parkedAgain && quiet ? settled + 1 : 0 + // Each megaYield spawns `count` detached tasks. A clock-awoken producer + // (e.g. an idle debounce resuming after `clock.advance`) needs enough + // yields within a single pass to resume, emit, and let the consumer + // reduce before we sample quiescence; too few and a busy suite schedules + // the resume after the sample, so we conclude "idle" before the idle + // event lands. 1000 keeps the per-call cost two orders below the legacy + // 10_000 while staying robust under contention. + await Task.megaYield(count: 1000) + // Quiescent when the consumer is parked, nothing processed this pass, and + // no idle-hook debounce is still scheduled. The last clause closes the + // race where `clock.advance` returned but the awoken idle task hasn't yet + // emitted: its key lingers in the manager until it does, so a pending + // count keeps draining instead of concluding "idle" too early. + let consumerIdle = parkCount == parksBefore && processedCount == processedBefore + let noPendingIdle = (manager?.pendingIdleHookCountForTesting ?? 0) == 0 + settled = consumerIdle && noPendingIdle ? settled + 1 : 0 if settled >= 2 { return } } } func attach(to manager: WorktreeTerminalManager) { + self.manager = manager let stream = manager.eventStream() self.stream = stream consumeTask?.cancel() diff --git a/supacodeTests/WorktreeInfoWatcherManagerTests.swift b/supacodeTests/WorktreeInfoWatcherManagerTests.swift index be886ed38..c68e33fb8 100644 --- a/supacodeTests/WorktreeInfoWatcherManagerTests.swift +++ b/supacodeTests/WorktreeInfoWatcherManagerTests.swift @@ -146,6 +146,29 @@ struct WorktreeInfoWatcherManagerTests { await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) } + + @Test func capsTheEventBufferUnderBackpressure() async throws { + let tempWorktree = try makeTempWorktree() + let manager = WorktreeInfoWatcherManager() + manager.handleCommand(.setPullRequestTrackingEnabled(false)) + let stream = manager.eventStream() + + // Each setWorktrees re-emits an immediate filesChanged for the worktree; + // with nothing draining, the buffer must cap rather than grow unbounded. + let overflow = WorktreeInfoWatcherManager.eventBufferCap + 50 + for _ in 0.. Worktree { let name = URL(fileURLWithPath: id).lastPathComponent return Worktree( From 8c3fd031a1676c438bfb2d130768adf1f2e298dc Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 3 Jun 2026 12:08:40 +0200 Subject: [PATCH 08/56] bump v0.10.2 --- Configurations/Project.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Configurations/Project.xcconfig b/Configurations/Project.xcconfig index 673e9d9af..11f05837b 100644 --- a/Configurations/Project.xcconfig +++ b/Configurations/Project.xcconfig @@ -1,5 +1,5 @@ -MARKETING_VERSION = 0.10.1 -CURRENT_PROJECT_VERSION = 141 +MARKETING_VERSION = 0.10.2 +CURRENT_PROJECT_VERSION = 142 POSTHOG_API_KEY = POSTHOG_HOST = SENTRY_DSN = From b1b65bf7201e72995cb247687cb39811cce839ea Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 3 Jun 2026 19:52:25 +0200 Subject: [PATCH 09/56] Tolerate login-shell noise in GitHub CLI JSON output (#378) * 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 #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. --- supacode/Clients/Github/GithubCLIClient.swift | 213 ++++++++++--- supacodeTests/GithubCLIClientTests.swift | 291 ++++++++++++++++++ 2 files changed, 466 insertions(+), 38 deletions(-) diff --git a/supacode/Clients/Github/GithubCLIClient.swift b/supacode/Clients/Github/GithubCLIClient.swift index b32878b92..e761b68bd 100644 --- a/supacode/Clients/Github/GithubCLIClient.swift +++ b/supacode/Clients/Github/GithubCLIClient.swift @@ -8,7 +8,7 @@ struct GithubAuthStatus: Equatable, Sendable { let host: String } -private struct GithubAuthStatusResponse: Sendable { +struct GithubAuthStatusResponse: Sendable { let hosts: [String: [GithubAuthAccount]] struct GithubAuthAccount: Sendable { @@ -41,6 +41,148 @@ extension GithubAuthStatusResponse.GithubAuthAccount: Decodable { } } +// A login shell sources `.zprofile` / `.zlogin` before `gh` runs, so a banner or version-manager +// line can prepend to captured stdout and corrupt the JSON. +enum GithubCLIOutput { + // No JSON found at all: the likely cause is shell startup output polluting stdout. + nonisolated static let noPayloadMessage = + "Could not read GitHub CLI output. Your shell startup files may be printing extra output to stdout." + + // Valid JSON was found but failed to decode: the likely cause is an incompatible gh version. + nonisolated static let undecodableMessage = + "Could not parse GitHub CLI output. The installed GitHub CLI version may be incompatible." + + nonisolated private static let logger = SupaLogger("GithubCLI") + + // Every balanced top-level JSON value ({...} or [...]) in `output`, in source order. An opener that + // never balances (a stray brace from shell noise) is skipped, so leading noise cannot swallow a real + // payload that follows. + nonisolated static func balancedJSONSpans(in output: String) -> [Substring] { + var spans: [Substring] = [] + var searchStart = output.startIndex + while let start = output[searchStart...].firstIndex(where: { $0 == "{" || $0 == "[" }) { + let opener = output[start] + let closer: Character = opener == "{" ? "}" : "]" + var depth = 0 + var inString = false + var escaped = false + var matchedEnd: String.Index? + var index = start + scan: while index < output.endIndex { + let character = output[index] + switch character { + case _ where inString && escaped: + escaped = false + case "\\" where inString: + escaped = true + case "\"" where inString: + inString = false + case _ where inString: + break + case "\"": + inString = true + case opener: + depth += 1 + case closer: + depth -= 1 + guard depth == 0 else { break } + matchedEnd = output.index(after: index) + break scan + default: + break + } + index = output.index(after: index) + } + guard let end = matchedEnd else { + // Unbalanced opener (stray bracket in noise): skip it and keep scanning. + searchStart = output.index(after: start) + continue + } + spans.append(output[start..( + _ type: T.Type, + from output: String, + decoder: JSONDecoder = JSONDecoder() + ) throws -> T { + guard let value = try decodeIfPresent(T.self, from: output, decoder: decoder) else { + logDecodeFailure(output) + throw GithubCLIError.commandFailed(noPayloadMessage) + } + return value + } + + // Returns nil when `output` carries no JSON payload at all (e.g. `gh run list` with no runs), and + // throws only when a payload is present but cannot be decoded. gh prints its JSON after any leading + // shell noise, so the last decodable span wins. + nonisolated static func decodeIfPresent( + _ type: T.Type, + from output: String, + decoder: JSONDecoder = JSONDecoder() + ) throws -> T? { + let spans = balancedJSONSpans(in: output) + guard !spans.isEmpty else { + return nil + } + var lastDecodingError: Error? + var sawValidJSON = false + for span in spans.reversed() { + let data = Data(span.utf8) + do { + return try decoder.decode(T.self, from: data) + } catch { + lastDecodingError = error + if (try? JSONSerialization.jsonObject(with: data)) != nil { + sawValidJSON = true + } + } + } + let detail = lastDecodingError.map { "\($0)" } ?? "unknown" + logger.error("Failed to decode GitHub CLI JSON output: \(snapshot(of: output)) error=\(detail)") + // Valid JSON that failed to decode points at gh schema drift; otherwise the brackets were noise. + throw GithubCLIError.commandFailed(sawValidJSON ? undecodableMessage : noPayloadMessage) + } + + // Logs a length-capped snapshot of the offending output so the user can retrieve it from the log + // stream without flooding it with a large banner. + nonisolated static func logDecodeFailure(_ output: String) { + logger.error("Failed to read GitHub CLI JSON output: \(snapshot(of: output))") + } + + // Caps the logged output so a large banner does not flood the log stream. + nonisolated private static func snapshot(of output: String) -> String { + let limit = 500 + let prefix = output.prefix(limit) + return prefix.endIndex == output.endIndex ? String(prefix) : "\(prefix)… (truncated)" + } +} + +enum GithubAuthStatusParsing { + // `hosts` is an unordered dictionary, so prefer github.com and otherwise sort the keys: the result + // is deterministic even when more than one host has an active account. + nonisolated static func activeAccount( + in response: GithubAuthStatusResponse + ) -> (host: String, login: String)? { + let orderedHosts = response.hosts.keys.sorted { lhs, rhs in + if lhs == "github.com" { return true } + if rhs == "github.com" { return false } + return lhs < rhs + } + for host in orderedHosts { + if let active = response.hosts[host]?.first(where: { $0.active }) { + return (host, active.login) + } + } + return nil + } +} + struct GithubCLIClient: Sendable { var defaultBranch: @Sendable (URL) async throws -> String var latestRun: @Sendable (URL, String) async throws -> GithubWorkflowRun? @@ -193,10 +335,9 @@ nonisolated private func defaultBranchFetcher( arguments: ["repo", "view", "--json", "defaultBranchRef"], repoRoot: repoRoot ) - let data = Data(output.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - let response = try decoder.decode(GithubRepoViewResponse.self, from: data) + let response = try GithubCLIOutput.decode(GithubRepoViewResponse.self, from: output, decoder: decoder) return response.defaultBranchRef.name } } @@ -221,14 +362,11 @@ nonisolated private func latestRunFetcher( ], repoRoot: repoRoot ) - if output.isEmpty { - return nil - } - let data = Data(output.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - let runs = try decoder.decode([GithubWorkflowRun].self, from: data) - return runs.first + // nil payload means no runs; a present-but-undecodable payload still throws. + let runs = try GithubCLIOutput.decodeIfPresent([GithubWorkflowRun].self, from: output, decoder: decoder) + return runs?.first } } @@ -247,33 +385,36 @@ nonisolated private func resolveRemoteInfoFetcher( resolver: GithubCLIExecutableResolver ) -> @Sendable (URL) async -> GithubRemoteInfo? { { repoRoot in + let output: String do { - let output = try await runGh( + output = try await runGh( shell: shell, resolver: resolver, arguments: ["repo", "view", "--json", "owner,name,url"], repoRoot: repoRoot ) - let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - let response = try JSONDecoder().decode( + } catch { + return nil + } + // nil contract: decodeIfPresent tolerates leading shell noise and logs a present-but-undecodable + // payload before throwing, which `try?` collapses back to nil. + guard + let response = try? GithubCLIOutput.decodeIfPresent( GithubRepoViewRemoteInfoResponse.self, - from: Data(trimmed.utf8) - ) - let host = hostFromRepoViewURL(response.url) ?? "github.com" - guard !response.owner.login.isEmpty, !response.name.isEmpty else { - return nil - } - return GithubRemoteInfo( - host: host, - owner: response.owner.login, - repo: response.name + from: output ) - } catch { + else { + return nil + } + let host = hostFromRepoViewURL(response.url) ?? "github.com" + guard !response.owner.login.isEmpty, !response.name.isEmpty else { return nil } + return GithubRemoteInfo( + host: host, + owner: response.owner.login, + repo: response.name + ) } } @@ -460,14 +601,11 @@ nonisolated private func authStatusFetcher( arguments: ["auth", "status", "--json", "hosts"], repoRoot: nil ) - let data = Data(output.utf8) - let response = try decodeAuthStatusResponse(from: data) - guard let (host, accounts) = response.hosts.first, - let activeAccount = accounts.first(where: { $0.active }) - else { + let response = try GithubCLIOutput.decode(GithubAuthStatusResponse.self, from: output) + guard let active = GithubAuthStatusParsing.activeAccount(in: response) else { return nil } - return GithubAuthStatus(username: activeAccount.login, host: host) + return GithubAuthStatus(username: active.login, host: active.host) } } @@ -617,10 +755,13 @@ nonisolated private func fetchPullRequestsChunk( return (chunkIndex, [:]) } - let data = Data(result.output.utf8) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - let response = try decoder.decode(GithubGraphQLPullRequestResponse.self, from: data) + let response = try GithubCLIOutput.decode( + GithubGraphQLPullRequestResponse.self, + from: result.output, + decoder: decoder + ) let prsByBranch = response.pullRequestsByBranch( aliasMap: result.aliasMap, owner: request.owner, @@ -792,7 +933,3 @@ nonisolated private func shouldRetryGhExecution(after error: Error) -> Bool { } return false } - -nonisolated private func decodeAuthStatusResponse(from data: Data) throws -> GithubAuthStatusResponse { - try JSONDecoder().decode(GithubAuthStatusResponse.self, from: data) -} diff --git a/supacodeTests/GithubCLIClientTests.swift b/supacodeTests/GithubCLIClientTests.swift index de5973792..051448fa2 100644 --- a/supacodeTests/GithubCLIClientTests.swift +++ b/supacodeTests/GithubCLIClientTests.swift @@ -543,6 +543,297 @@ struct GithubCLIClientTests { #expect(snapshot.ghCallCount == 2) #expect(snapshot.loginCallCount == 2) } + + // MARK: - Noisy login-shell output (#377) + + // A ShellClient that resolves `gh` via `which` and returns `stdout` for every gh invocation. + static func ghShell(stdout: String) -> ShellClient { + ShellClient( + run: { executableURL, _, _ in + executableURL.lastPathComponent == "which" + ? ShellOutput(stdout: "/usr/bin/gh", stderr: "", exitCode: 0) + : ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { executableURL, _, _, _ in + guard executableURL.lastPathComponent == "gh" else { + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + return ShellOutput(stdout: stdout, stderr: "", exitCode: 0) + } + ) + } + + @Test func balancedSpansReturnsCleanObjectUnchanged() { + #expect(GithubCLIOutput.balancedJSONSpans(in: #"{"a":1}"#).map(String.init) == [#"{"a":1}"#]) + } + + @Test func balancedSpansStripBannerBeforeObject() { + let noisy = "fastfetch banner line\nanother line\n" + #"{"hosts":{}}"# + #expect(GithubCLIOutput.balancedJSONSpans(in: noisy).map(String.init) == [#"{"hosts":{}}"#]) + } + + @Test func balancedSpansStripBannerBeforeArray() { + let noisy = "nvm: now using node v20\n" + #"[{"x":1},{"y":2}]"# + #expect(GithubCLIOutput.balancedJSONSpans(in: noisy).map(String.init) == [#"[{"x":1},{"y":2}]"#]) + } + + @Test func balancedSpansDropTrailingNoise() { + #expect(GithubCLIOutput.balancedJSONSpans(in: "{\"a\":1}\nshell goodbye").map(String.init) == [#"{"a":1}"#]) + } + + @Test func balancedSpansIgnoreBracesInsideStrings() { + #expect(GithubCLIOutput.balancedJSONSpans(in: #"{"a":"}]"}"#).map(String.init) == [#"{"a":"}]"}"#]) + } + + @Test func balancedSpansIgnoreEscapedQuoteInsideString() { + // The escaped quote keeps the scanner in-string, so the brace before it is not a premature close. + #expect(GithubCLIOutput.balancedJSONSpans(in: #"{"a":"}\""}"#).map(String.init) == [#"{"a":"}\""}"#]) + } + + @Test func balancedSpansReturnBothTopLevelObjectsInOrder() { + let input = #"{"a":1}"# + "\n" + #"{"b":2}"# + #expect(GithubCLIOutput.balancedJSONSpans(in: input).map(String.init) == [#"{"a":1}"#, #"{"b":2}"#]) + } + + @Test func balancedSpansSkipUnbalancedOpener() { + // A stray brace from a verbose login shell must not swallow the real trailing payload. + let noisy = "chpwd () {\n" + #"[{"databaseId":7}]"# + #expect(GithubCLIOutput.balancedJSONSpans(in: noisy).map(String.init) == [#"[{"databaseId":7}]"#]) + } + + @Test func balancedSpansReturnEmptyForPureNoise() { + #expect(GithubCLIOutput.balancedJSONSpans(in: "no json here at all").isEmpty) + } + + @Test func balancedSpansReturnEmptyForEmptyInput() { + #expect(GithubCLIOutput.balancedJSONSpans(in: " \n ").isEmpty) + } + + @Test func balancedSpansReturnEmptyForUnterminatedPayload() { + #expect(GithubCLIOutput.balancedJSONSpans(in: #"{"a":1"#).isEmpty) + } + + private struct SingleKeyPayload: Decodable, Equatable { + let value: Int + } + + @Test func decodeSkipsBracketBearingBannerBeforeObject() throws { + let noisy = "warn [deprecated]\n" + #"{"value":1}"# + let decoded = try GithubCLIOutput.decode(SingleKeyPayload.self, from: noisy) + #expect(decoded == SingleKeyPayload(value: 1)) + } + + @Test func decodeSkipsVersionManagerBannerBeforeObject() throws { + let noisy = "[nvm] using node v20\n" + #"{"hosts":{}}"# + let decoded = try GithubCLIOutput.decode(GithubAuthStatusResponse.self, from: noisy) + #expect(decoded.hosts.isEmpty) + } + + @Test func activeAccountScansAllHostsForActiveAccount() { + let response = GithubAuthStatusResponse(hosts: [ + "ghe.acme.com": [.init(active: false, login: "work")], + "github.com": [.init(active: true, login: "sbertix")], + ]) + + let active = GithubAuthStatusParsing.activeAccount(in: response) + + #expect(active?.host == "github.com") + #expect(active?.login == "sbertix") + } + + @Test func activeAccountReturnsNilWhenNoAccountActive() { + let response = GithubAuthStatusResponse(hosts: [ + "github.com": [.init(active: false, login: "sbertix")] + ]) + + #expect(GithubAuthStatusParsing.activeAccount(in: response) == nil) + } + + @Test func activeAccountPrefersGithubComWhenMultipleHostsActive() { + let response = GithubAuthStatusResponse(hosts: [ + "ghe.acme.com": [.init(active: true, login: "work")], + "github.com": [.init(active: true, login: "sbertix")], + ]) + + let active = GithubAuthStatusParsing.activeAccount(in: response) + + #expect(active?.host == "github.com") + #expect(active?.login == "sbertix") + } + + @Test func activeAccountSortsHostsWhenGithubComAbsent() { + let response = GithubAuthStatusResponse(hosts: [ + "z.example.com": [.init(active: true, login: "zed")], + "a.example.com": [.init(active: true, login: "ann")], + ]) + + let active = GithubAuthStatusParsing.activeAccount(in: response) + + #expect(active?.host == "a.example.com") + #expect(active?.login == "ann") + } + + @Test func authStatusSucceedsDespiteBannerPollutedStdout() async throws { + let stdout = + "╭─ fastfetch ─╮\n│ os macOS │\n╰─────────────╯\n" + + #"{"hosts":{"github.com":[{"state":"success","active":true,"host":"github.com","login":"sbertix"}]}}"# + let client = GithubCLIClient.live(shell: Self.ghShell(stdout: stdout)) + + let status = try await client.authStatus() + + #expect(status == GithubAuthStatus(username: "sbertix", host: "github.com")) + } + + @Test func authStatusReportsActiveAccountOnNonFirstHost() async throws { + let stdout = #""" + {"hosts":{"ghe.acme.com":[{"active":false,"login":"work"}],"github.com":[{"active":true,"login":"sbertix"}]}} + """# + let client = GithubCLIClient.live(shell: Self.ghShell(stdout: stdout)) + + let status = try await client.authStatus() + + #expect(status == GithubAuthStatus(username: "sbertix", host: "github.com")) + } + + @Test func authStatusThrowsCommandFailedOnNonJsonOutput() async { + let client = GithubCLIClient.live(shell: Self.ghShell(stdout: "command not found: gh")) + + do { + _ = try await client.authStatus() + Issue.record("Expected authStatus to throw") + } catch GithubCLIError.commandFailed(let message) { + // No JSON payload -> the shell-pollution hypothesis, not a raw error. + #expect(message == GithubCLIOutput.noPayloadMessage) + } catch { + Issue.record("Unexpected error type: \(error.localizedDescription)") + } + } + + @Test func authStatusThrowsUndecodableMessageWhenPayloadParsesButSchemaDiffers() async { + let client = GithubCLIClient.live(shell: Self.ghShell(stdout: #"{"unexpected":true}"#)) + + do { + _ = try await client.authStatus() + Issue.record("Expected authStatus to throw") + } catch GithubCLIError.commandFailed(let message) { + // Found JSON but it failed to decode -> the version-incompatibility hypothesis. + #expect(message == GithubCLIOutput.undecodableMessage) + } catch { + Issue.record("Unexpected error type: \(error.localizedDescription)") + } + } + + @Test func resolveRemoteInfoSucceedsDespiteBannerPollutedStdout() async { + let stdout = + "loading shell profile…\n" + + #"{"name":"upstream-repo","owner":{"login":"upstream-org"},"# + + #""url":"https://github.com/upstream-org/upstream-repo"}"# + let client = GithubCLIClient.live(shell: Self.ghShell(stdout: stdout)) + + let info = await client.resolveRemoteInfo(URL(fileURLWithPath: "/tmp/repo")) + + #expect(info == GithubRemoteInfo(host: "github.com", owner: "upstream-org", repo: "upstream-repo")) + } + + @Test func defaultBranchSucceedsDespiteBannerPollutedStdout() async throws { + let stdout = "conda activate base\n" + #"{"defaultBranchRef":{"name":"main"}}"# + let client = GithubCLIClient.live(shell: Self.ghShell(stdout: stdout)) + + let branch = try await client.defaultBranch(URL(fileURLWithPath: "/tmp/repo")) + + #expect(branch == "main") + } + + @Test func defaultBranchThrowsCommandFailedOnNonJsonOutput() async { + let client = GithubCLIClient.live(shell: Self.ghShell(stdout: "not a repo")) + + do { + _ = try await client.defaultBranch(URL(fileURLWithPath: "/tmp/repo")) + Issue.record("Expected defaultBranch to throw") + } catch GithubCLIError.commandFailed { + // Expected. + } catch { + Issue.record("Unexpected error type: \(error.localizedDescription)") + } + } + + @Test func latestRunSucceedsDespiteBannerPollutedArray() async throws { + let stdout = + "pyenv: version 3.12\n" + + #"[{"databaseId":7,"workflowName":"CI","name":"CI","displayTitle":"Fix","# + + #""status":"completed","conclusion":"success","# + + #""createdAt":"2026-05-01T00:00:00Z","updatedAt":"2026-05-01T00:00:00Z"}]"# + let client = GithubCLIClient.live(shell: Self.ghShell(stdout: stdout)) + + let run = try await client.latestRun(URL(fileURLWithPath: "/tmp/repo"), "main") + + #expect(run?.databaseId == 7) + #expect(run?.conclusion == "success") + } + + @Test func latestRunReturnsNilForEmptyOutput() async throws { + let client = GithubCLIClient.live(shell: Self.ghShell(stdout: "")) + + let run = try await client.latestRun(URL(fileURLWithPath: "/tmp/repo"), "main") + + #expect(run == nil) + } + + @Test func latestRunReturnsNilForBannerWithNoRuns() async throws { + // Banner-only stdout with no JSON payload means no runs, not a parse failure. + let client = GithubCLIClient.live(shell: Self.ghShell(stdout: "shell banner with no json")) + + let run = try await client.latestRun(URL(fileURLWithPath: "/tmp/repo"), "main") + + #expect(run == nil) + } + + @Test func latestRunThrowsCommandFailedOnPresentButUndecodablePayload() async { + let client = GithubCLIClient.live(shell: Self.ghShell(stdout: #"[{"databaseId":"not-an-int"}]"#)) + + do { + _ = try await client.latestRun(URL(fileURLWithPath: "/tmp/repo"), "main") + Issue.record("Expected latestRun to throw on a present-but-undecodable payload") + } catch GithubCLIError.commandFailed { + // Expected: a JSON payload that fails to decode is a real parse failure. + } catch { + Issue.record("Unexpected error type: \(error.localizedDescription)") + } + } + + @Test func latestRunSkipsStrayBraceBannerBeforeRunArray() async throws { + // A stray unbalanced brace (e.g. a `set -x` trace) must not swallow the real run array. + let stdout = "+ chpwd () {\n" + #"[{"databaseId":11,"status":"completed"}]"# + let client = GithubCLIClient.live(shell: Self.ghShell(stdout: stdout)) + + let run = try await client.latestRun(URL(fileURLWithPath: "/tmp/repo"), "main") + + #expect(run?.databaseId == 11) + } + + @Test func latestRunPrefersRealArrayOverLeadingEmptyArrayBanner() async throws { + // A leading `[]` from shell noise must not shadow the real run list that follows. + let stdout = "[]\n" + #"[{"databaseId":9,"status":"completed","conclusion":"success"}]"# + let client = GithubCLIClient.live(shell: Self.ghShell(stdout: stdout)) + + let run = try await client.latestRun(URL(fileURLWithPath: "/tmp/repo"), "main") + + #expect(run?.databaseId == 9) + } + + @Test func authStatusReportsPollutionForBracketNoiseWithoutValidJson() async { + // Brackets that are not valid JSON (e.g. `[INFO]` log prefixes) are shell noise, not schema drift. + let client = GithubCLIClient.live(shell: Self.ghShell(stdout: "[INFO] starting up\n[WARN] no config")) + + do { + _ = try await client.authStatus() + Issue.record("Expected authStatus to throw") + } catch GithubCLIError.commandFailed(let message) { + #expect(message == GithubCLIOutput.noPayloadMessage) + } catch { + Issue.record("Unexpected error type: \(error.localizedDescription)") + } + } } nonisolated private func graphQLResponse(for arguments: [String]) -> String { From 1d888dbc30f564ed9e8ed26b55c13da961b0887f Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Sat, 6 Jun 2026 00:12:35 +0200 Subject: [PATCH 10/56] Cross-fade shortcut hints and drop the hold-to-reveal delay (#381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- supacode/App/CommandKeyObserver.swift | 19 ++----- .../BusinessLogic/SidebarStructure.swift | 2 +- .../Reducer/SidebarItemFeature.swift | 7 --- .../OpenWorktreeActionMenuLabelView.swift | 7 +-- .../Views/PullRequestStatusButton.swift | 20 +------ .../Repositories/Views/SidebarItemView.swift | 44 +++++++++------- .../Repositories/Views/SidebarItemsView.swift | 2 +- .../Views/WorktreeDetailView.swift | 52 ++++++------------- .../TabBar/TerminalTabBarMetrics.swift | 3 ++ .../TerminalTabBarTrailingAccessories.swift | 32 ++---------- .../TabBar/Views/TerminalTabLabelView.swift | 4 +- .../TabBar/Views/TerminalTabView.swift | 23 ++++---- supacode/Support/ShortcutHintView.swift | 12 ----- supacodeTests/SidebarItemFeatureTests.swift | 7 +-- 14 files changed, 68 insertions(+), 166 deletions(-) delete mode 100644 supacode/Support/ShortcutHintView.swift diff --git a/supacode/App/CommandKeyObserver.swift b/supacode/App/CommandKeyObserver.swift index d91518e22..074a35a23 100644 --- a/supacode/App/CommandKeyObserver.swift +++ b/supacode/App/CommandKeyObserver.swift @@ -1,23 +1,20 @@ import AppKit import SwiftUI +/// Tracks whether the user is currently holding ⌘ or ⌃ so the UI can surface shortcut hints. @MainActor @Observable final class CommandKeyObserver { - private static let holdDelay: Duration = .milliseconds(300) - var isPressed: Bool private var monitor: Any? private var didBecomeActiveObserver: NSObjectProtocol? private var didResignActiveObserver: NSObjectProtocol? - private var holdTask: Task? init() { isPressed = false monitor = nil didBecomeActiveObserver = nil didResignActiveObserver = nil - holdTask = nil configureObservers() } @@ -54,17 +51,7 @@ final class CommandKeyObserver { } private func handleCommandKeyChange(isDown: Bool) { - holdTask?.cancel() - holdTask = nil - - if isDown { - holdTask = Task { - try? await ContinuousClock().sleep(for: Self.holdDelay) - guard !Task.isCancelled else { return } - isPressed = true - } - } else { - isPressed = false - } + // Flip immediately; consumers fade the visual change in/out themselves. + isPressed = isDown } } diff --git a/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift b/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift index c530d8602..d0a10cde8 100644 --- a/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift +++ b/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift @@ -312,7 +312,7 @@ extension SidebarItemFeature.Action { case .pullRequestChanged: return .selectedWorktreeSlice case .diffStatsChanged, .pullRequestQueryStarted, - .shortcutHintChanged, .dragSessionChanged, + .dragSessionChanged, .focusTerminalRequested, .focusTerminalConsumed: return [] } diff --git a/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift b/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift index 2f91a5305..17e3400ab 100644 --- a/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift +++ b/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift @@ -109,7 +109,6 @@ struct SidebarItemFeature { var isTaskRunning: Bool { isProgressBusy || hasAgentActivity } var isDragging: Bool = false - var shortcutHint: String? /// One-shot focus token: set when a selection arrives with `focusTerminal: true`. var shouldFocusTerminal: Bool = false } @@ -123,7 +122,6 @@ struct SidebarItemFeature { case runningScriptStopped(id: UUID) case agentSnapshotChanged([AgentPresenceFeature.AgentInstance], hasActivity: Bool) case terminalProjectionChanged(WorktreeRowProjection) - case shortcutHintChanged(String?) case dragSessionChanged(isDragging: Bool) case focusTerminalRequested case focusTerminalConsumed @@ -192,11 +190,6 @@ struct SidebarItemFeature { if state.notifications != projection.notifications { state.notifications = projection.notifications } return .none - case .shortcutHintChanged(let hint): - guard state.shortcutHint != hint else { return .none } - state.shortcutHint = hint - return .none - case .dragSessionChanged(let isDragging): guard state.isDragging != isDragging else { return .none } state.isDragging = isDragging diff --git a/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift b/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift index f3c143f3d..da8d2f8c8 100644 --- a/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift +++ b/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift @@ -4,7 +4,6 @@ import SwiftUI struct OpenWorktreeActionMenuLabelView: View { let action: OpenWorktreeAction - let shortcutHint: String? private func resizedIcon(_ image: NSImage, size: CGSize) -> NSImage { let newImage = NSImage(size: size) @@ -21,11 +20,7 @@ struct OpenWorktreeActionMenuLabelView: View { var body: some View { Label { - if let shortcutHint { - Text(shortcutHint) - } else { - Text(action.labelTitle) - } + Text(action.labelTitle) } icon: { if let icon = action.menuIcon { switch icon { diff --git a/supacode/Features/Repositories/Views/PullRequestStatusButton.swift b/supacode/Features/Repositories/Views/PullRequestStatusButton.swift index 801f4997d..68337411d 100644 --- a/supacode/Features/Repositories/Views/PullRequestStatusButton.swift +++ b/supacode/Features/Repositories/Views/PullRequestStatusButton.swift @@ -1,16 +1,7 @@ -import Sharing -import SupacodeSettingsShared import SwiftUI struct PullRequestStatusButton: View { let model: PullRequestStatusModel - @Environment(CommandKeyObserver.self) private var commandKeyObserver - @Shared(.settingsFile) private var settingsFile - - private var openPRDisplay: String { - let effective = AppShortcuts.openPullRequest.effective(from: settingsFile.global.shortcutOverrides) - return effective?.display ?? "" - } var body: some View { PullRequestChecksPopoverButton(pullRequest: model.pullRequest) { @@ -26,16 +17,9 @@ struct PullRequestStatusButton: View { PullRequestChecksRingView(breakdown: breakdown) } if let detailText = model.detailText { - Text( - commandKeyObserver.isPressed - ? "Open on GitHub \(openPRDisplay)" : detailText - ) - .lineLimit(1) - } else if commandKeyObserver.isPressed { - Text("Open on GitHub \(openPRDisplay)") + Text(detailText) .lineLimit(1) - } - if model.detailText == nil, !commandKeyObserver.isPressed { + } else { Text(model.title) .lineLimit(1) } diff --git a/supacode/Features/Repositories/Views/SidebarItemView.swift b/supacode/Features/Repositories/Views/SidebarItemView.swift index bce3607e2..bda751d37 100644 --- a/supacode/Features/Repositories/Views/SidebarItemView.swift +++ b/supacode/Features/Repositories/Views/SidebarItemView.swift @@ -454,25 +454,23 @@ private struct TrailingView: View { let showsPullRequestInfo: Bool var body: some View { - if let shortcutHint { - Text(shortcutHint) - .font(.caption) - .foregroundStyle(.secondary) - } else { - let display = WorktreePullRequestDisplay( - worktreeName: store.branchName, - pullRequest: showsPullRequestInfo ? store.pullRequest : nil, - ) - let prText = display.pullRequestBadgeStyle?.text - let agents = store.agents - let scriptColors = store.runningScripts.map(\.tint) - let showsNotificationIndicator = store.hasUnseenNotifications - let notifications = Array(store.notifications) - let added = store.addedLines ?? 0 - let removed = store.removedLines ?? 0 - let hasStats = added + removed > 0 - let hasStatus = !scriptColors.isEmpty || showsNotificationIndicator - + let hasHint = shortcutHint != nil + let display = WorktreePullRequestDisplay( + worktreeName: store.branchName, + pullRequest: showsPullRequestInfo ? store.pullRequest : nil, + ) + let prText = display.pullRequestBadgeStyle?.text + let agents = store.agents + let scriptColors = store.runningScripts.map(\.tint) + let showsNotificationIndicator = store.hasUnseenNotifications + let notifications = Array(store.notifications) + let added = store.addedLines ?? 0 + let removed = store.removedLines ?? 0 + let hasStats = added + removed > 0 + let hasStatus = !scriptColors.isEmpty || showsNotificationIndicator + + // Cross-fade via opacity so flipping ⌘ doesn't snap the row. + ZStack(alignment: .trailing) { HStack(spacing: 6) { if hasStats { DiffStatsContent(addedLines: added, removedLines: removed) @@ -497,7 +495,15 @@ private struct TrailingView: View { } // Title takes the squeeze under narrow widths, not the counters. .fixedSize(horizontal: true, vertical: false) + .opacity(hasHint ? 0 : 1) + .allowsHitTesting(!hasHint) + + Text(shortcutHint ?? "") + .font(.caption) + .foregroundStyle(.secondary) + .opacity(hasHint ? 1 : 0) } + .animation(.easeInOut(duration: TerminalTabBarMetrics.fadeAnimationDuration), value: hasHint) } } diff --git a/supacode/Features/Repositories/Views/SidebarItemsView.swift b/supacode/Features/Repositories/Views/SidebarItemsView.swift index 50e09ba7b..7cde49d58 100644 --- a/supacode/Features/Repositories/Views/SidebarItemsView.swift +++ b/supacode/Features/Repositories/Views/SidebarItemsView.swift @@ -784,7 +784,7 @@ private struct SidebarItemContextMenu: View { Button { store.send(.contextMenuOpenWorktree(worktree.id, action)) } label: { - OpenWorktreeActionMenuLabelView(action: action, shortcutHint: nil) + OpenWorktreeActionMenuLabelView(action: action) } .help("Open with \(action.labelTitle)") } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 015524983..a033eb65a 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -13,7 +13,6 @@ import SwiftUI struct WorktreeDetailView: View { @Bindable var store: StoreOf let terminalManager: WorktreeTerminalManager - @Environment(CommandKeyObserver.self) private var commandKeyObserver @Shared(.appStorage("worktreeRowHideSubtitleOnMatch")) private var hideSubtitleOnMatch = true @Shared(.settingsFile) private var settingsFile: SettingsFile @@ -88,7 +87,6 @@ struct WorktreeDetailView: View { kind: toolbarKind(for: selectedWorktree, selectedRow: selectedRow), statusToast: repositories.statusToast, openActionSelection: openActionSelection, - showExtras: commandKeyObserver.isPressed, repoScripts: repoScripts, globalScripts: globalScripts, runningScriptIDs: runningScriptIDs, @@ -383,7 +381,6 @@ struct WorktreeDetailView: View { let kind: Kind let statusToast: RepositoriesFeature.StatusToast? let openActionSelection: OpenWorktreeAction - let showExtras: Bool let repoScripts: [ScriptDefinition] let globalScripts: [ScriptDefinition] let runningScriptIDs: Set @@ -483,10 +480,7 @@ struct WorktreeDetailView: View { ToolbarSpacer(.flexible) ToolbarItem { - openMenu( - openActionSelection: toolbarState.openActionSelection, - showExtras: toolbarState.showExtras - ) + openMenu(openActionSelection: toolbarState.openActionSelection) } ToolbarSpacer(.fixed) @@ -507,7 +501,7 @@ struct WorktreeDetailView: View { } @ViewBuilder - private func openMenu(openActionSelection: OpenWorktreeAction, showExtras: Bool) -> some View { + private func openMenu(openActionSelection: OpenWorktreeAction) -> some View { let availableActions = OpenWorktreeAction.availableCases.filter { $0 != .finder } let resolved = OpenWorktreeAction.availableSelection(openActionSelection) let primarySelection = resolved == .finder ? availableActions.first : resolved @@ -519,7 +513,7 @@ struct WorktreeDetailView: View { onOpenActionSelectionChanged(action) onOpenWorktree(action) } label: { - OpenWorktreeActionMenuLabelView(action: action, shortcutHint: nil) + OpenWorktreeActionMenuLabelView(action: action) } .buttonStyle(.plain) .help(openActionHelpText(for: action, isDefault: isDefault)) @@ -528,14 +522,11 @@ struct WorktreeDetailView: View { Button { onRevealInFinder() } label: { - OpenWorktreeActionMenuLabelView(action: .finder, shortcutHint: nil) + OpenWorktreeActionMenuLabelView(action: .finder) } - .help("Reveal in Finder (\(resolveShortcutDisplay(for: AppShortcuts.revealInFinder)))") + .help("Reveal in Finder (\(WorktreeDetailView.resolveShortcutDisplay(for: AppShortcuts.revealInFinder)))") } label: { - OpenWorktreeActionMenuLabelView( - action: primarySelection, - shortcutHint: showExtras ? resolveShortcutDisplay(for: AppShortcuts.openWorktree, fallback: "") : nil - ) + OpenWorktreeActionMenuLabelView(action: primarySelection) } primaryAction: { onOpenWorktree(primarySelection) } @@ -545,7 +536,7 @@ struct WorktreeDetailView: View { private func openActionHelpText(for action: OpenWorktreeAction, isDefault: Bool) -> String { guard isDefault else { return action.title } - return "\(action.title) (\(resolveShortcutDisplay(for: AppShortcuts.openWorktree)))" + return "\(action.title) (\(WorktreeDetailView.resolveShortcutDisplay(for: AppShortcuts.openWorktree)))" } } @@ -667,6 +658,12 @@ struct WorktreeDetailView: View { } return nil } + + static func resolveShortcutDisplay(for shortcut: AppShortcut, fallback: String = "none") -> String { + @Shared(.settingsFile) var settingsFile + let display = shortcut.effective(from: settingsFile.global.shortcutOverrides)?.display ?? fallback + return display.isEmpty ? fallback : display + } } // MARK: - Detail placeholder. @@ -861,13 +858,6 @@ private struct MultiSelectedWorktreeSummary: Identifiable { let repositoryName: String? } -/// Resolves a shortcut's display string from the user's settings. -private func resolveShortcutDisplay(for shortcut: AppShortcut, fallback: String = "none") -> String { - @Shared(.settingsFile) var settingsFile - let display = shortcut.effective(from: settingsFile.global.shortcutOverrides)?.display ?? fallback - return display.isEmpty ? fallback : display -} - private struct MultiSelectedWorktreesDetailView: View { let rows: [MultiSelectedWorktreeSummary] @@ -992,7 +982,6 @@ private struct ScriptMenu: View { let onStopRunScripts: () -> Void let onManageRepoScripts: () -> Void let onManageGlobalScripts: () -> Void - @Environment(CommandKeyObserver.self) private var commandKeyObserver private var primaryScript: ScriptDefinition? { toolbarState.primaryScript @@ -1066,7 +1055,7 @@ private struct ScriptMenu: View { private func scriptButtonHelp(script: ScriptDefinition, isRunning: Bool, hasCommand: Bool) -> String { if isRunning { return "Stop \(script.displayName)." } - if !hasCommand { return "\"\(script.displayName)\" has no command — configure it in Settings." } + if !hasCommand { return "\"\(script.displayName)\" has no command. Configure it in Settings." } return "Run \(script.displayName)." } @@ -1074,13 +1063,8 @@ private struct ScriptMenu: View { private func scriptLabel(hasRunning: Bool) -> some View { let icon = hasRunning ? "stop" : (primaryScript?.resolvedSystemImage ?? "play") let label = hasRunning ? "Stop" : (primaryScript?.displayName ?? "Run") - let shortcut = hasRunning ? AppShortcuts.stopRunScript : AppShortcuts.runScript Label { - Text( - commandKeyObserver.isPressed - ? resolveShortcutDisplay(for: shortcut, fallback: label) - : label - ) + Text(label) } icon: { Image(systemName: icon) .accessibilityHidden(true) @@ -1101,7 +1085,6 @@ private struct ScriptMenu: View { @MainActor private struct WorktreeToolbarPreview: View { private let toolbarState: WorktreeDetailView.WorktreeToolbarState - private let commandKeyObserver: CommandKeyObserver init() { toolbarState = WorktreeDetailView.WorktreeToolbarState( @@ -1121,14 +1104,10 @@ private struct WorktreeToolbarPreview: View { kind: .git(pullRequest: nil), statusToast: nil, openActionSelection: .finder, - showExtras: false, repoScripts: [ScriptDefinition(kind: .run, command: "npm run dev")], globalScripts: [], runningScriptIDs: [], ) - let observer = CommandKeyObserver() - observer.isPressed = false - commandKeyObserver = observer } var body: some View { @@ -1153,7 +1132,6 @@ private struct WorktreeToolbarPreview: View { onManageGlobalScripts: {} ) } - .environment(commandKeyObserver) .frame(width: 900, height: 160) } } diff --git a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift index 6dc9f08d8..51e90a235 100644 --- a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift +++ b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift @@ -15,6 +15,9 @@ enum TerminalTabBarMetrics { static let activeTabOffset: CGFloat = 0.5 static let activeTabBottomPadding: CGFloat = 1 static let closeButtonSize: CGFloat = 16 + // Fixed trailing slot so the cmd badge can fade in without shifting other content. + // Sized for the longest `⌘N` badge plus a small margin; widen if shortcut overrides can render longer hints. + static let trailingSlotWidth: CGFloat = 24 static let dirtyIndicatorSize: CGFloat = 8 static let overflowShadowWidth: CGFloat = 24 static let dropIndicatorWidth: CGFloat = 2 diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift index 804e41565..46d7465d7 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift @@ -31,25 +31,13 @@ private struct TerminalTabBarAccessoryButton: View { @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts - @Environment(CommandKeyObserver.self) - private var commandKeyObserver var body: some View { let shortcut = ghosttyShortcuts.display(for: shortcutBinding) Button(action: action) { - if commandKeyObserver.isPressed { - HStack(spacing: TerminalTabBarMetrics.contentSpacing) { - Text(title) - .font(.caption) - if let shortcut { - ShortcutHintView(text: shortcut, color: TerminalTabBarColors.inactiveText) - } - } - } else { - Label(title, systemImage: systemImage) - .labelStyle(.iconOnly) - } + Label(title, systemImage: systemImage) + .labelStyle(.iconOnly) } .buttonStyle(.plain) .foregroundStyle(.secondary) @@ -69,8 +57,6 @@ private struct TerminalTabBarSplitMenu: View { @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts - @Environment(CommandKeyObserver.self) - private var commandKeyObserver var body: some View { let primaryShortcut = ghosttyShortcuts.display(for: primary.ghosttyBinding) @@ -85,18 +71,8 @@ private struct TerminalTabBarSplitMenu: View { } .ghosttyKeyboardShortcut(secondary.ghosttyBinding, in: ghosttyShortcuts) } label: { - if commandKeyObserver.isPressed { - HStack(spacing: TerminalTabBarMetrics.contentSpacing) { - Text(primary.title) - .font(.caption) - if let primaryShortcut { - ShortcutHintView(text: primaryShortcut, color: TerminalTabBarColors.inactiveText) - } - } - } else { - Label(primary.title, systemImage: primary.systemImage) - .labelStyle(.iconOnly) - } + Label(primary.title, systemImage: primary.systemImage) + .labelStyle(.iconOnly) } primaryAction: { split(primary) } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift index 7a2a145a0..d60f20abf 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift @@ -7,8 +7,6 @@ struct TerminalTabLabelView: View { let isActive: Bool let isHoveringTab: Bool let isHoveringClose: Bool - let shortcutHint: String? - let showsShortcutHint: Bool /// Per-tab scoped store. The badge subview observes `state.agents` here /// instead of iterating worktree-wide presence, so an agent storm on tab B /// doesn't invalidate tab A's label body. @@ -34,7 +32,7 @@ struct TerminalTabLabelView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .contentShape(.rect) .padding(.horizontal, TerminalTabBarMetrics.tabHorizontalPadding) - .padding(.trailing, TerminalTabBarMetrics.closeButtonSize + TerminalTabBarMetrics.contentSpacing) + .padding(.trailing, TerminalTabBarMetrics.trailingSlotWidth + TerminalTabBarMetrics.contentSpacing) } } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift index 4068feab1..a8e32089b 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift @@ -40,8 +40,6 @@ struct TerminalTabView: View { isActive: isActive, isHoveringTab: isHovering, isHoveringClose: isHoveringClose, - shortcutHint: shortcutHint, - showsShortcutHint: showsShortcutHint, tabStore: tabStore, ) } @@ -60,9 +58,11 @@ struct TerminalTabView: View { .allowsHitTesting(!isEditing) .opacity(isEditing ? 0 : 1) - // Trailing slot priority: zoom-dismiss > close (on hover) > ⌘ hint > lock > dot. - // The lock subsumes the dot so a bell on a frozen tab doesn't contest the lock signal. - ZStack { + // Fixed 24pt trailing slot. Lock and notification dot are mutually exclusive; + // the hint, close, and zoom-dismiss buttons cross-fade via opacity. Close/zoom + // suppress lock/dot via the `suppress:` parameter when hovering, dragging, + // hint-showing, or split-zoomed. + ZStack(alignment: .trailing) { if tab.isBlockingScriptCompleted { TerminalTabLockIndicator( suppress: isHovering || isHoveringClose || isDragging || isShowingHint || isSplitZoomed @@ -73,13 +73,15 @@ struct TerminalTabView: View { suppress: isHovering || isHoveringClose || isDragging || isShowingHint || isSplitZoomed ) } - if isShowingHint, let shortcutHint { + if let shortcutHint { Text(shortcutHint) .font(.caption) // Explicit `.regular` because the tab bar lacks the sidebar's List/vibrancy // context, where `.font(.caption)` would otherwise render heavier. .fontWeight(.regular) .foregroundStyle(.secondary) + .opacity(isShowingHint ? 1 : 0) + .animation(.easeInOut(duration: TerminalTabBarMetrics.fadeAnimationDuration), value: isShowingHint) } if isSplitZoomed { TerminalTabExitSplitZoomButton( @@ -99,6 +101,7 @@ struct TerminalTabView: View { ) } } + .frame(width: TerminalTabBarMetrics.trailingSlotWidth, height: TerminalTabBarMetrics.closeButtonSize) .animation(.easeInOut(duration: TerminalTabBarMetrics.hoverAnimationDuration), value: isHovering) .padding(.trailing, TerminalTabBarMetrics.tabHorizontalPadding) .opacity(isEditing ? 0 : 1) @@ -115,7 +118,7 @@ struct TerminalTabView: View { .padding(.horizontal, TerminalTabBarMetrics.tabHorizontalPadding) .padding( .trailing, - TerminalTabBarMetrics.closeButtonSize + TerminalTabBarMetrics.contentSpacing + TerminalTabBarMetrics.trailingSlotWidth + TerminalTabBarMetrics.contentSpacing ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .onSubmit { onEndRename() } @@ -236,15 +239,11 @@ struct TerminalTabView: View { return "⌘\(number)" } - private var showsShortcutHint: Bool { - commandKeyObserver.isPressed && shortcutHint != nil - } - /// True when the cmd-pressed hotkey hint should occupy the trailing slot. /// Hover wins: when the user is over the tab the close button takes the /// slot regardless of whether ⌘ is also pressed. private var isShowingHint: Bool { - showsShortcutHint && !isHovering && !isDragging + commandKeyObserver.isPressed && shortcutHint != nil && !isHovering && !isDragging } /// State-aware accessibility value for VoiceOver. Restores the OSC-9 progress diff --git a/supacode/Support/ShortcutHintView.swift b/supacode/Support/ShortcutHintView.swift deleted file mode 100644 index ee4860383..000000000 --- a/supacode/Support/ShortcutHintView.swift +++ /dev/null @@ -1,12 +0,0 @@ -import SwiftUI - -struct ShortcutHintView: View { - let text: String - let color: Color - - var body: some View { - Text(text) - .font(.caption2) - .foregroundStyle(color) - } -} diff --git a/supacodeTests/SidebarItemFeatureTests.swift b/supacodeTests/SidebarItemFeatureTests.swift index 32c7222a4..41d1952ba 100644 --- a/supacodeTests/SidebarItemFeatureTests.swift +++ b/supacodeTests/SidebarItemFeatureTests.swift @@ -274,15 +274,10 @@ struct SidebarItemFeatureTests { // MARK: - UI-scalar guards. - @Test func shortcutHintAndDragSessionGuardsSkipNoOps() async { + @Test func dragSessionGuardSkipsNoOps() async { let store = TestStore(initialState: makeState(name: "feature")) { SidebarItemFeature() } - await store.send(.shortcutHintChanged("⌘1")) { - $0.shortcutHint = "⌘1" - } - // Same hint: no-op. - await store.send(.shortcutHintChanged("⌘1")) await store.send(.dragSessionChanged(isDragging: true)) { $0.isDragging = true } From 548609abf72b6a3a30b24b44c01a41b467a26429 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 10 Jun 2026 10:57:23 +0200 Subject: [PATCH 11/56] Carry agent presence and notifications over OSC 3008 (#390) * 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). --- .../AgentHookSettingsCommand.swift | 72 +-- .../BusinessLogic/AgentPresenceOSC.swift | 218 ++++++++ .../BusinessLogic/PiExtensionContent.swift | 188 +++---- patches/ghostty-osc3008-context-signal.patch | 275 ++++++++++ .../Reducer/AgentPresenceFeature.swift | 85 ++- .../WorktreeTerminalManager.swift | 26 +- .../Models/WorktreeTerminalState.swift | 344 ++++++++++-- .../AgentHookSocketServer.swift | 179 +++--- .../Ghostty/GhosttySurfaceBridge.swift | 20 + supacodeTests/AgentBusyStateTests.swift | 235 +++++--- supacodeTests/AgentHookCommandTests.swift | 517 ++++++++++-------- .../AgentHookSocketServerTests.swift | 355 ++++-------- supacodeTests/AgentPresence+TestHelpers.swift | 4 +- supacodeTests/AgentPresenceFeatureTests.swift | 166 +++++- supacodeTests/AgentPresenceOSCTests.swift | 377 +++++++++++++ supacodeTests/GhosttySurfaceBridgeTests.swift | 69 ++- .../WorktreeTerminalManagerTests.swift | 79 ++- 17 files changed, 2216 insertions(+), 993 deletions(-) create mode 100644 SupacodeSettingsShared/BusinessLogic/AgentPresenceOSC.swift create mode 100644 patches/ghostty-osc3008-context-signal.patch create mode 100644 supacodeTests/AgentPresenceOSCTests.swift diff --git a/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsCommand.swift b/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsCommand.swift index e3fb815f9..aebe88e9f 100644 --- a/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsCommand.swift +++ b/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsCommand.swift @@ -13,7 +13,7 @@ nonisolated enum HookEvent: String { nonisolated enum AgentHookSettingsCommand { /// Sentinel comment appended to every Supacode-installed hook command. - /// `AgentHookCommandOwnership` uses this — and ONLY this — to identify + /// `AgentHookCommandOwnership` uses this (and ONLY this) to identify /// managed commands. `SUPACODE_SOCKET_PATH` is documented public API /// (CLI skill env table, Pi extension example, deeplink reference), so /// matching on the env-var name alone would silently strip user-authored @@ -22,7 +22,7 @@ nonisolated enum AgentHookSettingsCommand { /// Documented public env var. Used as ONE half of the legacy CLI-shim /// fingerprint (paired with `supacode integration event`); never matched - /// alone — user-authored hooks reference it legitimately. + /// alone. User-authored hooks reference it legitimately. static let socketPathEnvVar = "SUPACODE_SOCKET_PATH" /// Markers present in legacy Supacode hook commands (pre-socket). @@ -43,22 +43,11 @@ nonisolated enum AgentHookSettingsCommand { + #" && [ -n "${SUPACODE_TAB_ID:-}" ]"# + #" && [ -n "${SUPACODE_SURFACE_ID:-}" ]"# - private static let ids = - "$SUPACODE_WORKTREE_ID $SUPACODE_TAB_ID $SUPACODE_SURFACE_ID" - - /// Both stdout AND stderr go to /dev/null — Codex parses hook stdout as - /// structured JSON and would reject the socket ack otherwise. - private static func managed(_ pipeline: String) -> String { - "\(envCheck) && \(pipeline) >/dev/null 2>&1 || true \(ownershipMarker)" - } - - /// Builds a single shell command that fires every `event` envelope and - /// optionally forwards stdin as a notification, all under one envCheck - /// guard with one sentinel. Stdin is consumed once via `payload=$(cat)` - /// so the same payload can be relayed after the fixed envelopes. The - /// precondition rejects a no-op invocation because the empty-empty - /// fallthrough would otherwise emit `{ ; }` (shell syntax error masked - /// by `|| true`). + /// Composes the OSC 3008 hook command: one token guard, then (once that passes) + /// the tty resolve plus a presence emit per event and/or a notify emit, all in a + /// single brace group whose output is suppressed. Guarding first keeps the + /// command truly inert outside Supacode (no `ps` runs when the token is unset). + /// The precondition rejects a no-op invocation that would emit nothing. static func compositeCommand( events: [HookEvent], forwardStdinAsNotification: Bool, @@ -68,43 +57,18 @@ nonisolated enum AgentHookSettingsCommand { !events.isEmpty || forwardStdinAsNotification, "compositeCommand needs at least one side-effect (events or stdin forward).", ) - if events.count == 1, !forwardStdinAsNotification { - return managed(envelopePipeline(event: events[0], agent: agent)) - } - if events.isEmpty, forwardStdinAsNotification { - return managed(notifyPipeline(agent: agent, payloadExpr: nil)) - } - var steps: [String] = [] - if forwardStdinAsNotification { steps.append("payload=$(cat)") } - for event in events { - steps.append(envelopePipeline(event: event, agent: agent)) - } - if forwardStdinAsNotification { - steps.append(notifyPipeline(agent: agent, payloadExpr: #""$payload""#)) - } - return managed("{ \(steps.joined(separator: "; ")); }") - } - - private static func envelopePipeline(event: HookEvent, agent: SkillAgent) -> String { - let envelope = - #"{\"event\":\"\#(event.rawValue)\","# - + #"\"v\":1,\"agent\":\"\#(agent.rawValue)\","# - + #"\"surface_id\":\"$SUPACODE_SURFACE_ID\",\"pid\":$PPID}"# - return #"printf '%s' "\#(envelope)" | /usr/bin/nc -U -w1 "$SUPACODE_SOCKET_PATH""# + var steps: [String] = [AgentPresenceOSC.ttyResolveSnippet] + steps += events.map { AgentPresenceOSC.emitShell(event: $0, agent: agent) } + if forwardStdinAsNotification { steps.append(AgentPresenceOSC.emitNotifyShell(agent: agent)) } + return "\(oscGuardExpr) && { \(steps.joined(separator: "; ")); } >/dev/null 2>&1 || true \(ownershipMarker)" } - /// `payloadExpr == nil` → forward stdin live via `cat`. Non-nil → relay - /// a previously-stashed shell expression (so the composite path can - /// consume stdin once and reuse it after event envelopes). - private static func notifyPipeline(agent: SkillAgent, payloadExpr: String?) -> String { - let body: String - if let payloadExpr { - body = #"printf '%s' \#(payloadExpr)"# - } else { - body = "cat" - } - return - #"{ printf '%s \#(agent.rawValue)\n' "\#(ids)"; \#(body); }"# - + #" | /usr/bin/nc -U -w1 "$SUPACODE_SOCKET_PATH""# + /// Guard for the OSC command: the per-surface OSC token set (the + /// no-op-outside-Supacode gate) and a surface id present. Fires both locally and + /// over SSH; the pid suffix inside the presence emit is what's gated on the + /// socket path, not the emission itself. + private static var oscGuardExpr: String { + #"[ -n "${\#(AgentPresenceOSC.tokenEnvVar):-}" ]"# + + #" && [ -n "${SUPACODE_SURFACE_ID:-}" ]"# } } diff --git a/SupacodeSettingsShared/BusinessLogic/AgentPresenceOSC.swift b/SupacodeSettingsShared/BusinessLogic/AgentPresenceOSC.swift new file mode 100644 index 000000000..499e1ac6d --- /dev/null +++ b/SupacodeSettingsShared/BusinessLogic/AgentPresenceOSC.swift @@ -0,0 +1,218 @@ +import Foundation + +/// OSC 3008 (UAPI hierarchical context signal) wire format that carries the +/// agent-presence event lifecycle over the terminal stream, so the badge tracks +/// state over SSH where the local Unix socket can't be reached. The sequence is +/// inert in any terminal that doesn't handle OSC 3008 (no toast, no side effect). +/// +/// Emit shape: `OSC 3008 ; = ; event= ; token=[ ; pid=] ST`. +/// libghostty splits that into `id = ` (the context id, up to the first +/// `;`) and `metadata = "event=;token=[;pid=]"`, which is what +/// `parse` receives. `parse` derives the event solely from the `event=` field and +/// ignores the start/end action byte. +/// - attribution is by the receiving surface, so no surface id is carried; +/// - `event` is the `HookEvent` rawValue; +/// - `token` echoes the per-surface `SUPACODE_OSC_TOKEN` capability nonce, which +/// the app verifies against the receiving surface before trusting the signal; +/// - `pid` is the agent's LOCAL process id, present only when the hook ran on the +/// same host (gated on `SUPACODE_SOCKET_PATH`); it feeds the app's liveness +/// sweep so a crashed local agent is reaped. Omitted over SSH. +/// +/// The same transport also carries the rich notification leg +/// (`kind=notify;token=;data=`); presence and notify are +/// disjoint metadata shapes routed by which `parse*` succeeds. +/// +/// Single source of truth for both the emit side (the agent hook) and the parse +/// side (the app), so the field names can't drift. +public nonisolated enum AgentPresenceOSC { + /// Env var carrying the per-surface secret capability nonce. Present only on + /// Supacode surfaces, so its presence doubles as the no-op-outside-Supacode gate. + public static let tokenEnvVar = "SUPACODE_OSC_TOKEN" + + static let eventField = "event" + static let tokenField = "token" + static let pidField = "pid" + static let kindField = "kind" + static let dataField = "data" + static let notifyKind = "notify" + + /// A parsed, NOT-yet-trusted presence signal. The caller must verify `token` + /// against the receiving surface's nonce before acting on it. + public struct Signal: Equatable, Sendable { + /// Context id, i.e. the agent rawValue. + public let agent: String + /// A known HookEvent rawValue. Parse rejects unknown values; stored as + /// String so wire concerns don't leak into the enum. + public let eventRawValue: String + public let token: String + /// The agent's process id, trusted via the per-surface token, not verified + /// local: parse/trust can't distinguish a genuinely-local emit from a forged + /// `pid=` on the wire. The emit gates it on `SUPACODE_SOCKET_PATH` so a + /// legitimate local hook carries it and a remote one omits it, but a forged + /// positive pid at worst pins a live-looking badge until surface close. + public let pid: pid_t? + } + + /// Parse the OSC 3008 context id + raw key=value metadata (as surfaced by + /// libghostty) into a `Signal`. Returns nil for anything that isn't a + /// well-formed presence signal with a known event. Does NOT verify the token. + public static func parse(id: String, metadata: String) -> Signal? { + guard !id.isEmpty else { return nil } + guard let fields = parseFields(metadata) else { return nil } + guard + let rawEvent = fields[Substring(eventField)], + HookEvent(rawValue: String(rawEvent)) != nil + else { return nil } + guard let token = fields[Substring(tokenField)], !token.isEmpty else { return nil } + return Signal( + agent: id, + eventRawValue: String(rawEvent), + token: String(token), + pid: parsePid(fields[Substring(pidField)]), + ) + } + + /// Parse the optional `pid=` field. Rejects non-numeric and non-positive + /// values: a 0 / negative pid would let `kill(_:0)` match the caller's process + /// group and pin a permanent badge in the liveness sweep. + private static func parsePid(_ raw: Substring?) -> pid_t? { + guard let raw, let value = pid_t(raw), value > 0 else { return nil } + return value + } + + /// True when the metadata carries `kind=notify`. Cheap routing check (presence + /// vs notify) that inspects the `kind` field, not a raw substring, so a base64 + /// `data` value that happens to contain "kind=notify" can't misroute. + public static func isNotifyMetadata(_ metadata: String) -> Bool { + parseFields(metadata)?[Substring(kindField)] == Substring(notifyKind) + } + + /// Split the OSC 3008 raw metadata into its `key=value` fields. Standard base64 + /// values are framing-safe here: their alphabet (A-Za-z0-9+/=) has no `;`, and + /// the value keeps everything after the FIRST `=` (`firstIndex(of:)`), so base64 + /// `=` padding survives intact. + /// + /// Duplicate trust-bearing keys (`token`, `event`, `kind`) are rejected: a + /// repeated key would otherwise pin perceived state to the last occurrence, + /// which an attacker who can splice into the wire could exploit to flip + /// `event=` or to pair a valid `token=` with an injected `kind=notify`. All + /// other duplicate keys keep the historical last-write-wins behavior. + static func parseFields(_ metadata: String) -> [Substring: Substring]? { + var fields: [Substring: Substring] = [:] + for pair in metadata.split(separator: ";", omittingEmptySubsequences: true) { + guard let equalsIndex = pair.firstIndex(of: "=") else { continue } + let key = pair[.. = [ + Substring(tokenField), Substring(eventField), Substring(kindField), + ] + + /// A parsed, NOT-yet-trusted notification signal. `payload` is the decoded + /// agent JSON (the same shape the socket notify pipeline forwards). The caller + /// must verify `token` against the receiving surface's nonce before acting. + public struct NotifySignal: Equatable, Sendable { + /// Context id, i.e. the agent rawValue. + public let agent: String + public let token: String + /// The base64-decoded agent notification JSON. + public let payload: Data + } + + /// Parse an OSC 3008 notify signal (`kind=notify;token=;data=`). + /// Returns nil unless it carries the notify kind, a non-empty token, and a + /// base64-decodable payload. Does NOT verify the token. + public static func parseNotify(id: String, metadata: String) -> NotifySignal? { + guard !id.isEmpty else { return nil } + guard let fields = parseFields(metadata) else { return nil } + guard fields[Substring(kindField)] == Substring(notifyKind) else { return nil } + guard let token = fields[Substring(tokenField)], !token.isEmpty else { return nil } + guard + let rawData = fields[Substring(dataField)], + let payload = Data(base64Encoded: String(rawData)) + else { return nil } + return NotifySignal(agent: id, token: String(token), payload: payload) + } + + /// The OSC 3008 action for an event: session_end ends a context, everything + /// else starts / updates one. The app keys off `event=` in the metadata, not + /// this action, so it is descriptive rather than load-bearing. + static func action(for event: HookEvent) -> String { + event == .sessionEnd ? "end" : "start" + } + + /// The `key=value` metadata a PRESENCE signal carries (everything after the + /// context id). `parse` recovers the event from this exact shape. `pidSuffix` + /// is appended verbatim (e.g. `;pid=123`) so the emit can splice in a + /// shell-built, conditionally-empty suffix. See `notifyMetadata` for the + /// notify counterpart. + static func metadata(event: HookEvent, token: String, pidSuffix: String = "") -> String { + "\(eventField)=\(event.rawValue);\(tokenField)=\(token)\(pidSuffix)" + } + + /// Shell that resolves `$__tty` to a writable terminal device for the OSC emits. + /// Agents run hooks without a controlling terminal (`/dev/tty` open fails), so + /// the hook recovers the terminal its parent agent is attached to via + /// `ps -o tty=`. `ps` reports a bare name (`ttys039` on macOS, `pts/5` on + /// Linux), so a `/dev/` prefix is added; a parent with no tty (`??`) falls back + /// to `/dev/tty` for the rare context that does have a controlling terminal. + static let ttyResolveSnippet = + #"__tty=$(ps -o tty= -p "$PPID" 2>/dev/null | tr -d '[:space:]'); "# + + #"case "$__tty" in *[0-9]*) __tty="/dev/${__tty#/dev/}";; *) __tty="/dev/tty";; esac"# + + /// Shell `printf` that emits the OSC 3008 presence sequence for `event`, + /// echoing `$SUPACODE_OSC_TOKEN` for the token placeholder. Written to the + /// `$__tty` device resolved by `ttyResolveSnippet` so it reaches the terminal + /// even though the hook has no controlling terminal and captured stdout. The + /// caller guards emission on the token env var and runs `ttyResolveSnippet` + /// first. + /// + /// The pid suffix is gated on `SUPACODE_SOCKET_PATH` (set only on the local + /// host) so a legitimate local hook carries `$PPID` and a remote one omits it. + /// This is not verified on receipt: the per-surface token is the only real + /// gate, and a forged positive pid at worst pins a live-looking badge until + /// surface close. The suffix is built in shell and filled into a trailing + /// `%s`, empty when remote. + static func emitShell(event: HookEvent, agent: SkillAgent) -> String { + // token=%s then a trailing %s for the shell-built, conditionally-empty pid suffix. + let meta = metadata(event: event, token: "%s", pidSuffix: "%s") + let payload = #"\033]3008;\#(action(for: event))=\#(agent.rawValue);\#(meta)\033\\"# + return #"__sp=""; [ -n "${SUPACODE_SOCKET_PATH:-}" ] && __sp=";\#(pidField)=$PPID"; "# + + #"printf '\#(payload)' "$\#(tokenEnvVar)" "$__sp" > "$__tty""# + } + + /// The `key=value` metadata a notify signal carries. `parseNotify` recovers the + /// payload from this exact shape. + static func notifyMetadata(token: String, data: String) -> String { + "\(kindField)=\(notifyKind);\(tokenField)=\(token);\(dataField)=\(data)" + } + + /// Shell snippet that reads the agent notification JSON from stdin, + /// base64-encodes it, and emits the OSC 3008 notify sequence echoing + /// `$SUPACODE_OSC_TOKEN`. `base64 | tr -d '\n'` is portable (macOS + Linux) and + /// strips the wrapping newlines so the payload stays a single OSC field. The + /// emit/parse pair is locked to STANDARD base64 (`Data(base64Encoded:)` rejects + /// the URL-safe alphabet a busybox/alpine `base64` might default to). + static func emitNotifyShell(agent: SkillAgent) -> String { + let payload = #"\033]3008;start=\#(agent.rawValue);\#(notifyMetadata(token: "%s", data: "%s"))\033\\"# + return #"__osc_d=$(base64 | tr -d '\n'); printf '\#(payload)' "$\#(tokenEnvVar)" "$__osc_d" > "$__tty""# + } + + /// Equal-length constant-time compare. A length mismatch returns immediately; + /// safe because the expected token is server-generated and fixed-length + /// (32 hex chars), not attacker-controlled. + public static func tokensMatch(_ lhs: String, _ rhs: String) -> Bool { + let lhsBytes = Array(lhs.utf8) + let rhsBytes = Array(rhs.utf8) + guard lhsBytes.count == rhsBytes.count else { return false } + var diff: UInt8 = 0 + for index in lhsBytes.indices { diff |= lhsBytes[index] ^ rhsBytes[index] } + return diff == 0 + } +} diff --git a/SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift b/SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift index 690e3c1ae..a24f7df86 100644 --- a/SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift +++ b/SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift @@ -11,36 +11,29 @@ nonisolated enum PiExtensionContent { static let indexTs = """ \(ownershipMarker) /** - * Supacode ↔ Pi integration extension. + * Supacode + Pi integration extension. * - * Reports agent lifecycle hooks back to Supacode via the Unix domain socket - * it injects into every managed terminal session, matching the semantics of - * the existing Claude and Codex hook integrations. + * Reports agent lifecycle and notifications to Supacode by emitting OSC 3008 + * escape sequences to the controlling terminal. The sequences are inert in any + * terminal that does not handle OSC 3008, and reach Supacode over SSH too (no + * local socket needed), matching the Claude / Codex / Kiro hook integrations. * - * Required env vars (injected automatically by Supacode): - * SUPACODE_SOCKET_PATH — path to the Unix domain socket - * SUPACODE_WORKTREE_ID — worktree identifier - * SUPACODE_TAB_ID — tab UUID - * SUPACODE_SURFACE_ID — terminal surface UUID + * Required env var (injected automatically by Supacode on every surface): + * SUPACODE_OSC_TOKEN per-surface capability nonce; gates emission and is + * verified app-side. Absent = not a Supacode surface. + * Optional: + * SUPACODE_SOCKET_PATH present only on the local host; gates the local pid + * so the app's liveness sweep can reap a crashed agent. * * Hook event mapping: - * extension load → session_start (agent presence badge) - * Pi agent_start → busy (UserPromptSubmit equivalent) - * Pi agent_end → idle (Stop equivalent — resets activity) - * → notification with last_assistant_message - * Pi session_shutdown → session_end (SessionEnd equivalent) - * → idle (defensive activity reset) + * extension load -> session_start (agent presence badge) + * Pi agent_start -> busy + * Pi agent_end -> idle + notification with last_assistant_message + * Pi session_shutdown -> session_end + idle (defensive activity reset) */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; - import { createConnection } from "node:net"; - - interface SupacodeEnv { - socketPath: string; - worktreeId: string; - tabId: string; - surfaceId: string; - } + import { openSync, writeSync, closeSync } from "node:fs"; interface HookPayload { hook_event_name: string; @@ -49,73 +42,82 @@ nonisolated enum PiExtensionContent { last_assistant_message?: string; } - function readSupacodeEnv(): SupacodeEnv | null { - const socketPath = process.env["SUPACODE_SOCKET_PATH"]; - const worktreeId = process.env["SUPACODE_WORKTREE_ID"]; - const tabId = process.env["SUPACODE_TAB_ID"]; - const surfaceId = process.env["SUPACODE_SURFACE_ID"]; + const AGENT = "pi"; - if (!socketPath || !worktreeId || !tabId || !surfaceId) return null; - return { socketPath, worktreeId, tabId, surfaceId }; + let lastWarnedAt = 0; + const WARN_INTERVAL_MS = 60_000; + + function readToken(): string | null { + const token = process.env["SUPACODE_OSC_TOKEN"]; + return token && token.length > 0 ? token : null; } /** - * Sends raw bytes to a Unix domain socket and closes the connection. - * Times out after 1 s (Pi serializes hook callbacks, so a stalled - * delivery would stall the agent) and swallows all errors — - * hook delivery is best-effort. + * The agent's local process id as an OSC pid suffix, but only on the local + * host (SUPACODE_SOCKET_PATH is set). A remote pid over SSH would be + * meaningless to the app's liveness sweep, so it is omitted there. */ - function sendToSocket(socketPath: string, data: Buffer): Promise { - return new Promise((resolve) => { - let settled = false; - const done = () => { - if (!settled) { - settled = true; - resolve(); - } - }; - - const client = createConnection({ path: socketPath }); - const timer = setTimeout(() => { - client.destroy(); - done(); - }, 1000); - - client.on("connect", () => { - client.write(data, () => { - client.end(); - clearTimeout(timer); - done(); - }); - }); + function localPidSuffix(): string { + return process.env["SUPACODE_SOCKET_PATH"] ? `;pid=${process.pid}` : ""; + } - client.on("error", () => { - clearTimeout(timer); - done(); - }); - }); + /** + * Writes an OSC sequence to the controlling terminal. The extension runs + * inside the Pi TUI process, which owns the terminal, so /dev/tty resolves. + * Best-effort, but a systematically-failing tty is logged at most once per + * `WARN_INTERVAL_MS` to stderr so a broken write path is distinguishable + * from "not a Supacode surface" without spamming the log on every emit. + */ + function writeToTerminal(sequence: string): void { + try { + const fd = openSync("/dev/tty", "w"); + try { + // Loop until the full byte length lands: a short write would leave a + // half OSC 3008 with no ST (ESC\\) and corrupt the terminal parser. + const bytes = Buffer.from(sequence, "utf8"); + let offset = 0; + while (offset < bytes.length) { + try { + const written = writeSync(fd, bytes, offset, bytes.length - offset); + if (written <= 0) { + throw new Error(`short write (${offset}/${bytes.length} bytes)`); + } + offset += written; + } catch (writeErr) { + // Retry interrupted / non-blocking transient errors; abort on anything else. + const code = (writeErr as NodeJS.ErrnoException).code; + if (code === "EINTR" || code === "EAGAIN") continue; + throw writeErr; + } + } + } finally { + closeSync(fd); + } + } catch (err) { + const now = Date.now(); + if (now - lastWarnedAt > WARN_INTERVAL_MS) { + lastWarnedAt = now; + const e = err as NodeJS.ErrnoException; + const code = e.code ?? ""; + const errno = e.errno ?? ""; + const message = e.message ?? String(err); + process.stderr.write( + `supacode: OSC emit failed: code=${code} errno=${errno} message=${message}\\n`, + ); + } + } } - function sendNotification(env: SupacodeEnv, payload: HookPayload): Promise { - const header = `${env.worktreeId} ${env.tabId} ${env.surfaceId} pi\\n`; - const body = JSON.stringify(payload) + "\\n"; - return sendToSocket(env.socketPath, Buffer.from(header + body, "utf8")); + function emitPresence(token: string, event: string): void { + const action = event === "session_end" ? "end" : "start"; + const meta = `event=${event};token=${token}${localPidSuffix()}`; + writeToTerminal(`\\x1b]3008;${action}=${AGENT};${meta}\\x1b\\\\`); } - /** - * Sends a hook event (session lifecycle or per-turn activity) using - * the same JSON envelope every other agent emits. - */ - function sendEvent(env: SupacodeEnv, eventName: string): Promise { - const envelope = { - event: eventName, - v: 1, - agent: "pi", - surface_id: env.surfaceId, - pid: process.pid, - }; - const body = JSON.stringify(envelope) + "\\n"; - return sendToSocket(env.socketPath, Buffer.from(body, "utf8")); + function emitNotification(token: string, payload: HookPayload): void { + const data = Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); + const meta = `kind=notify;token=${token};data=${data}`; + writeToTerminal(`\\x1b]3008;start=${AGENT};${meta}\\x1b\\\\`); } function lastAssistantText(ctx: { sessionManager: { getEntries(): any[] } }): string | undefined { @@ -140,34 +142,34 @@ nonisolated enum PiExtensionContent { } export default function (pi: ExtensionAPI) { - const env = readSupacodeEnv(); + const token = readToken(); - // Not running under Supacode — skip lifecycle hooks. - if (!env) return; + // Not running under Supacode, or not a Supacode surface: stay inert. + if (!token) return; // Extension load = agent process running. Pi has no equivalent of // Claude's SessionStart hook, so we fire it ourselves. - void sendEvent(env, "session_start"); + emitPresence(token, "session_start"); - pi.on("agent_start", async (_event, _ctx) => { - await sendEvent(env, "busy"); + pi.on("agent_start", (_event, _ctx) => { + emitPresence(token, "busy"); }); - pi.on("agent_end", async (_event, ctx) => { + pi.on("agent_end", (_event, ctx) => { // Atomic state-set: `idle` overwrites whatever was running on the - // Supacode side — turn-level Stop equivalent. - await sendEvent(env, "idle"); + // Supacode side (turn-level Stop equivalent). + emitPresence(token, "idle"); const lastMessage = lastAssistantText(ctx); - await sendNotification(env, { + emitNotification(token, { hook_event_name: "Stop", last_assistant_message: lastMessage, }); }); - pi.on("session_shutdown", async (_event, _ctx) => { - await sendEvent(env, "session_end"); - await sendEvent(env, "idle"); + pi.on("session_shutdown", (_event, _ctx) => { + emitPresence(token, "session_end"); + emitPresence(token, "idle"); }); } """ diff --git a/patches/ghostty-osc3008-context-signal.patch b/patches/ghostty-osc3008-context-signal.patch new file mode 100644 index 000000000..e2ae75823 --- /dev/null +++ b/patches/ghostty-osc3008-context-signal.patch @@ -0,0 +1,275 @@ +diff --git a/include/ghostty.h b/include/ghostty.h +index 3c4002abc..626c96876 100644 +--- a/include/ghostty.h ++++ b/include/ghostty.h +@@ -641,6 +641,13 @@ typedef struct { + const char* body; + } ghostty_action_desktop_notification_s; + ++// apprt.action.ContextSignal.C ++typedef struct { ++ uint8_t action; // 0 = start, 1 = end ++ const char* id; ++ const char* metadata; ++} ghostty_action_context_signal_s; ++ + // apprt.action.SetTitle.C + typedef struct { + const char* title; +@@ -926,6 +933,7 @@ typedef enum { + GHOSTTY_ACTION_SEARCH_SELECTED, + GHOSTTY_ACTION_READONLY, + GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD, ++ GHOSTTY_ACTION_CONTEXT_SIGNAL, + } ghostty_action_tag_e; + + typedef union { +@@ -942,6 +950,7 @@ typedef union { + ghostty_action_scrollbar_s scrollbar; + ghostty_action_inspector_e inspector; + ghostty_action_desktop_notification_s desktop_notification; ++ ghostty_action_context_signal_s context_signal; + ghostty_action_set_title_s set_title; + ghostty_action_set_title_s set_tab_title; + ghostty_action_prompt_title_e prompt_title; +diff --git a/src/Surface.zig b/src/Surface.zig +index c8055cfee..be18a3b6a 100644 +--- a/src/Surface.zig ++++ b/src/Surface.zig +@@ -1073,6 +1073,16 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { + try self.showDesktopNotification(title, body); + }, + ++ .context_signal => |signal| { ++ const id = std.mem.sliceTo(&signal.id, 0); ++ const metadata = std.mem.sliceTo(&signal.metadata, 0); ++ _ = try self.rt_app.performAction( ++ .{ .surface = self }, ++ .context_signal, ++ .{ .action = signal.action, .id = id, .metadata = metadata }, ++ ); ++ }, ++ + .renderer_health => |health| self.updateRendererHealth(health), + + .scrollbar => |scrollbar| self.updateScrollbar(scrollbar), +diff --git a/src/apprt/action.zig b/src/apprt/action.zig +index f6865af83..e7180a2d2 100644 +--- a/src/apprt/action.zig ++++ b/src/apprt/action.zig +@@ -343,6 +343,10 @@ pub const Action = union(Key) { + /// otherwise the terminal-set title. + copy_title_to_clipboard, + ++ /// OSC 3008 hierarchical context signal (UAPI spec). A program inside the ++ /// terminal signalled a context change. ++ context_signal: ContextSignal, ++ + /// Sync with: ghostty_action_tag_e + pub const Key = enum(c_int) { + quit, +@@ -410,6 +414,7 @@ pub const Action = union(Key) { + search_selected, + readonly, + copy_title_to_clipboard, ++ context_signal, + + test "ghostty.h Action.Key" { + try lib.checkGhosttyHEnum(Key, "GHOSTTY_ACTION_"); +@@ -771,6 +776,28 @@ pub const DesktopNotification = struct { + } + }; + ++pub const ContextSignal = struct { ++ /// 0 = start, 1 = end. ++ action: u8, ++ id: [:0]const u8, ++ metadata: [:0]const u8, ++ ++ // Sync with: ghostty_action_context_signal_s ++ pub const C = extern struct { ++ action: u8, ++ id: [*:0]const u8, ++ metadata: [*:0]const u8, ++ }; ++ ++ pub fn cval(self: ContextSignal) C { ++ return .{ ++ .action = self.action, ++ .id = self.id.ptr, ++ .metadata = self.metadata.ptr, ++ }; ++ } ++}; ++ + pub const KeySequence = union(enum) { + trigger: input.Trigger, + end, +diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig +index 3cb0016fa..296e70bc9 100644 +--- a/src/apprt/surface.zig ++++ b/src/apprt/surface.zig +@@ -60,6 +60,20 @@ pub const Message = union(enum) { + body: [255:0]u8, + }, + ++ /// OSC 3008 hierarchical context signal (UAPI spec). ++ context_signal: struct { ++ /// The signalled action: 0 = start, 1 = end. ++ action: u8, ++ ++ /// Context identifier (1-64 chars per spec). ++ id: [64:0]u8, ++ ++ /// Raw semicolon-separated key=value metadata, truncated to 2047 bytes ++ /// (may split a field; see handleContextSignal). Sized to carry a ++ /// base64-encoded notification payload, not just the tiny presence fields. ++ metadata: [2047:0]u8, ++ }, ++ + /// Health status change for the renderer. + renderer_health: renderer.Health, + +diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig +index 9771334f9..4187bcbfb 100644 +--- a/src/terminal/stream.zig ++++ b/src/terminal/stream.zig +@@ -125,6 +125,7 @@ pub const Action = union(Key) { + kitty_color_report: kitty.color.OSC, + color_operation: ColorOperation, + semantic_prompt: SemanticPrompt, ++ context_signal: ContextSignal, + + pub const Key = lib.Enum( + lib.target, +@@ -222,6 +223,7 @@ pub const Action = union(Key) { + "kitty_color_report", + "color_operation", + "semantic_prompt", ++ "context_signal", + }, + ); + +@@ -348,6 +350,26 @@ pub const Action = union(Key) { + } + }; + ++ pub const ContextSignal = struct { ++ action: u8, ++ id: []const u8, ++ metadata: []const u8, ++ ++ pub const C = extern struct { ++ action: u8, ++ id: lib.String, ++ metadata: lib.String, ++ }; ++ ++ pub fn cval(self: ContextSignal) ContextSignal.C { ++ return .{ ++ .action = self.action, ++ .id = .init(self.id), ++ .metadata = .init(self.metadata), ++ }; ++ } ++ }; ++ + pub const StartHyperlink = struct { + uri: []const u8, + id: ?[]const u8, +@@ -2047,6 +2069,21 @@ pub fn Stream(comptime H: type) type { + self.handler.vt(.progress_report, v); + }, + ++ .context_signal => |v| { ++ // The u8 wire value is a locked C ABI; pin the enum mapping so a ++ // future ghostty bump that reorders Action fails loudly here ++ // instead of silently renumbering every consumer. ++ comptime { ++ std.debug.assert(@intFromEnum(@TypeOf(v.action).start) == 0); ++ std.debug.assert(@intFromEnum(@TypeOf(v.action).end) == 1); ++ } ++ self.handler.vt(.context_signal, .{ ++ .action = @intFromEnum(v.action), ++ .id = v.id, ++ .metadata = v.metadata, ++ }); ++ }, ++ + .conemu_sleep, + .conemu_show_message_box, + .conemu_change_tab_title, +@@ -2058,7 +2095,6 @@ pub fn Stream(comptime H: type) type { + .conemu_run_process, + .kitty_text_sizing, + .kitty_clipboard_protocol, +- .context_signal, + => { + log.debug("unimplemented OSC callback: {}", .{cmd}); + }, +diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig +index 8e0f91110..93712be0d 100644 +--- a/src/terminal/stream_terminal.zig ++++ b/src/terminal/stream_terminal.zig +@@ -259,6 +259,7 @@ pub const Handler = struct { + // Have no terminal-modifying effect + .report_pwd, + .show_desktop_notification, ++ .context_signal, + .progress_report, + .clipboard_contents, + .title_push, +diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig +index fb3a6b3ff..08084ca69 100644 +--- a/src/termio/stream_handler.zig ++++ b/src/termio/stream_handler.zig +@@ -327,6 +327,7 @@ pub const StreamHandler = struct { + .window_title => try self.windowTitle(value.title), + .report_pwd => try self.reportPwd(value.url), + .show_desktop_notification => try self.showDesktopNotification(value.title, value.body), ++ .context_signal => try self.handleContextSignal(value.action, value.id, value.metadata), + .progress_report => self.progressReport(value), + .start_hyperlink => try self.startHyperlink(value.uri, value.id), + .clipboard_contents => try self.clipboardContents(value.kind, value.data), +@@ -1447,6 +1448,41 @@ pub const StreamHandler = struct { + self.surfaceMessageWriter(message); + } + ++ fn handleContextSignal( ++ self: *StreamHandler, ++ action: u8, ++ id: []const u8, ++ metadata: []const u8, ++ ) !void { ++ var message = apprt.surface.Message{ .context_signal = undefined }; ++ ++ message.context_signal.action = action; ++ ++ // id is spec-bounded to 64 (max_context_id_len) by the parser; the clamp ++ // is defense-in-depth and is not expected to fire. Warn symmetrically ++ // with the metadata path so a future spec-cap raise doesn't hide an ++ // id-truncation regression. ++ if (id.len > message.context_signal.id.len) { ++ log.warn("OSC 3008: context_signal id truncated from {d} to {d} bytes", .{ ++ id.len, message.context_signal.id.len, ++ }); ++ } ++ const id_len = @min(id.len, message.context_signal.id.len); ++ @memcpy(message.context_signal.id[0..id_len], id[0..id_len]); ++ message.context_signal.id[id_len] = 0; ++ ++ if (metadata.len > message.context_signal.metadata.len) { ++ log.warn("OSC 3008: context_signal metadata truncated from {d} to {d} bytes", .{ ++ metadata.len, message.context_signal.metadata.len, ++ }); ++ } ++ const metadata_len = @min(metadata.len, message.context_signal.metadata.len); ++ @memcpy(message.context_signal.metadata[0..metadata_len], metadata[0..metadata_len]); ++ message.context_signal.metadata[metadata_len] = 0; ++ ++ self.surfaceMessageWriter(message); ++ } ++ + /// Send a report to the pty. + pub fn sendSizeReport(self: *StreamHandler, style: terminal.SizeReportStyle) void { + switch (style) { diff --git a/supacode/Features/AgentPresence/Reducer/AgentPresenceFeature.swift b/supacode/Features/AgentPresence/Reducer/AgentPresenceFeature.swift index fc0870527..f9e68c6bf 100644 --- a/supacode/Features/AgentPresence/Reducer/AgentPresenceFeature.swift +++ b/supacode/Features/AgentPresence/Reducer/AgentPresenceFeature.swift @@ -4,8 +4,6 @@ import Foundation import Sharing import SupacodeSettingsShared -private let presenceLogger = SupaLogger("AgentPresence") - @Reducer struct AgentPresenceFeature { /// Activity state per (surface, agent). Set atomically by the wire events @@ -34,6 +32,11 @@ struct AgentPresenceFeature { nonisolated struct PresenceRecord: Equatable, Sendable { var activity: Activity = .idle + /// Local pids attributed to this record. Empty means the OSC presence was + /// emitted without a local pid (SSH attach); `pids.isEmpty` is the + /// discriminator for the pid-less lifecycle branches below. Every event + /// arrives over OSC now, so there is no "socket-owned" record to defend + /// against. var pids: Set } @@ -71,7 +74,8 @@ struct AgentPresenceFeature { @ObservableState struct State: Equatable { /// Per-(surface, agent) record. Pids drive the liveness sweep and record - /// disposal. All bridges require a pid in the envelope. + /// disposal. Socket bridges carry a pid; the OSC-over-SSH transport seeds + /// pid-less records that the sweep skips. var records: [PresenceKey: PresenceRecord] = [:] /// Per-surface agent presence. A surface can host multiple agents (rare, /// but possible if e.g. Claude spawns Codex). Order not guaranteed; sort before display. @@ -161,40 +165,68 @@ struct AgentPresenceFeature { let key = PresenceKey(agent: agent, surfaceID: event.surfaceID) switch event.eventName { case .sessionStart: - guard let pid = event.pid else { return [] } - var record = state.records[key] ?? PresenceRecord(pids: []) - let inserted = record.pids.insert(pid).inserted - state.records[key] = record + // A pid is the local-hook source (OSC presence carries `pid=$PPID` only on + // the local host); a missing pid is the OSC-over-SSH source, which attributes + // by the receiving surface and has no local pid to track. + if let pid = event.pid { + var record = state.records[key] ?? PresenceRecord(pids: []) + let inserted = record.pids.insert(pid).inserted + state.records[key] = record + rebuildPresence(forSurface: event.surfaceID, in: &state) + return inserted ? [event.surfaceID] : [] + } + // Pid-less OSC seed: don't clobber a record that already carries a pid. + guard state.records[key] == nil else { return [] } + state.records[key] = PresenceRecord(pids: []) rebuildPresence(forSurface: event.surfaceID, in: &state) - return inserted ? [event.surfaceID] : [] + return [event.surfaceID] case .sessionEnd: - guard let pid = event.pid, var record = state.records[key] else { return [] } - let removed = record.pids.remove(pid) != nil - if record.pids.isEmpty { - state.records.removeValue(forKey: key) - } else { - state.records[key] = record + if let pid = event.pid { + guard var record = state.records[key] else { return [] } + let removed = record.pids.remove(pid) != nil + if record.pids.isEmpty { + state.records.removeValue(forKey: key) + } else { + state.records[key] = record + } + rebuildPresence(forSurface: event.surfaceID, in: &state) + return removed ? [event.surfaceID] : [] } + // Pid-less (OSC over SSH): only tear down a pid-less record; never one + // that carries a tracked local pid the liveness sweep still owns. + guard let record = state.records[key], record.pids.isEmpty else { return [] } + state.records.removeValue(forKey: key) rebuildPresence(forSurface: event.surfaceID, in: &state) - return removed ? [event.surfaceID] : [] + return [event.surfaceID] case .busy: - return setActivity(.busy, for: key, in: &state) ? [event.surfaceID] : [] + return applyActivity(.busy, event: event, key: key, into: &state) ? [event.surfaceID] : [] case .awaitingInput: - return setActivity(.awaitingInput, for: key, in: &state) ? [event.surfaceID] : [] + return applyActivity(.awaitingInput, event: event, key: key, into: &state) ? [event.surfaceID] : [] case .idle: - return setActivity(.idle, for: key, in: &state) ? [event.surfaceID] : [] + return applyActivity(.idle, event: event, key: key, into: &state) ? [event.surfaceID] : [] case .notification, .none: return [] } } - /// No-op on identical activity so repeated same-event hook bursts (e.g. consecutive - /// PreToolUse) don't churn observers; the busy/idle flip itself is a real transition, - /// debounced upstream. Returns true when the record actually flipped. - private static func setActivity(_ activity: Activity, for key: PresenceKey, in state: inout State) -> Bool { - guard var record = state.records[key], record.activity != activity else { return false } - record.activity = activity - state.records[key] = record + /// Auto-seed only on the OSC path (pid == nil), and only when the activity + /// would actually carry a badge: SSH attach can land on `busy` / + /// `awaiting_input` with no prior `session_start`, but an `idle` arriving + /// after the `session_end` + `idle` composite shutdown emit must NOT + /// re-create the record. A pid-less idle re-seed would be skipped by the + /// liveness sweep and pinned until surface close. + private static func applyActivity( + _ activity: Activity, event: AgentHookEvent, key: PresenceKey, into state: inout State + ) -> Bool { + if var record = state.records[key] { + guard record.activity != activity else { return false } + record.activity = activity + state.records[key] = record + return true + } + guard event.pid == nil, activity != .idle else { return false } + state.records[key] = PresenceRecord(activity: activity, pids: []) + rebuildPresence(forSurface: event.surfaceID, in: &state) return true } @@ -258,6 +290,8 @@ struct AgentPresenceFeature { for (surfaceID, records) in layout.allAgentRecords() { for record in records { guard let agent = SkillAgent(rawValue: record.agent) else { continue } + // Pid-less OSC records aren't restore-durable: they persist with no + // pid, so they drop here and re-seed on the next OSC event post-relaunch. let pids = Set(record.pids.filter { $0 > 0 }) guard !pids.isEmpty else { continue } let activity = Activity(rawValue: record.activity) ?? .idle @@ -283,6 +317,7 @@ struct AgentPresenceFeature { var dirtySurfaces: Set = [] for (key, record) in records { if state.records[key] != nil { continue } + // Restored records always have alive pids (pid-less OSC records are dropped in stageRestore). state.records[key] = PresenceRecord(activity: record.activity, pids: record.alivePids) dirtySurfaces.insert(key.surfaceID) } diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 5dc2df244..cf89089a4 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -149,18 +149,6 @@ final class WorktreeTerminalManager { } private func configureSocketServer(_ server: AgentHookSocketServer) { - server.onNotification = { [weak self] worktreeID, _, surfaceID, notification in - guard let self else { return } - guard self.settingsFile.global.richAgentNotificationsEnabled else { return } - let decoded = worktreeID.removingPercentEncoding ?? worktreeID - guard let state = self.states[decoded] else { - terminalLogger.debug("Dropped hook notification for unknown worktree \(decoded)") - return - } - let title = notification.title ?? notification.agent - let body = notification.body ?? "" - state.appendHookNotification(title: title, body: body, surfaceID: surfaceID) - } server.onCommand = { [weak self] deeplinkURL, clientFD in guard let handler = self?.onDeeplinkCommand else { AgentHookSocketServer.sendCommandResponse(clientFD: clientFD, ok: false, error: "Not ready.") @@ -175,18 +163,10 @@ final class WorktreeTerminalManager { } handler(resource, params, clientFD) } - // Always record; the badges toggle gates DISPLAY in - // `AgentPresenceFeature.State.agents(forSurface:badgesEnabled:)`. - // Gating recording too would drop session_start events fired while - // the toggle was off, so flipping it back on later wouldn't restore - // badges for already-running agents. - server.onEvent = { [weak self] event in - self?.dispatchHookEvent(event) - } } /// Holds `.idle` for a debounce window so PostToolUse / PreToolUse storms don't flap downstream UI. - /// Lives at the socket boundary so the debounce applies before the event lands in TCA. + /// Applies the idle debounce before the OSC-sourced event lands in TCA. private func dispatchHookEvent(_ event: AgentHookEvent) { guard let agent = SkillAgent(rawValue: event.agent) else { applyHookEvent(event) @@ -484,6 +464,10 @@ final class WorktreeTerminalManager { state.onSurfacesClosed = { [weak self] ids in self?.emit(.surfacesClosed(ids)) } + // OSC-sourced presence events go through the existing idle-debounce funnel. + state.onAgentHookEvent = { [weak self] event in + self?.dispatchHookEvent(event) + } state.onNotificationReceived = { [weak self] surfaceID, title, body in self?.emit( .notificationReceived( diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 957d99fee..07d7c57f7 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -5,9 +5,14 @@ import Foundation import GhosttyKit import IdentifiedCollections import Observation +import Security import Sharing import SupacodeSettingsShared +#if !DEBUG + import Sentry +#endif + private let blockingScriptLogger = SupaLogger("BlockingScript") private let layoutLogger = SupaLogger("Layout") private let terminalStateLogger = SupaLogger("Terminal") @@ -88,11 +93,49 @@ final class WorktreeTerminalState { /// doesn't invalidate every leaf; the per-instance `hasUnseenNotification` is /// the observed signal. @ObservationIgnored private(set) var surfaceStates: [UUID: WorktreeSurfaceState] = [:] + /// Per-surface secret nonce echoed in OSC 3008 presence signals, compared + /// against the incoming token before the signal is trusted. + @ObservationIgnored private var oscTokensBySurfaceID: [UUID: String] = [:] var notificationsEnabled = true @ObservationIgnored @Dependency(\.date.now) private var now @ObservationIgnored @Dependency(\.zmxClient) private var zmxClient @ObservationIgnored @Dependency(\.analyticsClient) private var analyticsClient - private var recentHookBySurfaceID: [UUID: (text: String, recordedAt: Date)] = [:] + @ObservationIgnored @Dependency(\.continuousClock) private var clock + /// When a custom (hook / OSC 3008) notification last committed per surface. + /// Stored as a monotonic instant so the suppression window and the OSC-9 hold + /// share one clock source and can't desync on an NTP step / manual clock change. + private var lastCustomNotificationAt: [UUID: any InstantProtocol] = [:] + /// Agent OSC 9 notifications held to see if a custom notification supersedes them. + private var pendingAgentOSCNotifications: [UUID: Task] = [:] + /// How long after a custom notification the agent's own OSC 9 is suppressed. + /// Split from `oscHoldWindow` so tuning the suppression side cannot silently + /// change the hold side. + private static let oscSuppressionAfterCustom: TimeInterval = 0.5 + /// How long the agent's own OSC 9 is held before firing, waiting for a custom + /// notification to supersede it. Covers the socket-vs-inline-stream arrival skew. + private static let oscHoldWindow: TimeInterval = 0.5 + /// Monotonic gap between two instants from the same clock. Opens the existentials + /// so the suppression window can compare instants of the type-erased clock. + private static func elapsed( + from start: any InstantProtocol, + to end: any InstantProtocol + ) -> Duration { + func gap(_ start: I, _ end: any InstantProtocol) -> Duration + where I.Duration == Duration { + guard let end = end as? I else { + // Fail OPEN: a type mismatch must not pin the dedupe window true forever. + assertionFailure("clock instant type mismatch") + return .seconds(Self.oscSuppressionAfterCustom + 1) + } + return start.duration(to: end) + } + return gap(start, end) + } + #if DEBUG + var debugCustomNotificationTimestampCount: Int { lastCustomNotificationAt.count } + var debugPendingOSCCount: Int { pendingAgentOSCNotifications.count } + func debugOSCToken(forSurfaceID surfaceID: UUID) -> String? { oscTokensBySurfaceID[surfaceID] } + #endif var hasUnseenNotification: Bool { notifications.contains { !$0.isRead } } @@ -117,11 +160,6 @@ final class WorktreeTerminalState { notifications.filter { !$0.isRead }.sorted { $0.createdAt > $1.createdAt } } - #if DEBUG - var debugRecentHookCount: Int { - recentHookBySurfaceID.count - } - #endif var isSelected: () -> Bool = { false } var onNotificationReceived: ((UUID, String, String) -> Void)? var onNotificationIndicatorChanged: (() -> Void)? @@ -137,6 +175,9 @@ final class WorktreeTerminalState { var onSetupScriptConsumed: (() -> Void)? /// Forwarded to the manager so it can emit a `surfacesClosed` event into TCA. var onSurfacesClosed: ((Set) -> Void)? + /// Forwarded to the manager's `dispatchHookEvent` so an OSC-sourced presence + /// event joins the same funnel as the socket path (idle-debounce, badge). + var onAgentHookEvent: ((AgentHookEvent) -> Void)? /// Fires when a tab's per-tab projection (surfaces / focus / unseen count) /// drifts. Manager forwards into `TerminalTabFeature.State` via /// `tabProjectionChanged` so the leaf observes a per-tab store. @@ -1307,6 +1348,11 @@ final class WorktreeTerminalState { let currentPath = ProcessInfo.processInfo.environment["PATH"] ?? "" env["PATH"] = currentPath.isEmpty ? cliBinDir : "\(cliBinDir):\(currentPath)" } + // Per-surface OSC presence capability nonce. Present only on Supacode + // surfaces, so the hook treats its absence as the no-op-outside-Supacode gate. + let oscToken = Self.makeOSCToken() + oscTokensBySurfaceID[surfaceID] = oscToken + env[AgentPresenceOSC.tokenEnvVar] = oscToken return env } @@ -1422,7 +1468,11 @@ final class WorktreeTerminalState { } view.bridge.onDesktopNotification = { [weak self, weak view] title, body in guard let self, let view else { return } - self.appendNotification(title: title, body: body, surfaceID: view.id) + self.handleAgentOSCNotification(title: title, body: body, surfaceID: view.id) + } + view.bridge.onContextSignal = { [weak self, weak view] _, id, metadata in + guard let self, let view else { return } + self.handleContextSignal(surfaceID: view.id, id: id, metadata: metadata) } view.bridge.onCloseRequest = { [weak self, weak view] processAlive in guard let self, let view else { return } @@ -1439,6 +1489,186 @@ final class WorktreeTerminalState { } } + /// Routes an OSC 3008 context signal to the presence or notify handler. + private func handleContextSignal(surfaceID: UUID, id: String, metadata: String) { + // Route by notify INTENT, not by parse success: a notify whose payload was + // truncated past the 2047-byte cap fails parseNotify, and must log as a + // notify drop rather than silently falling through to the presence handler. + if AgentPresenceOSC.isNotifyMetadata(metadata) { + handleNotifySignal(surfaceID: surfaceID, id: id, metadata: metadata) + } else { + handlePresenceSignal(surfaceID: surfaceID, id: id, metadata: metadata) + } + } + + /// Verify an OSC 3008 presence signal against the receiving surface's nonce, + /// then synthesize an `AgentHookEvent` and forward it to the manager. Attribution + /// is by the receiving surface, so the wire never carries a surface id that could + /// spoof another worktree's badge; a pid rides along only for local hooks. + private func handlePresenceSignal(surfaceID: UUID, id: String, metadata: String) { + switch Self.presenceEvent( + id: id, + metadata: metadata, + expectedToken: oscTokensBySurfaceID[surfaceID], + surfaceID: surfaceID, + surfaceExists: surfaces[surfaceID] != nil + ) { + case .success(let event): + onAgentHookEvent?(event) + case .failure(.tokenMismatch(let agent, let event)): + // A wrong token is a potential spoof over the wider SSH capability; warn. + terminalStateLogger.warning( + "Rejected OSC presence with mismatched token for surface \(surfaceID), agent=\(agent), event=\(event).") + case .failure(.parseFailed): + // Malformed metadata on a live surface is probe-shaped; warn (mirrors notify). + terminalStateLogger.warning("Dropped malformed OSC presence signal for surface \(surfaceID).") + case .failure(.unknownSurface), .failure(.noToken): + terminalStateLogger.debug("Dropped OSC presence signal for surface \(surfaceID).") + } + } + + /// Typed reasons a presence signal was dropped, so the single call site can pick a + /// log severity per cause (warn for spoof-shaped failures, debug otherwise). + enum PresenceDrop: Error, Equatable { + case unknownSurface + case noToken + case parseFailed + case tokenMismatch(agent: String, event: String) + } + + /// Pure trust decision for an OSC presence signal: returns an `AgentHookEvent` + /// attributed to the RECEIVING surface only when the surface is known and the + /// echoed token matches its nonce; otherwise a typed `PresenceDrop` so the caller + /// can log per cause. The wire never carries a surface id (so a payload can't + /// spoof another worktree). The pid is trusted via the per-surface token, not + /// verified local: a forged positive pid at worst pins a live-looking badge until + /// surface close. The parser still rejects a non-positive pid before it could + /// reach the sweep. + nonisolated static func presenceEvent( + id: String, + metadata: String, + expectedToken: String?, + surfaceID: UUID, + surfaceExists: Bool + ) -> Result { + guard surfaceExists else { return .failure(.unknownSurface) } + guard let expectedToken else { return .failure(.noToken) } + guard let signal = AgentPresenceOSC.parse(id: id, metadata: metadata) else { + return .failure(.parseFailed) + } + guard AgentPresenceOSC.tokensMatch(signal.token, expectedToken) else { + return .failure(.tokenMismatch(agent: signal.agent, event: signal.eventRawValue)) + } + return .success( + AgentHookEvent( + agent: signal.agent, event: signal.eventRawValue, surfaceID: surfaceID, pid: signal.pid)) + } + + /// Verify an OSC 3008 notify signal against the receiving surface's nonce, then + /// decode + sanitize the agent notification and display it via the same hook + /// path the socket uses. Gated by the rich-notifications setting like the socket. + private func handleNotifySignal(surfaceID: UUID, id: String, metadata: String) { + switch Self.notification( + id: id, + metadata: metadata, + expectedToken: oscTokensBySurfaceID[surfaceID], + surfaceExists: surfaces[surfaceID] != nil + ) { + case .success(let resolved): + // Gate AFTER trust check so the setting can't be probed via drop-rate signals. + @Shared(.settingsFile) var settingsFile + guard settingsFile.global.richAgentNotificationsEnabled else { + terminalStateLogger.debug("Dropped trusted OSC notify; rich notifications disabled.") + return + } + appendHookNotification(title: resolved.title, body: resolved.body, surfaceID: surfaceID) + case .failure(.tokenMismatch(let agent)): + // A wrong token is a potential spoof over the wider SSH capability; warn. + terminalStateLogger.warning( + "Rejected OSC notify with mismatched token for surface \(surfaceID), agent=\(agent).") + case .failure(.parseFailed): + // A payload truncated past the metadata cap fails base64 decode here. + // Log the byte count so a Ghostty truncation can be correlated with the drop. + terminalStateLogger.warning( + "Dropped malformed OSC notify (metadata bytes: \(metadata.utf8.count), cap 2047) for surface \(surfaceID).") + case .failure(.unknownSurface), .failure(.missingToken), .failure(.empty): + terminalStateLogger.debug("Dropped OSC notify signal for surface \(surfaceID).") + } + } + + /// Typed reasons a notify signal was dropped, so the single call site can pick a + /// log severity per cause (warn for spoof-shaped failures, debug otherwise). + enum NotifyDrop: Error { + case unknownSurface + case missingToken + case tokenMismatch(agent: String) + case parseFailed + case empty + } + + /// Pure trust + parse decision for an OSC notify signal: returns the sanitized + /// (title, body) on success, or a typed `NotifyDrop` so the caller can log per + /// cause. Title/body are bounded and stripped of control characters since the + /// OSC leg is a wider capability than the local socket. + nonisolated static func notification( + id: String, + metadata: String, + expectedToken: String?, + surfaceExists: Bool + ) -> Result<(title: String, body: String), NotifyDrop> { + guard surfaceExists else { return .failure(.unknownSurface) } + guard let expectedToken else { return .failure(.missingToken) } + guard let notify = AgentPresenceOSC.parseNotify(id: id, metadata: metadata) else { + return .failure(.parseFailed) + } + guard AgentPresenceOSC.tokensMatch(notify.token, expectedToken) else { + return .failure(.tokenMismatch(agent: notify.agent)) + } + guard let parsed = AgentHookSocketServer.parseNotification(agent: notify.agent, data: notify.payload) + else { return .failure(.parseFailed) } + let title = sanitizeNotificationText(parsed.title ?? notify.agent, max: 200) + let body = sanitizeNotificationText(parsed.body ?? "", max: 1000) + guard !(title.isEmpty && body.isEmpty) else { return .failure(.empty) } + return .success((title, body)) + } + + /// Bound length and neutralize control characters in attacker-influenceable + /// notification text. Newline / tab / carriage return collapse to a space; + /// other C0 controls and DEL are dropped (defends against escape-sequence + /// injection into the toast). Length is capped in unicode scalars. + nonisolated static func sanitizeNotificationText(_ text: String, max: Int) -> String { + var scalars = String.UnicodeScalarView() + for scalar in text.unicodeScalars { + if scalars.count >= max { break } + switch scalar.value { + case 0x0A, 0x09, 0x0D: + scalars.append(" ") + case 0x00...0x1F, 0x7F: + continue + default: + scalars.append(scalar) + } + } + return String(scalars).trimmingCharacters(in: .whitespaces) + } + + /// 128-bit (32 hex char) nonce. Hex avoids the `;` / `=` / control bytes that + /// would corrupt the OSC 3008 metadata framing. + static func makeOSCToken() -> String { + var bytes = [UInt8](repeating: 0, count: 16) + if SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess { + return bytes.map { String(format: "%02x", $0) }.joined() + } + // RNG failure downgrades a security capability; surface to Sentry and fall back + // to arc4random_buf (always available, no failure path) rather than UUID entropy. + terminalStateLogger.error("SecRandomCopyBytes failed; OSC token falling back to arc4random_buf.") + #if !DEBUG + SentrySDK.logger.error("SecRandomCopyBytes failed; OSC token degraded to arc4random_buf.") + #endif + arc4random_buf(&bytes, bytes.count) + return bytes.map { String(format: "%02x", $0) }.joined() + } + struct ResolvedLaunch { var command: String? var initialInput: String? @@ -1560,25 +1790,56 @@ final class WorktreeTerminalState { return trees[tabId]?.visibleLeaves().first?.id } - /// Appends a notification from an agent hook on a specific surface. + /// Appends a notification from a custom (hook / OSC 3008) source. Records the + /// time so the agent's own OSC 9 for the same event is deduped, and cancels any + /// OSC 9 currently held for this surface (the expanded one supersedes it). func appendHookNotification(title: String, body: String, surfaceID: UUID) { guard surfaces[surfaceID] != nil else { terminalStateLogger.debug("Dropped hook notification for unknown surface \(surfaceID) in worktree \(worktree.id)") return } - // Record for deduplication against later OSC 9 notifications. - if let normalized = Self.normalizedText("\(title) \(body)") { - recentHookBySurfaceID[surfaceID] = (text: normalized, recordedAt: now) + lastCustomNotificationAt[surfaceID] = clock.now + if let superseded = pendingAgentOSCNotifications.removeValue(forKey: surfaceID) { + superseded.cancel() + terminalStateLogger.debug( + "Dropped held agent OSC 9 for surface \(surfaceID) in worktree \(worktree.id): superseded by hook notification" + ) } - appendNotification(title: title, body: body, surfaceID: surfaceID, fromHook: true) + appendNotification(title: title, body: body, surfaceID: surfaceID) } - private func appendNotification( - title: String, - body: String, - surfaceID: UUID, - fromHook: Bool = false - ) { + /// The agent's own OSC 9 desktop notification, a summary of the expanded custom + /// notification we ship. Deduped: dropped if a custom notification just + /// committed for this surface (hook-first); otherwise held briefly and dropped + /// if a custom one supersedes it during the hold (OSC-9-first), else shown. + private func handleAgentOSCNotification(title: String, body: String, surfaceID: UUID) { + if let last = lastCustomNotificationAt[surfaceID], + Self.elapsed(from: last, to: clock.now) <= .seconds(Self.oscSuppressionAfterCustom) + { + terminalStateLogger.debug( + "Dropped agent OSC 9 for surface \(surfaceID) in \(worktree.id): custom notification within dedupe window" + ) + return + } + let clock = clock + pendingAgentOSCNotifications.removeValue(forKey: surfaceID)?.cancel() + pendingAgentOSCNotifications[surfaceID] = Task { [weak self] in + do { + try await clock.sleep(for: .seconds(Self.oscHoldWindow)) + } catch is CancellationError { + return + } catch { + terminalStateLogger.error("OSC 9 hold sleep failed: \(error)") + return + } + guard !Task.isCancelled, let self else { return } + self.pendingAgentOSCNotifications.removeValue(forKey: surfaceID) + guard self.surfaces[surfaceID] != nil else { return } + self.appendNotification(title: title, body: body, surfaceID: surfaceID) + } + } + + private func appendNotification(title: String, body: String, surfaceID: UUID) { let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) guard !(trimmedTitle.isEmpty && trimmedBody.isEmpty) else { return } @@ -1601,55 +1862,22 @@ final class WorktreeTerminalState { } emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) } - // Suppress OSC 9 system notifications that duplicate a recent hook notification. - if !fromHook, shouldSuppressDesktopNotification(title: trimmedTitle, body: trimmedBody, surfaceID: surfaceID) { - return - } onNotificationReceived?(surfaceID, trimmedTitle, trimmedBody) } - // MARK: - Notification deduplication (matches supaterm's approach). - - private static let notificationCoalescingWindow: TimeInterval = 2 - - private static let genericCompletionTexts: Set = [ - "agent turn complete", - "task complete", - "turn complete", - ] - - private func shouldSuppressDesktopNotification(title: String, body: String, surfaceID: UUID) -> Bool { - guard - let terminalText = Self.normalizedText("\(title) \(body)"), - let recent = recentHookBySurfaceID[surfaceID], - now.timeIntervalSince(recent.recordedAt) <= Self.notificationCoalescingWindow - else { - return false - } - if terminalText == recent.text { return true } - if recent.text.hasPrefix(terminalText) { return true } - if Self.genericCompletionTexts.contains(terminalText) { return true } - return false - } - - private static func normalizedText(_ value: String) -> String? { - let collapsed = - value - .split(whereSeparator: \.isWhitespace) - .joined(separator: " ") - .lowercased() - .trimmingCharacters(in: .punctuationCharacters) - return collapsed.isEmpty ? nil : collapsed - } - /// Detaches one surface from the local bookkeeping. The zmx session is NOT /// killed here; callers route the kill through `killZmxSessions(forSurfaceIDs:)` /// so a single multi-pane close emits one `count=N` analytics event + one /// `withTaskGroup` instead of N events and N detached Tasks. + /// Also cancels any held agent OSC 9 and forgets the per-surface OSC token + /// and last-custom-notification instant so a future surface ID can't be + /// authenticated with stale state. private func cleanupSurfaceState(for surfaceID: UUID) { - recentHookBySurfaceID.removeValue(forKey: surfaceID) + pendingAgentOSCNotifications.removeValue(forKey: surfaceID)?.cancel() + lastCustomNotificationAt.removeValue(forKey: surfaceID) surfaces.removeValue(forKey: surfaceID) surfaceStates.removeValue(forKey: surfaceID) + oscTokensBySurfaceID.removeValue(forKey: surfaceID) onSurfacesClosed?([surfaceID]) } @@ -2048,7 +2276,7 @@ final class WorktreeTerminalState { /// Test-only seam for bulk-assigning the notifications log. Fans /// `emitAllTabProjections()` so `lastTabProjections` stays in sync with /// the raw log; production code must go through the per-event helpers - /// (`appendNotification`, `markNotificationsRead`, etc.) which already + /// (`appendHookNotification`, `markNotificationsRead`, etc.) which already /// emit. Gated `#if DEBUG` so release builds genuinely can't reach the /// projection-bypass path. func setNotificationsForTesting(_ list: [WorktreeTerminalNotification]) { diff --git a/supacode/Infrastructure/AgentHookSocketServer.swift b/supacode/Infrastructure/AgentHookSocketServer.swift index 2eeb80b64..0e4280975 100644 --- a/supacode/Infrastructure/AgentHookSocketServer.swift +++ b/supacode/Infrastructure/AgentHookSocketServer.swift @@ -4,34 +4,25 @@ import SupacodeSettingsShared private nonisolated let socketLogger = SupaLogger("AgentHookSocket") -/// Lightweight Unix domain socket server that receives messages from -/// agent hooks (via `nc -U`) and the Supacode CLI tool. +/// Lightweight Unix domain socket server for the Supacode CLI control protocol. /// -/// Four message formats are supported: -/// - **Notification** (legacy text proto): ` \n\n`. -/// The agent field defaults to `"unknown"` when absent. Kept until rich -/// notifications migrate to the JSON envelope. -/// - **Command**: JSON object with a `"deeplink"` key wrapping a `supacode://` URL. -/// - **Query**: JSON object with a `"query"` key and optional parameters. -/// - **Hook event**: JSON object with a top-level `"event"` string, designed to -/// be extended without changing the wire format. Drives presence and -/// activity. See `AgentHookEvent` for the field shape and `EventName` for -/// known events. +/// Two message formats are supported, both JSON objects: +/// - **Command**: a `"deeplink"` key wrapping a `supacode://` URL. +/// - **Query**: a `"query"` key and optional parameters. +/// +/// Agent presence and notifications no longer travel over the socket. Hooks emit +/// them as OSC 3008 to the terminal (see `AgentPresenceOSC`), which also works +/// over SSH where this socket can't be reached; the socket carries only the CLI +/// control protocol. @MainActor final class AgentHookSocketServer { private(set) var socketPath: String? private var listenTask: Task? - /// (worktreeID, tabID, surfaceID, notification). - var onNotification: ((String, UUID, UUID, AgentHookNotification) -> Void)? /// Deeplink URL received from the CLI. Second parameter is the client FD for response. var onCommand: ((URL, Int32) -> Void)? /// Query received from the CLI. Parameters: resource name, extra params, client FD for response. var onQuery: ((String, [String: String], Int32) -> Void)? - /// Hook event from an agent bridge (the JSON envelope hook commands write - /// directly to the socket). Fire-and-forget — the server has already acked - /// the client by the time this fires, so handlers must not block. - var onEvent: ((AgentHookEvent) -> Void)? init() { let uid = getuid() @@ -118,8 +109,6 @@ final class AgentHookSocketServer { await MainActor.run { [weak self] in switch message { - case .notification(let worktreeID, let tabID, let surfaceID, let notification): - self?.onNotification?(worktreeID, tabID, surfaceID, notification) case .command(let deeplinkURL, let clientFD): guard let self, let handler = self.onCommand else { Self.sendCommandResponse(clientFD: clientFD, ok: false, error: "Not ready.") @@ -132,8 +121,6 @@ final class AgentHookSocketServer { return } handler(resource, params, clientFD) - case .event(let event): - self?.onEvent?(event) } } } @@ -213,15 +200,10 @@ final class AgentHookSocketServer { private nonisolated static let maxPayloadSize = 65_536 nonisolated enum Message: Sendable { - case notification( - worktreeID: String, tabID: UUID, surfaceID: UUID, notification: AgentHookNotification) /// CLI command with the client FD kept open for writing a response. case command(deeplinkURL: URL, clientFD: Int32) /// CLI query with the client FD kept open for writing data back. case query(resource: String, params: [String: String], clientFD: Int32) - /// Hook event from an agent bridge. Acked before dispatch, so the FD is - /// already closed by the time the handler runs. - case event(AgentHookEvent) } /// Writes a JSON response with data to a client and closes the FD. @@ -294,20 +276,12 @@ final class AgentHookSocketServer { return nil } - // For command/query messages, keep the FD open for the response. - // For event messages, ack immediately and close — handlers must not - // block agent hook execution waiting for app state. + // Command/query messages keep the FD open so the handler can write a response. switch message { case .command(let url, _): return .command(deeplinkURL: url, clientFD: clientFD) case .query(let resource, let params, _): return .query(resource: resource, params: params, clientFD: clientFD) - case .event: - sendCommandResponse(clientFD: clientFD, ok: true) - return message - default: - close(clientFD) - return message } } @@ -340,63 +314,26 @@ final class AgentHookSocketServer { nonisolated static func parse(data: Data) -> Message? { guard let rawString = String(data: data, encoding: .utf8) else { - socketLogger.warning("Dropped non-UTF8 hook payload (\(data.count) bytes)") + socketLogger.warning("Dropped non-UTF8 CLI payload (\(data.count) bytes)") return nil } - let raw = rawString.trimmingCharacters(in: .whitespacesAndNewlines) guard !raw.isEmpty else { - socketLogger.debug("Dropped empty hook payload") - return nil - } - - // JSON object starting with `{` → CLI message (event, query, or command). - if raw.hasPrefix("{") { - return parseJSONMessage(data: data) - } - - // Notification (legacy text proto): `worktreeID tabID surfaceID agent\n`. - // Activity events flow through the JSON envelope path above. - let lines = raw.split(separator: "\n", omittingEmptySubsequences: false) - let headerParts = lines[0].split(separator: " ", maxSplits: 3) - guard - headerParts.count >= 3, - let tabID = UUID(uuidString: String(headerParts[1])), - let surfaceID = UUID(uuidString: String(headerParts[2])) - else { - socketLogger.warning("Malformed header: \(lines[0])") - return nil - } - - let worktreeID = String(headerParts[0]) - - // Notification: 4th header field is the agent name; remaining lines are JSON. - // Agent is a raw string intentionally — left open for custom agents since - // the socket is already listening and anyone can send messages. - guard lines.count > 1 else { - socketLogger.warning("Single-line text payload no longer supported: \(lines[0])") - return nil - } - let agent = headerParts.count >= 4 ? String(headerParts[3]) : "unknown" - let jsonPayload = lines.dropFirst().joined(separator: "\n") - - guard let jsonData = jsonPayload.data(using: .utf8) else { - socketLogger.warning("Invalid notification payload encoding") + socketLogger.debug("Dropped empty CLI payload") return nil } - - guard let notification = parseNotification(agent: agent, data: jsonData) else { + // The CLI control protocol is always a JSON object (command or query). + guard raw.hasPrefix("{") else { + socketLogger.debug("Dropped non-JSON socket payload") return nil } - return .notification( - worktreeID: worktreeID, - tabID: tabID, - surfaceID: surfaceID, - notification: notification - ) + return parseJSONMessage(data: data) } - private nonisolated static func parseNotification( + /// Parses an agent notification JSON payload into an `AgentHookNotification`, + /// decoding the body from whichever agent-specific field is present. The OSC + /// notify leg is the sole caller; the socket no longer carries notifications. + nonisolated static func parseNotification( agent: String, data: Data ) -> AgentHookNotification? { @@ -418,24 +355,9 @@ final class AgentHookSocketServer { ) } - /// Parses a JSON message into an event, query, or command. The placeholder + /// Parses a CLI JSON message into a query or command. The placeholder /// `clientFD` of `-1` is replaced with the real FD in `acceptAndParse`. - /// - /// Discriminator precedence: a top-level `"event"` string routes to the - /// hook-event parser exclusively — an event-shaped payload never falls - /// through to query/command parsing, so a malformed event produces a single - /// clear warning instead of two misleading ones. private nonisolated static func parseJSONMessage(data: Data) -> Message? { - let probe = try? JSONDecoder().decode(EventDiscriminatorProbe.self, from: data) - if let event = probe?.event, !event.isEmpty { - do { - let parsed = try JSONDecoder().decode(AgentHookEvent.self, from: data) - return .event(parsed) - } catch { - socketLogger.warning("Hook event payload failed to decode: \(error)") - return nil - } - } guard let request = SocketCommandRequest(data: data) else { socketLogger.warning("Failed to decode CLI message payload") return nil @@ -453,13 +375,6 @@ final class AgentHookSocketServer { } } -/// Probe used to detect the `event` discriminator without paying for a full -/// envelope decode. Decoding this is cheap — the JSON parser walks until it -/// finds the field and ignores the rest. -private nonisolated struct EventDiscriminatorProbe: Decodable { - let event: String? -} - /// Parsed notification from a coding agent hook event. nonisolated struct AgentHookNotification: Equatable, Sendable { let agent: String @@ -468,12 +383,14 @@ nonisolated struct AgentHookNotification: Equatable, Sendable { let body: String? } -/// JSON envelope for hook events sent by the agent bridge. -/// `surface_id` is the only required scope field — tab and worktree are -/// derived app-side from the current terminal topology so a moved/split -/// surface never carries stale attribution. +/// An agent presence/activity event. Built by the OSC ingest from a verified +/// presence signal (memberwise init, attributed to the receiving surface), and +/// also `Decodable` from the legacy JSON envelope shape below for test +/// construction. `surface_id` is the only scope field; tab and worktree are +/// derived app-side from the current terminal topology so a moved/split surface +/// never carries stale attribution. /// -/// Wire shape: +/// JSON shape (the Decodable contract): /// ```json /// { /// "event": "session_start", // discriminator + event name (required) @@ -486,8 +403,8 @@ nonisolated struct AgentHookNotification: Equatable, Sendable { /// } /// ``` nonisolated struct AgentHookEvent: Equatable, Sendable, Decodable { - /// Known event names. Stored as raw `String` on the wire so unknown events - /// from a newer bridge / older app don't drop — handlers can ignore them. + /// Known event names. Stored as raw `String` so an unknown event from a newer + /// emitter / older app doesn't drop; handlers can ignore them. enum EventName: String, Sendable { case sessionStart = "session_start" case sessionEnd = "session_end" @@ -504,7 +421,7 @@ nonisolated struct AgentHookEvent: Equatable, Sendable, Decodable { let pid: pid_t? let timestamp: Date? /// Event-specific payload preserved as opaque JSON. Handlers decode with - /// their own typed shape via `decodeData(_:)` — keeps this layer decoupled + /// their own typed shape via `decodeData(_:)`, keeping this layer decoupled /// from per-event payload schemas. let data: JSONValue? @@ -513,7 +430,7 @@ nonisolated struct AgentHookEvent: Equatable, Sendable, Decodable { /// Decode the per-event `data` payload into a concrete type. Returns nil /// when `data` is missing. Returns nil and logs a warning when `data` is - /// present but fails to decode against `T` — silent nil there would make a + /// present but fails to decode against `T`; silent nil there would make a /// shape mismatch invisible to handlers. func decodeData(_ type: T.Type = T.self) -> T? { guard let data else { return nil } @@ -545,7 +462,7 @@ nonisolated struct AgentHookEvent: Equatable, Sendable, Decodable { self.agent = try container.decodeIfPresent(String.self, forKey: .agent) ?? "unknown" self.version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 if let rawPid = try container.decodeIfPresent(Int.self, forKey: .pid) { - // Reject 0 and negatives — `kill(0, 0)` succeeds for the caller's + // Reject 0 and negatives: `kill(0, 0)` succeeds for the caller's // process group and `kill(-N, 0)` for group N, both of which would // pin a permanent badge in the liveness sweep on a buggy hook (e.g. // `pid:$$` from a subshell that recycled). @@ -566,6 +483,34 @@ nonisolated struct AgentHookEvent: Equatable, Sendable, Decodable { self.data = try container.decodeIfPresent(JSONValue.self, forKey: .data) } + /// Memberwise init for synthesizing events from sources other than the JSON + /// wire. The OSC presence path uses it: attribution is by the receiving + /// surface and there is no remote pid, so `pid` defaults to nil. + init( + version: Int = 1, + agent: String, + event: String, + surfaceID: UUID, + pid: pid_t? = nil, + timestamp: Date? = nil, + data: JSONValue? = nil + ) { + self.version = version + self.agent = agent + self.event = event + self.surfaceID = surfaceID + // Mirror the Decodable validation: a non-positive pid would let `kill(0/-N, 0)` + // match the caller's process group and pin a permanent badge. + if let pid, pid <= 0 { + socketLogger.warning("Clamped non-positive pid \(pid) to nil in AgentHookEvent(agent: \(agent)).") + self.pid = nil + } else { + self.pid = pid + } + self.timestamp = timestamp + self.data = data + } + private static func decodeUUID( from container: KeyedDecodingContainer, forKey key: CodingKeys @@ -602,7 +547,7 @@ private nonisolated struct AgentHookPayload: Decodable { hookEventName = try container.decodeIfPresent(String.self, forKey: .hookEventName) title = try container.decodeIfPresent(String.self, forKey: .title) // Tolerate per-field decode errors (e.g. `"message": 42`) so a single - // malformed field does not drop the whole notification — fall through + // malformed field does not drop the whole notification; fall through // to the next candidate instead. let candidates = [CodingKeys.message, .lastAssistantMessage, .assistantResponse] .map { key in Self.decodeOptionalString(container, forKey: key) } diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift index 34e0311df..80821f63d 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift @@ -3,6 +3,8 @@ import Foundation import GhosttyKit import SupacodeSettingsShared +private let terminalStateLogger = SupaLogger("Terminal") + enum GhosttyOpenURLKind: Equatable { case unknown case text @@ -62,7 +64,13 @@ final class GhosttySurfaceBridge { // blockingScripts dict in the handlers. var onCommandFinished: ((Int?) -> Void)? var onChildExited: ((UInt32) -> Void)? + // The agent's own OSC 9 desktop notification. Deduped against our richer custom + // notification one layer up. var onDesktopNotification: ((String, String) -> Void)? + // OSC 3008 context signal: (action 0=start/1=end, context id, raw key=value + // metadata). Forwarded raw; the per-surface capability token carried in the + // metadata is verified one layer up where the surface's nonce lives. + var onContextSignal: ((UInt8, String, String) -> Void)? // Coalesce OSC-9 progress: a flush task applies the latest value at the // throttle cadence while it moves, and a slow stale-watch clears a bar whose @@ -103,6 +111,7 @@ final class GhosttySurfaceBridge { if let handled = handleAppAction(action) { return handled } if let handled = handleSplitAction(action) { return handled } if handleTitleAndPath(action) { return false } + if handleContextSignal(action) { return false } if handleCommandStatus(action) { return false } if handleMouseAndLink(action) { return action.tag == GHOSTTY_ACTION_OPEN_URL @@ -286,6 +295,17 @@ final class GhosttySurfaceBridge { } } + private func handleContextSignal(_ action: ghostty_action_s) -> Bool { + guard action.tag == GHOSTTY_ACTION_CONTEXT_SIGNAL else { return false } + let signal = action.action.context_signal + guard let id = string(from: signal.id), let metadata = string(from: signal.metadata) else { + terminalStateLogger.warning("OSC 3008 context signal arrived with null id or metadata") + return true + } + onContextSignal?(signal.action, id, metadata) + return true + } + private func handleCommandStatus(_ action: ghostty_action_s) -> Bool { switch action.tag { case GHOSTTY_ACTION_PROGRESS_REPORT: diff --git a/supacodeTests/AgentBusyStateTests.swift b/supacodeTests/AgentBusyStateTests.swift index 5699c77e2..a620b8968 100644 --- a/supacodeTests/AgentBusyStateTests.swift +++ b/supacodeTests/AgentBusyStateTests.swift @@ -32,6 +32,52 @@ struct AgentBusyStateTests { #expect(!fixture.isBusy) } + @Test func presenceWithMismatchedTokenIsDroppedFromLiveBridge() { + let fixture = makeStateWithSurface() + var forwardedEvents = 0 + fixture.state.onAgentHookEvent = { _ in forwardedEvents += 1 } + + // Drive the live `onContextSignal` callpath with a presence payload whose + // token doesn't match the surface's nonce: the manager-side hook must never see it. + fixture.surface.bridge.onContextSignal?(0, "claude", "event=busy;token=WRONG") + + #expect(forwardedEvents == 0) + #expect(!fixture.isBusy) + } + + @Test func presenceWithOtherSurfaceTokenIsDroppedFromLiveBridge() { + // Two live surfaces share the worktree state. Surface A's real token must not + // validate when echoed on surface B's bridge: tokens are per-surface, so a + // payload that authenticates for A is a spoof for B. + let (manager, _) = WorktreeTerminalManager.withPresenceHarness() + let worktree = makeWorktree() + let state = manager.state(for: worktree) { false } + let tabAId = state.createTab()! + let tabBId = state.createTab()! + let surfaceA = state.splitTree(for: tabAId).root!.leftmostLeaf() + let surfaceB = state.splitTree(for: tabBId).root!.leftmostLeaf() + guard let tokenA = state.debugOSCToken(forSurfaceID: surfaceA.id) else { + Issue.record("Expected an OSC token for surface A") + return + } + + var forwardedForA = 0 + var forwardedForB = 0 + let previous = state.onAgentHookEvent + state.onAgentHookEvent = { event in + if event.surfaceID == surfaceA.id { forwardedForA += 1 } + if event.surfaceID == surfaceB.id { forwardedForB += 1 } + previous?(event) + } + + // Deliver a presence signal carrying A's token on B's bridge: the receiving + // surface is B, so the expected token is B's nonce and A's token must mismatch. + surfaceB.bridge.onContextSignal?(0, "claude", "event=busy;token=\(tokenA)") + + #expect(forwardedForA == 0) + #expect(forwardedForB == 0) + } + @Test func activityEventForUnknownSurfaceIsNoOp() { let fixture = makeStateWithSurface() @@ -106,6 +152,7 @@ struct AgentBusyStateTests { @Test(.dependencies) func hookNotificationRecordedForDedup() { withDependencies { $0.date = .constant(Date(timeIntervalSince1970: 1000)) + $0.continuousClock = ImmediateClock() } operation: { let fixture = makeStateWithSurface() @@ -120,132 +167,140 @@ struct AgentBusyStateTests { } } - @Test(.dependencies) func oscNotificationSuppressedWithinWindow() { + @Test(.dependencies) func agentOSCSuppressedWhenCustomFiredFirst() { withDependencies { $0.date = .constant(Date(timeIntervalSince1970: 1000)) + $0.continuousClock = ImmediateClock() } operation: { let fixture = makeStateWithSurface() - var systemNotificationCount = 0 - fixture.state.onNotificationReceived = { _, _, _ in - systemNotificationCount += 1 - } - - // Hook notification fires system notification. - fixture.state.appendHookNotification( - title: "Done", - body: "Task complete", - surfaceID: fixture.surface.id, - ) - #expect(systemNotificationCount == 1) + var systemCount = 0 + fixture.state.onNotificationReceived = { _, _, _ in systemCount += 1 } - // OSC 9 with identical text within the 2s window (via bridge callback). - fixture.surface.bridge.onDesktopNotification?("Done", "Task complete") + // Hook-first: our expanded custom notification fires. + fixture.state.appendHookNotification(title: "Done", body: "Expanded detail", surfaceID: fixture.surface.id) + #expect(systemCount == 1) - // The system notification should be suppressed (still 1). - #expect(systemNotificationCount == 1) - // But the in-app notification is still recorded. - #expect(fixture.state.notifications.count == 2) + // The agent's own OSC 9 summary for the same surface is dropped immediately. + fixture.surface.bridge.onDesktopNotification?("Done", "summary") + #expect(systemCount == 1) + #expect(fixture.state.notifications.count == 1) + #expect(fixture.state.debugPendingOSCCount == 0) } } - @Test(.dependencies) func oscNotificationNotSuppressedAfterWindow() { - let baseDate = Date(timeIntervalSince1970: 1000) - let currentDate = LockIsolated(baseDate) - - withDependencies { - $0.date = .init { currentDate.value } + @Test(.dependencies) func agentOSCNotSuppressedAfterWindow() async { + let clock = TestClock() + await withDependencies { + $0.date = .constant(Date(timeIntervalSince1970: 1000)) + $0.continuousClock = clock } operation: { let fixture = makeStateWithSurface() - var systemNotificationCount = 0 - fixture.state.onNotificationReceived = { _, _, _ in - systemNotificationCount += 1 - } - - // Hook notification at t=1000. - fixture.state.appendHookNotification( - title: "Done", - body: "All complete", - surfaceID: fixture.surface.id, - ) - #expect(systemNotificationCount == 1) - - // OSC 9 at t=1003 (beyond the 2s window). - currentDate.setValue(baseDate.addingTimeInterval(3)) - fixture.surface.bridge.onDesktopNotification?("Done", "All complete") - - // Not suppressed — fires system notification. - #expect(systemNotificationCount == 2) + var systemCount = 0 + fixture.state.onNotificationReceived = { _, _, _ in systemCount += 1 } + + // Custom notification fires, then the suppression window fully elapses. + fixture.state.appendHookNotification(title: "Done", body: "Expanded detail", surfaceID: fixture.surface.id) + #expect(systemCount == 1) + await clock.advance(by: .seconds(0.6)) + + // The OSC 9 now lands outside the window, so it is held instead of dropped. + fixture.surface.bridge.onDesktopNotification?("Done", "summary") + await Task.megaYield() + #expect(systemCount == 1) + #expect(fixture.state.debugPendingOSCCount == 1) + + // After its own hold it shows. + await clock.advance(by: .seconds(0.5)) + await Task.megaYield() + #expect(systemCount == 2) } } - @Test(.dependencies) func genericCompletionTextSuppressedWithinWindow() { - withDependencies { + @Test(.dependencies) func agentOSCDroppedWhenCustomSupersedesDuringHold() async { + let clock = TestClock() + await withDependencies { $0.date = .constant(Date(timeIntervalSince1970: 1000)) + $0.continuousClock = clock } operation: { let fixture = makeStateWithSurface() - var systemNotificationCount = 0 - fixture.state.onNotificationReceived = { _, _, _ in - systemNotificationCount += 1 - } + var systemCount = 0 + fixture.state.onNotificationReceived = { _, _, _ in systemCount += 1 } + + // OSC-9-first: the agent's summary arrives and is held. + fixture.surface.bridge.onDesktopNotification?("Done", "summary") + await Task.megaYield() + #expect(systemCount == 0) + #expect(fixture.state.debugPendingOSCCount == 1) + + // Our expanded custom notification lands during the hold: it shows and the + // held OSC 9 is dropped. + fixture.state.appendHookNotification(title: "Done", body: "Expanded detail", surfaceID: fixture.surface.id) + #expect(systemCount == 1) + #expect(fixture.state.debugPendingOSCCount == 0) + + await clock.advance(by: .seconds(0.5)) + await Task.megaYield() + #expect(systemCount == 1) + } + } - // Hook notification with specific text. - fixture.state.appendHookNotification( - title: "Claude", - body: "Refactored the module", - surfaceID: fixture.surface.id, - ) - #expect(systemNotificationCount == 1) + @Test(.dependencies) func agentOSCShownAfterHoldWithoutCustom() async { + let clock = TestClock() + await withDependencies { + $0.date = .constant(Date(timeIntervalSince1970: 1000)) + $0.continuousClock = clock + } operation: { + let fixture = makeStateWithSurface() + var systemCount = 0 + fixture.state.onNotificationReceived = { _, _, _ in systemCount += 1 } - // OSC 9 with generic "Task Complete" text. - fixture.surface.bridge.onDesktopNotification?("Task Complete", "") + fixture.surface.bridge.onDesktopNotification?("Agent", "standalone") + await Task.megaYield() + #expect(systemCount == 0) - // Generic completion text is suppressed. - #expect(systemNotificationCount == 1) + // No custom notification superseded it, so after the hold it shows. + await clock.advance(by: .seconds(0.5)) + await Task.megaYield() + #expect(systemCount == 1) } } - @Test(.dependencies) func closingTabCleansRecentHookEntries() { - withDependencies { + @Test(.dependencies) func closingSurfaceCancelsHeldOSC() async { + let clock = TestClock() + await withDependencies { $0.date = .constant(Date(timeIntervalSince1970: 1000)) + $0.continuousClock = clock } operation: { let fixture = makeStateWithSurface() - - fixture.state.appendHookNotification( - title: "Done", - body: "All complete", - surfaceID: fixture.surface.id, - ) - #expect(fixture.state.debugRecentHookCount == 1) + var systemCount = 0 + fixture.state.onNotificationReceived = { _, _, _ in systemCount += 1 } + // No prior custom notification, so the OSC 9 is held (not suppressed). + fixture.surface.bridge.onDesktopNotification?("Agent", "held") + await Task.megaYield() + #expect(fixture.state.debugPendingOSCCount == 1) fixture.state.closeTab(fixture.tabId) + #expect(fixture.state.debugPendingOSCCount == 0) - #expect(fixture.state.debugRecentHookCount == 0) + // Advancing past the hold window proves the cancelled OSC never fires late. + await clock.advance(by: .seconds(0.5)) + await Task.megaYield() + #expect(systemCount == 0) + #expect(fixture.state.notifications.count == 0) } } - @Test(.dependencies) func closingSurfaceCleansRecentHookEntries() { + @Test(.dependencies) func closingSurfaceClearsCustomNotificationTimestamp() { withDependencies { $0.date = .constant(Date(timeIntervalSince1970: 1000)) + $0.continuousClock = ImmediateClock() } operation: { let fixture = makeStateWithSurface() - #expect(fixture.state.performSplitAction(.newSplit(direction: .right), for: fixture.surface.id)) - - let leaves = fixture.state.splitTree(for: fixture.tabId).leaves() - guard let splitSurface = leaves.first(where: { $0.id != fixture.surface.id }) else { - Issue.record("Expected split surface") - return - } + fixture.state.appendHookNotification(title: "Done", body: "x", surfaceID: fixture.surface.id) + #expect(fixture.state.debugCustomNotificationTimestampCount == 1) - fixture.state.appendHookNotification( - title: "Done", - body: "All complete", - surfaceID: splitSurface.id, - ) - #expect(fixture.state.debugRecentHookCount == 1) - - splitSurface.bridge.onCloseRequest?(false) - - #expect(fixture.state.debugRecentHookCount == 0) + fixture.state.closeTab(fixture.tabId) + #expect(fixture.state.debugCustomNotificationTimestampCount == 0) } } @@ -300,7 +355,7 @@ struct AgentBusyStateTests { "surface_id": "\(surfaceID.uuidString)"\(pidLine) } """ - guard case .event(let event) = AgentHookSocketServer.parse(data: Data(json.utf8)) else { + guard let event = try? JSONDecoder().decode(AgentHookEvent.self, from: Data(json.utf8)) else { preconditionFailure("Failed to parse test event") } return event diff --git a/supacodeTests/AgentHookCommandTests.swift b/supacodeTests/AgentHookCommandTests.swift index e54d21e43..e5b58bd7b 100644 --- a/supacodeTests/AgentHookCommandTests.swift +++ b/supacodeTests/AgentHookCommandTests.swift @@ -7,16 +7,16 @@ import Testing struct AgentHookCommandTests { // MARK: - Command generation. - @Test func compositeEnvelopeContainsEventName() { + @Test func compositeBusyCarriesOSCBusyEvent() { let command = AgentHookSettingsCommand.compositeCommand( events: [.busy], forwardStdinAsNotification: false, agent: .claude) - #expect(command.contains(#"\"event\":\"busy\""#)) + #expect(command.contains("event=busy")) } - @Test func compositeIdleEnvelopeContainsIdle() { + @Test func compositeIdleCarriesOSCIdleEvent() { let command = AgentHookSettingsCommand.compositeCommand( events: [.idle], forwardStdinAsNotification: false, agent: .claude) - #expect(command.contains(#"\"event\":\"idle\""#)) + #expect(command.contains("event=idle")) } // MARK: - Claude canonical hook map. @@ -28,8 +28,8 @@ struct AgentHookCommandTests { let postToolUse = try #require(groups["PostToolUse"]) let commands = Self.commandStrings(in: postToolUse) #expect(!commands.isEmpty) - #expect(commands.allSatisfy { $0.contains(#"\"event\":\"idle\""#) }) - #expect(commands.allSatisfy { !$0.contains(#"\"event\":\"busy\""#) }) + #expect(commands.allSatisfy { $0.contains("event=idle") }) + #expect(commands.allSatisfy { !$0.contains("event=busy") }) } @Test func claudePreToolUseOrdersAwaitingAfterBusy() throws { @@ -44,12 +44,12 @@ struct AgentHookCommandTests { let first = try #require(preToolUse.first) #expect(first.objectValue?["matcher"]?.stringValue == "") let firstCommand = try #require(Self.commandStrings(in: [first]).first) - #expect(firstCommand.contains(#"\"event\":\"busy\""#)) + #expect(firstCommand.contains("event=busy")) let second = try #require(preToolUse.last) #expect(second.objectValue?["matcher"]?.stringValue == "AskUserQuestion|ExitPlanMode") let secondCommand = try #require(Self.commandStrings(in: [second]).first) - #expect(secondCommand.contains(#"\"event\":\"awaiting_input\""#)) + #expect(secondCommand.contains("event=awaiting_input")) } private static func commandStrings(in groups: [JSONValue]) -> [String] { @@ -60,13 +60,16 @@ struct AgentHookCommandTests { } } - @Test func compositeChecksAllFourEnvVars() { + @Test func compositeGuardsOnTokenAndSurfaceOnly() { + // OSC is the only transport now: the guard is the per-surface capability + // token plus the surface id. The worktree / tab ids the socket envelope + // carried are no longer referenced anywhere in the command. let command = AgentHookSettingsCommand.compositeCommand( events: [.busy], forwardStdinAsNotification: false, agent: .claude) - #expect(command.contains("SUPACODE_SOCKET_PATH")) - #expect(command.contains("SUPACODE_WORKTREE_ID")) - #expect(command.contains("SUPACODE_TAB_ID")) + #expect(command.contains("SUPACODE_OSC_TOKEN")) #expect(command.contains("SUPACODE_SURFACE_ID")) + #expect(!command.contains("SUPACODE_WORKTREE_ID")) + #expect(!command.contains("SUPACODE_TAB_ID")) } @Test func compositeSuppressesErrorsAndCarriesSentinel() { @@ -82,11 +85,15 @@ struct AgentHookCommandTests { #expect(command.contains("claude")) } - @Test func compositeNotifyIncludesAllThreeIDs() { - let command = AgentHookSettingsCommand.compositeCommand(events: [], forwardStdinAsNotification: true, agent: .codex) - #expect(command.contains("$SUPACODE_WORKTREE_ID")) - #expect(command.contains("$SUPACODE_TAB_ID")) - #expect(command.contains("$SUPACODE_SURFACE_ID")) + @Test func notifyDoesNotReferenceWorktreeOrTabIDs() { + // The notify leg used to prefix a `worktree tab surface agent` header for + // the socket text proto. The OSC notify carries only the base64 payload and + // the per-surface token, so those ids must be gone. + let command = AgentHookSettingsCommand.compositeCommand( + events: [], forwardStdinAsNotification: true, agent: .codex) + #expect(!command.contains("SUPACODE_WORKTREE_ID")) + #expect(!command.contains("SUPACODE_TAB_ID")) + #expect(command.contains("SUPACODE_SURFACE_ID")) } // MARK: - Command ownership. @@ -135,10 +142,8 @@ struct AgentHookCommandTests { } @Test func userAuthoredHookFollowingDocumentedSocketPatternIsNotOwned() { - // The CLI skill env table and Pi extension docs tell users to write - // hooks against `SUPACODE_SOCKET_PATH` via `/usr/bin/nc -U`. A - // user-authored hook following that exact pattern but lacking the - // sentinel marker must NOT be classified as legacy — otherwise + // A user-authored hook that talks to the socket via `/usr/bin/nc -U` but + // lacks the sentinel marker must NOT be classified as legacy. Otherwise // install would silently strip it on the next run. let userHook = #"[ -n "$SUPACODE_SOCKET_PATH" ] && echo "x" | /usr/bin/nc -U -w1 "$SUPACODE_SOCKET_PATH" || true"# @@ -163,7 +168,7 @@ struct AgentHookCommandTests { // direct-nc era) shelled out to `supacode integration event`. // Strip-on-update must still recognise it as Supacode-managed, // otherwise the canonical hook is appended on top instead of - // replacing it — producing duplicate SessionStart hooks. + // replacing it, producing duplicate SessionStart hooks. let legacy = #"[ -n "${SUPACODE_SOCKET_PATH:-}" ] && supacode integration event session_start"# + #" --agent claude --pid "$PPID" 2>/dev/null || true"# @@ -172,11 +177,10 @@ struct AgentHookCommandTests { } @Test func managedCommandSilencesStdoutAndStderr() { - // Codex parses SessionStart hook stdout as structured JSON output - // and rejects anything that doesn't match its hook output schema — - // so the `{"ok":true}` ack the socket server writes back through - // `nc` would fail the run. Hook commands must redirect both - // streams to /dev/null. + // Codex parses SessionStart hook stdout as structured JSON output and + // rejects anything that doesn't match its hook output schema, so the OSC + // escape bytes must never leak onto stdout. Hook commands redirect both + // streams to /dev/null (the OSC itself goes straight to /dev/tty). let busy = AgentHookSettingsCommand.compositeCommand( events: [.busy], forwardStdinAsNotification: false, agent: .claude) let session = AgentHookSettingsCommand.compositeCommand( @@ -187,12 +191,16 @@ struct AgentHookCommandTests { // MARK: - Shared constants consistency. - @Test func socketPathEnvVarPresentInGeneratedCommands() { - let busy = AgentHookSettingsCommand.compositeCommand( + @Test func socketPathGatesThePresencePidSuffixOnly() { + // `SUPACODE_SOCKET_PATH` survives in the command solely as the local-host + // gate for the pid suffix on presence; the notify-only command (no pid) + // never references it. + let presence = AgentHookSettingsCommand.compositeCommand( events: [.busy], forwardStdinAsNotification: false, agent: .claude) - let notify = AgentHookSettingsCommand.compositeCommand(events: [], forwardStdinAsNotification: true, agent: .claude) - #expect(busy.contains(AgentHookSettingsCommand.socketPathEnvVar)) - #expect(notify.contains(AgentHookSettingsCommand.socketPathEnvVar)) + let notifyOnly = AgentHookSettingsCommand.compositeCommand( + events: [], forwardStdinAsNotification: true, agent: .claude) + #expect(presence.contains(AgentHookSettingsCommand.socketPathEnvVar)) + #expect(!notifyOnly.contains(AgentHookSettingsCommand.socketPathEnvVar)) } // MARK: - compositeCommand branches. @@ -201,29 +209,31 @@ struct AgentHookCommandTests { let composite = AgentHookSettingsCommand.compositeCommand( events: [.sessionEnd, .idle], forwardStdinAsNotification: false, agent: .claude ) - #expect(composite.contains(#"\"event\":\"session_end\""#)) - #expect(composite.contains(#"\"event\":\"idle\""#)) - #expect(composite.contains("{ printf")) - #expect(composite.contains("; }")) - // Order matters: session_end envelope is emitted before idle so the - // socket server sees the lifecycle close-out before the activity reset. - let sessionEndIdx = composite.range(of: "session_end")?.lowerBound - let idleIdx = composite.range(of: #"\"event\":\"idle\""#)?.lowerBound + #expect(composite.contains("event=session_end")) + #expect(composite.contains("event=idle")) + // Both presence emits live inside one guarded brace group (opened by the tty + // resolve) that closes before the error-suppression tail. + #expect(composite.contains("&& { __tty=")) + #expect(composite.contains("; } >/dev/null 2>&1 || true")) + // Order matters: the session_end presence is emitted before idle so the app + // sees the lifecycle close-out before the activity reset. + let sessionEndIdx = composite.range(of: "event=session_end")?.lowerBound + let idleIdx = composite.range(of: "event=idle")?.lowerBound if let sessionEndIdx, let idleIdx { #expect(sessionEndIdx < idleIdx) } } - @Test func compositeEventsPlusNotifyStashesStdinBeforeEmittingEnvelopes() { + @Test func compositeEventsPlusNotifyEmitsPresenceBeforeNotify() { let composite = AgentHookSettingsCommand.compositeCommand( events: [.idle], forwardStdinAsNotification: true, agent: .claude ) - #expect(composite.contains("payload=$(cat)")) - #expect(composite.contains(#"printf '%s' "$payload""#)) - let stashIdx = composite.range(of: "payload=$(cat)")?.lowerBound - let envelopeIdx = composite.range(of: #"\"event\":\"idle\""#)?.lowerBound - if let stashIdx, let envelopeIdx { - #expect(stashIdx < envelopeIdx) + #expect(composite.contains("event=idle")) + #expect(composite.contains("kind=notify")) + let presenceIdx = composite.range(of: "event=idle")?.lowerBound + let notifyIdx = composite.range(of: "kind=notify")?.lowerBound + if let presenceIdx, let notifyIdx { + #expect(presenceIdx < notifyIdx) } } @@ -239,186 +249,282 @@ struct AgentHookCommandTests { let composite = AgentHookSettingsCommand.compositeCommand( events: [.busy], forwardStdinAsNotification: false, agent: .claude ) - let expected = - #"[ -n "${SUPACODE_SOCKET_PATH:-}" ] && [ -n "${SUPACODE_WORKTREE_ID:-}" ] "# - + #"&& [ -n "${SUPACODE_TAB_ID:-}" ] && [ -n "${SUPACODE_SURFACE_ID:-}" ] && "# - + #"printf '%s' "{\"event\":\"busy\",\"v\":1,\"agent\":\"claude\","# - + #"\"surface_id\":\"$SUPACODE_SURFACE_ID\",\"pid\":$PPID}" "# - + #"| /usr/bin/nc -U -w1 "$SUPACODE_SOCKET_PATH" >/dev/null 2>&1 || true # supacode-managed-hook"# + let expected = Self.snapshotClaudeBusy #expect(composite == expected) } - @Test func compositeByteSnapshot_claudeStopIdleAndNotify() { + @Test func compositeByteSnapshot_claudeIdleAndNotify() { let composite = AgentHookSettingsCommand.compositeCommand( events: [.idle], forwardStdinAsNotification: true, agent: .claude ) - let expected = - #"[ -n "${SUPACODE_SOCKET_PATH:-}" ] && [ -n "${SUPACODE_WORKTREE_ID:-}" ] "# - + #"&& [ -n "${SUPACODE_TAB_ID:-}" ] && [ -n "${SUPACODE_SURFACE_ID:-}" ] && "# - + #"{ payload=$(cat); "# - + #"printf '%s' "{\"event\":\"idle\",\"v\":1,\"agent\":\"claude\","# - + #"\"surface_id\":\"$SUPACODE_SURFACE_ID\",\"pid\":$PPID}" "# - + #"| /usr/bin/nc -U -w1 "$SUPACODE_SOCKET_PATH"; "# - + #"{ printf '%s claude\n' "$SUPACODE_WORKTREE_ID $SUPACODE_TAB_ID $SUPACODE_SURFACE_ID"; "# - + #"printf '%s' "$payload"; } "# - + #"| /usr/bin/nc -U -w1 "$SUPACODE_SOCKET_PATH"; } >/dev/null 2>&1 || true # supacode-managed-hook"# - #expect(composite == expected) + #expect(composite == Self.snapshotClaudeIdleAndNotify) } @Test func compositeByteSnapshot_claudeSessionEndAndIdle() { - // Multi-event branch with no stdin forward. Covers the brace-grouped - // shape used by Claude `SessionEnd`. Without this, a refactor of the - // multi-event template silently flips every existing install to - // `.outdated` and auto-update rewrites every settings.json. let composite = AgentHookSettingsCommand.compositeCommand( events: [.sessionEnd, .idle], forwardStdinAsNotification: false, agent: .claude ) - let expected = - #"[ -n "${SUPACODE_SOCKET_PATH:-}" ] && [ -n "${SUPACODE_WORKTREE_ID:-}" ] "# - + #"&& [ -n "${SUPACODE_TAB_ID:-}" ] && [ -n "${SUPACODE_SURFACE_ID:-}" ] && "# - + #"{ printf '%s' "{\"event\":\"session_end\",\"v\":1,\"agent\":\"claude\","# - + #"\"surface_id\":\"$SUPACODE_SURFACE_ID\",\"pid\":$PPID}" "# - + #"| /usr/bin/nc -U -w1 "$SUPACODE_SOCKET_PATH"; "# - + #"printf '%s' "{\"event\":\"idle\",\"v\":1,\"agent\":\"claude\","# - + #"\"surface_id\":\"$SUPACODE_SURFACE_ID\",\"pid\":$PPID}" "# - + #"| /usr/bin/nc -U -w1 "$SUPACODE_SOCKET_PATH"; } >/dev/null 2>&1 || true # supacode-managed-hook"# - #expect(composite == expected) + #expect(composite == Self.snapshotClaudeSessionEndAndIdle) } - @Test func compositeByteSnapshot_codexStopIdleAndNotify() { - // Per-agent templating parity. A refactor of `\(agent.rawValue)` in - // the envelope or notify pipeline could regress Codex/Kiro without - // tripping the Claude snapshots. + @Test func compositeByteSnapshot_codexIdleAndNotify() { let composite = AgentHookSettingsCommand.compositeCommand( events: [.idle], forwardStdinAsNotification: true, agent: .codex ) - let expected = - #"[ -n "${SUPACODE_SOCKET_PATH:-}" ] && [ -n "${SUPACODE_WORKTREE_ID:-}" ] "# - + #"&& [ -n "${SUPACODE_TAB_ID:-}" ] && [ -n "${SUPACODE_SURFACE_ID:-}" ] && "# - + #"{ payload=$(cat); "# - + #"printf '%s' "{\"event\":\"idle\",\"v\":1,\"agent\":\"codex\","# - + #"\"surface_id\":\"$SUPACODE_SURFACE_ID\",\"pid\":$PPID}" "# - + #"| /usr/bin/nc -U -w1 "$SUPACODE_SOCKET_PATH"; "# - + #"{ printf '%s codex\n' "$SUPACODE_WORKTREE_ID $SUPACODE_TAB_ID $SUPACODE_SURFACE_ID"; "# - + #"printf '%s' "$payload"; } "# - + #"| /usr/bin/nc -U -w1 "$SUPACODE_SOCKET_PATH"; } >/dev/null 2>&1 || true # supacode-managed-hook"# - #expect(composite == expected) + #expect(composite == Self.snapshotCodexIdleAndNotify) } - @Test func compositeByteSnapshot_kiroStopIdleAndNotify() { + @Test func compositeByteSnapshot_kiroIdleAndNotify() { let composite = AgentHookSettingsCommand.compositeCommand( events: [.idle], forwardStdinAsNotification: true, agent: .kiro ) + #expect(composite == Self.snapshotKiroIdleAndNotify) + } + + /// Cross-check against a fully-inlined literal so a refactor that drifts both + /// the production code AND the `presence` / `guardAndTTY` helpers cannot + /// pass byte-stability. The other snapshots compose from helpers that mirror + /// the production code structure, so they only catch drift if exactly one + /// side moves. + @Test func compositeByteSnapshot_claudeBusy_inlineLiteral() { + let composite = AgentHookSettingsCommand.compositeCommand( + events: [.busy], forwardStdinAsNotification: false, agent: .claude + ) let expected = - #"[ -n "${SUPACODE_SOCKET_PATH:-}" ] && [ -n "${SUPACODE_WORKTREE_ID:-}" ] "# - + #"&& [ -n "${SUPACODE_TAB_ID:-}" ] && [ -n "${SUPACODE_SURFACE_ID:-}" ] && "# - + #"{ payload=$(cat); "# - + #"printf '%s' "{\"event\":\"idle\",\"v\":1,\"agent\":\"kiro\","# - + #"\"surface_id\":\"$SUPACODE_SURFACE_ID\",\"pid\":$PPID}" "# - + #"| /usr/bin/nc -U -w1 "$SUPACODE_SOCKET_PATH"; "# - + #"{ printf '%s kiro\n' "$SUPACODE_WORKTREE_ID $SUPACODE_TAB_ID $SUPACODE_SURFACE_ID"; "# - + #"printf '%s' "$payload"; } "# - + #"| /usr/bin/nc -U -w1 "$SUPACODE_SOCKET_PATH"; } >/dev/null 2>&1 || true # supacode-managed-hook"# + #"[ -n "${SUPACODE_OSC_TOKEN:-}" ] && [ -n "${SUPACODE_SURFACE_ID:-}" ] && { "# + + #"__tty=$(ps -o tty= -p "$PPID" 2>/dev/null | tr -d '[:space:]'); "# + + #"case "$__tty" in *[0-9]*) __tty="/dev/${__tty#/dev/}";; *) __tty="/dev/tty";; esac; "# + + #"__sp=""; [ -n "${SUPACODE_SOCKET_PATH:-}" ] && __sp=";pid=$PPID"; "# + + #"printf '\033]3008;start=claude;event=busy;token=%s%s\033\\' "# + + #""$SUPACODE_OSC_TOKEN" "$__sp" > "$__tty"; "# + + #"} >/dev/null 2>&1 || true # supacode-managed-hook"# #expect(composite == expected) } - // MARK: - Envelope round-trip. + // MARK: - OSC presence emission. + + @Test func compositeEmitsOSCPresenceGuardedByToken() { + let command = AgentHookSettingsCommand.compositeCommand( + events: [.busy], forwardStdinAsNotification: false, agent: .claude) + // OSC is the sole transport, gated only by the per-surface token (no-op + // outside Supacode) and the surface id. It fires local and remote alike. + #expect(command.contains("]3008;start=claude;event=busy;")) + #expect(command.contains(#"[ -n "${SUPACODE_OSC_TOKEN:-}" ]"#)) + #expect(command.contains("token=%s")) + #expect(command.contains(#"> "$__tty""#)) + #expect(command.contains("ps -o tty=")) + #expect(!command.contains(#"[ -z "${SUPACODE_SOCKET_PATH:-}" ]"#)) + } + + @Test func sessionStartComposesOSCPresenceForClaudeAndCodex() { + for agent in [SkillAgent.claude, .codex] { + let command = AgentHookSettingsCommand.compositeCommand( + events: [.sessionStart], forwardStdinAsNotification: false, agent: agent) + #expect(command.contains("]3008;start=\(agent.rawValue);event=session_start;")) + } + } + + @Test func sessionEndUsesOSCEndAction() { + let command = AgentHookSettingsCommand.compositeCommand( + events: [.sessionEnd, .idle], forwardStdinAsNotification: false, agent: .claude) + #expect(command.contains("]3008;end=claude;event=session_end;")) + } + + @Test func awaitingInputComposesOSCPresence() { + // awaiting_input is the badge-critical "needs you" state; assert it rides OSC too. + let command = AgentHookSettingsCommand.compositeCommand( + events: [.awaitingInput], forwardStdinAsNotification: false, agent: .claude) + #expect(command.contains("]3008;start=claude;event=awaiting_input;")) + } + + @Test func notifyOnlyComposesNotifyOSCButNoPresenceOSC() { + // Notify-only (no events) emits the notify OSC but no presence OSC. + let command = AgentHookSettingsCommand.compositeCommand( + events: [], forwardStdinAsNotification: true, agent: .claude) + #expect(command.contains("]3008;start=claude;kind=notify;")) + #expect(!command.contains(";event=")) + } + + @Test func notifyComposesOSCNotify() { + let command = AgentHookSettingsCommand.compositeCommand( + events: [.idle], forwardStdinAsNotification: true, agent: .claude) + #expect(command.contains("]3008;start=claude;kind=notify;token=%s;data=%s")) + #expect(command.contains("base64 | tr -d")) + } + + @Test func eventOnlyCommandEmitsNoOSCNotify() { + let command = AgentHookSettingsCommand.compositeCommand( + events: [.busy], forwardStdinAsNotification: false, agent: .claude) + #expect(!command.contains("kind=notify")) + } + + // MARK: - Runtime behaviour (real shell). - /// Executes the command in a real shell with all required env vars set - /// and a fake `nc` on PATH that captures stdin to a file. Verifies the - /// JSON the hook produced is parseable by the same code that consumes - /// it on the socket — a regression guard against future Swift changes - /// that subtly break the envelope template. - @Test func compositeEnvelopeProducesParseableJSON() throws { + @Test func presenceCarriesLocalPidButNotRemote() throws { + // The pid suffix is the local/remote discriminator: present when + // SUPACODE_SOCKET_PATH is set (local host), absent over SSH. A regression + // that always or never emitted it would silently break the liveness sweep. + let base: [String: String] = [ + "SUPACODE_OSC_TOKEN": "testtoken", + "SUPACODE_SURFACE_ID": UUID().uuidString, + ] + let command = AgentHookSettingsCommand.compositeCommand( + events: [.busy], forwardStdinAsNotification: false, agent: .claude) + + // Local (socket present): the presence OSC carries a positive pid. + let local = try runHookCommandCapturingTTY( + command, env: base.merging(["SUPACODE_SOCKET_PATH": "/tmp/sock-\(UUID().uuidString)"]) { $1 }) + let localSignal = try #require(Self.parsePresence(fromTTY: local)) + #expect(localSignal.eventRawValue == "busy") + #expect(localSignal.token == "testtoken") + #expect((localSignal.pid ?? 0) > 0) + + // Remote (socket absent): the presence OSC lands but carries no pid. + let remote = try runHookCommandCapturingTTY(command, env: base) + let remoteSignal = try #require(Self.parsePresence(fromTTY: remote)) + #expect(remoteSignal.eventRawValue == "busy") + #expect(remoteSignal.token == "testtoken") + #expect(remoteSignal.pid == nil) + } + + @Test func notifyBase64sStdinPayload() throws { + let json = #"{"hook_event_name":"Stop","message":"hi there"}"# + let base: [String: String] = [ + "SUPACODE_OSC_TOKEN": "tok", + "SUPACODE_SURFACE_ID": UUID().uuidString, + ] + let command = AgentHookSettingsCommand.compositeCommand( + events: [], forwardStdinAsNotification: true, agent: .claude) + let tty = try runHookCommandCapturingTTY(command, env: base, stdin: json) + #expect(tty.contains("]3008;start=claude;kind=notify;token=tok;data=")) + #expect(tty.contains("data=\(Data(json.utf8).base64EncodedString())")) + } + + @Test func eventsPlusNotifyDeliverPresenceAndFullStdin() throws { + // events + notify: both legs fire, and the notify leg must receive the + // COMPLETE JSON (not a partial read), proving the guarded-body stdin handoff. + let json = #"{"hook_event_name":"Stop","message":"the full message survives intact"}"# + let base: [String: String] = [ + "SUPACODE_OSC_TOKEN": "tok", + "SUPACODE_SURFACE_ID": UUID().uuidString, + ] + let command = AgentHookSettingsCommand.compositeCommand( + events: [.idle], forwardStdinAsNotification: true, agent: .claude) + let tty = try runHookCommandCapturingTTY(command, env: base, stdin: json) + #expect(tty.contains("]3008;start=claude;event=idle;")) + #expect(tty.contains("data=\(Data(json.utf8).base64EncodedString())")) + } + + @Test func emitsNothingOutsideSupacode() throws { + // No OSC token = not a Supacode surface: the guard short-circuits and the + // command writes nothing to the tty (the inert-outside-Supacode contract). + let command = AgentHookSettingsCommand.compositeCommand( + events: [.busy], forwardStdinAsNotification: true, agent: .claude) + let tty = try runHookCommandCapturingTTY( + command, env: ["SUPACODE_SURFACE_ID": UUID().uuidString], stdin: "{}") + #expect(tty.isEmpty) + } + + // MARK: - OSC presence round-trip. + + @Test func presenceOSCRoundTripsThroughParser() throws { + // The shell-produced OSC must parse back into a well-formed, pid-bearing + // signal. A guard against a template change that subtly breaks the wire. let surfaceID = UUID() - let agentPid: pid_t = getpid() - let captured = try runHookCommandCapturingStdin( + let captured = try runHookCommandCapturingTTY( AgentHookSettingsCommand.compositeCommand( events: [.sessionStart], forwardStdinAsNotification: false, agent: .claude), env: [ - "SUPACODE_SOCKET_PATH": "/tmp/supacode-roundtrip-\(UUID().uuidString)", - "SUPACODE_WORKTREE_ID": "/some/worktree", - "SUPACODE_TAB_ID": UUID().uuidString, + "SUPACODE_OSC_TOKEN": "rttoken", "SUPACODE_SURFACE_ID": surfaceID.uuidString, - ] - ) - guard case .event(let parsed) = AgentHookSocketServer.parse(data: captured) else { - Issue.record("Expected parser to recognise envelope; got nil/non-event from \(captured.count) bytes") - return - } - #expect(parsed.eventName == .sessionStart) - #expect(parsed.agent == "claude") - #expect(parsed.surfaceID == surfaceID) - // PPID inside the shell is whatever spawned it (Process), not the - // test's pid — so just check it's positive and decodes cleanly. - #expect((parsed.pid ?? 0) > 0) - } - - /// Composite shell roundtrip for `forwardStdinAsNotification: true`: - /// pipes representative Claude `Stop` JSON through the `idle + notify` - /// composite, captures both `nc -U` writes, and asserts the parser - /// recognises the first as an idle event and the second as a notification. - @Test func compositeIdleAndNotifyProducesParseableEventThenNotification() throws { - let surfaceID = UUID() - let stopPayload = - #"{"stop_hook_active":false,"hook_event_name":"Stop","last_assistant_message":"Done."}"# - let captures = try runHookCommandCapturingMultipleStdin( - AgentHookSettingsCommand.compositeCommand( - events: [.idle], forwardStdinAsNotification: true, agent: .claude - ), - stdin: stopPayload, - env: [ "SUPACODE_SOCKET_PATH": "/tmp/supacode-rt-\(UUID().uuidString)", - "SUPACODE_WORKTREE_ID": "/some/worktree", - "SUPACODE_TAB_ID": UUID().uuidString, - "SUPACODE_SURFACE_ID": surfaceID.uuidString, ] ) - #expect(captures.count == 2) - guard captures.count == 2 else { return } - guard case .event(let event) = AgentHookSocketServer.parse(data: captures[0]) else { - Issue.record("Expected first capture to be an idle event envelope") - return - } - #expect(event.eventName == .idle) - #expect(event.surfaceID == surfaceID) - guard - case .notification(_, _, let notifSurfaceID, let notification) = AgentHookSocketServer.parse(data: captures[1]) - else { - Issue.record("Expected second capture to be a notification (header + payload)") - return - } - #expect(notification.agent == "claude") - #expect(notifSurfaceID == surfaceID) - // The notification body decodes from `last_assistant_message`, confirming - // `$(cat)` preserved the payload across the brace-grouped pipeline. - #expect(notification.body == "Done.") - } - - /// Multi-nc variant of `runHookCommandCapturingStdin`. The stub appends - /// each invocation's stdin to a single file separated by a sentinel - /// line, then we split on the sentinel to return one `Data` per `nc`. - private func runHookCommandCapturingMultipleStdin( - _ command: String, stdin: String, env: [String: String] - ) throws -> [Data] { + let signal = try #require(Self.parsePresence(fromTTY: captured)) + #expect(signal.agent == "claude") + #expect(signal.eventRawValue == "session_start") + #expect(signal.token == "rttoken") + // PPID inside the shell is whatever spawned it (Process), not the test's + // pid, so just check it decoded as positive. + #expect((signal.pid ?? 0) > 0) + } + + /// Reconstructs libghostty's OSC 3008 split from a captured tty stream: the + /// first `verb=id` field becomes the context id, the rest is the metadata + /// `parse` consumes. Returns the parsed, not-yet-trusted signal. + private static func parsePresence(fromTTY tty: String) -> AgentPresenceOSC.Signal? { + guard let marker = tty.range(of: "]3008;") else { return nil } + let afterMarker = tty[marker.upperBound...] + guard let stRange = afterMarker.range(of: "\u{1b}\\") else { return nil } + let body = afterMarker[../dev/null | tr -d '[:space:]'); "# + + #"case "$__tty" in *[0-9]*) __tty="/dev/${__tty#/dev/}";; *) __tty="/dev/tty";; esac; "# + private static let suppressTail = #"} >/dev/null 2>&1 || true # supacode-managed-hook"# + + private static func presence(_ action: String, _ agent: String, _ event: String) -> String { + #"__sp=""; [ -n "${SUPACODE_SOCKET_PATH:-}" ] && __sp=";pid=$PPID"; "# + + #"printf '\033]3008;\#(action)=\#(agent);event=\#(event);token=%s%s\033\\' "# + + #""$SUPACODE_OSC_TOKEN" "$__sp" > "$__tty"; "# + } + + private static func notify(_ agent: String) -> String { + #"__osc_d=$(base64 | tr -d '\n'); "# + + #"printf '\033]3008;start=\#(agent);kind=notify;token=%s;data=%s\033\\' "# + + #""$SUPACODE_OSC_TOKEN" "$__osc_d" > "$__tty"; "# + } + + static let snapshotClaudeBusy = + guardAndTTY + presence("start", "claude", "busy") + suppressTail + + static let snapshotClaudeIdleAndNotify = + guardAndTTY + presence("start", "claude", "idle") + notify("claude") + suppressTail + + static let snapshotClaudeSessionEndAndIdle = + guardAndTTY + presence("end", "claude", "session_end") + presence("start", "claude", "idle") + suppressTail + + static let snapshotCodexIdleAndNotify = + guardAndTTY + presence("start", "codex", "idle") + notify("codex") + suppressTail + + static let snapshotKiroIdleAndNotify = + guardAndTTY + presence("start", "kiro", "idle") + notify("kiro") + suppressTail + + /// Runs `command` with `/dev/tty` (the OSC sink) redirected to a capture file, + /// optionally feeding `stdin`, and returns the text written to the fake tty. + private func runHookCommandCapturingTTY( + _ command: String, env: [String: String], stdin: String = "" + ) throws -> String { let workDir = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent("supacode-hook-multi-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("supacode-hook-tty-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: workDir) } - let stubBin = workDir.appendingPathComponent("bin", isDirectory: true) - try FileManager.default.createDirectory(at: stubBin, withIntermediateDirectories: true) - let stubNC = stubBin.appendingPathComponent("nc") - let captureFile = workDir.appendingPathComponent("capture") - let boundary = "---NC-CAPTURE-BOUNDARY---" - try "#!/bin/sh\ncat >> '\(captureFile.path)'\nprintf '\\n\(boundary)\\n' >> '\(captureFile.path)'\n" - .write(to: stubNC, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: stubNC.path) - let patched = command.replacing("/usr/bin/nc", with: stubNC.path) + let captureFile = workDir.appendingPathComponent("tty") + FileManager.default.createFile(atPath: captureFile.path, contents: nil) + // Append (`>>`) not truncate: a real /dev/tty streams, so multiple OSC writes + // (presence + notify) must both land. A plain `> file` would have the second + // printf clobber the first. + let patched = command.replacing(#"> "$__tty""#, with: ">> \(captureFile.path)") let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/zsh") process.arguments = ["-c", patched] var environment = ProcessInfo.processInfo.environment + // The host may already export Supacode-surface vars (tests can run inside a + // Supacode surface); clear all three so every absent-variable assertion is genuine. + environment.removeValue(forKey: "SUPACODE_SOCKET_PATH") + environment.removeValue(forKey: "SUPACODE_OSC_TOKEN") + environment.removeValue(forKey: "SUPACODE_SURFACE_ID") for (key, value) in env { environment[key] = value } process.environment = environment let stdinPipe = Pipe() @@ -427,47 +533,6 @@ struct AgentHookCommandTests { stdinPipe.fileHandleForWriting.write(Data(stdin.utf8)) try? stdinPipe.fileHandleForWriting.close() process.waitUntilExit() - - let raw = (try? Data(contentsOf: captureFile)) ?? Data() - guard let text = String(data: raw, encoding: .utf8) else { return [] } - return text.components(separatedBy: "\n\(boundary)\n") - .dropLast() // trailing element after the last boundary is empty. - .map { Data($0.utf8) } - } - - /// Run `command` via `/bin/zsh -c`, with a stub `nc` on PATH that - /// dumps its stdin to a temp file. Returns the captured stdin bytes. - private func runHookCommandCapturingStdin( - _ command: String, env: [String: String] - ) throws -> Data { - let workDir = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent("supacode-hook-rt-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: workDir) } - - // Stub nc that ignores its args (e.g. `-U -w1 `) and writes - // stdin to ./capture so we can read the JSON the hook produced. - let stubBin = workDir.appendingPathComponent("bin", isDirectory: true) - try FileManager.default.createDirectory(at: stubBin, withIntermediateDirectories: true) - let stubNC = stubBin.appendingPathComponent("nc") - let captureFile = workDir.appendingPathComponent("capture") - try "#!/bin/sh\ncat > '\(captureFile.path)'\n".write(to: stubNC, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: stubNC.path) - - // The hook hard-codes `/usr/bin/nc`, so symlink that path target - // into a private prefix. We cheat by patching the command string - // for this test to call the stub instead. - let patched = command.replacing("/usr/bin/nc", with: stubNC.path) - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/zsh") - process.arguments = ["-c", patched] - var environment = ProcessInfo.processInfo.environment - for (key, value) in env { environment[key] = value } - process.environment = environment - try process.run() - process.waitUntilExit() - - return (try? Data(contentsOf: captureFile)) ?? Data() + return (try? String(contentsOf: captureFile, encoding: .utf8)) ?? "" } } diff --git a/supacodeTests/AgentHookSocketServerTests.swift b/supacodeTests/AgentHookSocketServerTests.swift index 72c76d340..a74eb9f97 100644 --- a/supacodeTests/AgentHookSocketServerTests.swift +++ b/supacodeTests/AgentHookSocketServerTests.swift @@ -6,199 +6,21 @@ import Testing @MainActor struct AgentHookSocketServerTests { - // MARK: - Legacy single-line text payload (no longer parsed). + // MARK: - CLI protocol framing. - @Test func singleLineTextPayloadIsRejected() { - // The text-protocol busy message (`worktreeID tabID surfaceID 0|1`) - // was retired in favor of the JSON envelope. Any single-line text - // header now returns nil — exercises the new guard in `parse`. + @Test func nonJSONPayloadIsRejected() { + // The socket carries only the CLI control protocol (JSON command / query). + // Anything that is not a JSON object is dropped. let raw = "wt \(UUID().uuidString) \(UUID().uuidString) 1" #expect(AgentHookSocketServer.parse(data: Data(raw.utf8)) == nil) } - // MARK: - Notification message parsing. - - @Test func parsesValidNotificationWithPayload() { - let tabID = UUID() - let surfaceID = UUID() - let payload = #"{"hook_event_name":"Stop","title":"Done","message":"All tasks complete"}"# - let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) claude\n\(payload)" - let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) - - guard case .notification(_, let tID, let sID, let notification) = message else { - Issue.record("Expected notification message, got \(String(describing: message))") - return - } - #expect(tID == tabID) - #expect(sID == surfaceID) - #expect(notification.agent == "claude") - #expect(notification.event == "Stop") - #expect(notification.title == "Done") - #expect(notification.body == "All tasks complete") - } - - @Test func parsesNotificationWithLastAssistantMessageFallback() { - let tabID = UUID() - let surfaceID = UUID() - let payload = #"{"hook_event_name":"Stop","last_assistant_message":"fallback body"}"# - let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) codex\n\(payload)" - let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) - - guard case .notification(_, _, _, let notification) = message else { - Issue.record("Expected notification message") - return - } - #expect(notification.agent == "codex") - #expect(notification.body == "fallback body") - } - - @Test func parsesNotificationWithAssistantResponseFallback() { - let tabID = UUID() - let surfaceID = UUID() - let payload = #"{"hook_event_name":"stop","assistant_response":"kiro body"}"# - let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) kiro\n\(payload)" - let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) - - guard case .notification(_, _, _, let notification) = message else { - Issue.record("Expected notification message") - return - } - #expect(notification.agent == "kiro") - #expect(notification.body == "kiro body") - } - - @Test func lastAssistantMessageTakesPrecedenceOverAssistantResponse() { - let tabID = UUID() - let surfaceID = UUID() - let payload = """ - {"hook_event_name":"Stop","last_assistant_message":"codex body","assistant_response":"kiro body"} - """ - let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) codex\n\(payload)" - let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) - - guard case .notification(_, _, _, let notification) = message else { - Issue.record("Expected notification message") - return - } - #expect(notification.body == "codex body") - } - - @Test func messageFieldTakesPrecedenceOverAllFallbacks() { - let tabID = UUID() - let surfaceID = UUID() - let payload = - #"{"hook_event_name":"Stop","message":"primary","# - + #""last_assistant_message":"secondary","assistant_response":"tertiary"}"# - let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) claude\n\(payload)" - let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) - - guard case .notification(_, _, _, let notification) = message else { - Issue.record("Expected notification message") - return - } - #expect(notification.body == "primary") - } - - @Test func nullMessageFieldFallsThroughToLastAssistantMessage() { - let tabID = UUID() - let surfaceID = UUID() - let payload = #"{"hook_event_name":"Stop","message":null,"last_assistant_message":"fallback"}"# - let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) codex\n\(payload)" - let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) - - guard case .notification(_, _, _, let notification) = message else { - Issue.record("Expected notification message") - return - } - #expect(notification.body == "fallback") - } - - @Test func emptyStringMessageFieldFallsThroughToFallback() { - let tabID = UUID() - let surfaceID = UUID() - let payload = - #"{"hook_event_name":"Stop","message":"","last_assistant_message":"real body"}"# - let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) codex\n\(payload)" - let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) - - guard case .notification(_, _, _, let notification) = message else { - Issue.record("Expected notification message") - return - } - #expect(notification.body == "real body") - } - - @Test func typeMismatchOnMessageFieldFallsThroughToFallback() { - let tabID = UUID() - let surfaceID = UUID() - // Claude-shape with an unexpectedly numeric message: decoder must - // tolerate the mismatch and fall through to assistant_response. - let payload = - #"{"hook_event_name":"stop","message":42,"assistant_response":"kiro body"}"# - let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) kiro\n\(payload)" - let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) - - guard case .notification(_, _, _, let notification) = message else { - Issue.record("Expected notification message") - return - } - #expect(notification.body == "kiro body") - } - - @Test func invalidJSONPayloadDropsNotification() { - let tabID = UUID() - let surfaceID = UUID() - let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) claude\nnot json at all" - let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) - - #expect(message == nil) - } - - // MARK: - Malformed messages. - - @Test func malformedHeaderWithFewerThanThreeFieldsReturnsNil() { - let message = AgentHookSocketServer.parse(data: Data("wt only-two-fields".utf8)) - #expect(message == nil) - } - - @Test func invalidTabIDReturnsNil() { - let surfaceID = UUID() - let raw = "wt not-a-uuid \(surfaceID.uuidString) claude\n{\"hook_event_name\":\"Stop\"}" - let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) - #expect(message == nil) - } - - @Test func invalidSurfaceIDReturnsNil() { - let tabID = UUID() - let raw = "wt \(tabID.uuidString) not-a-uuid claude\n{\"hook_event_name\":\"Stop\"}" - let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) - #expect(message == nil) - } - @Test func emptyInputReturnsNil() { - let message = AgentHookSocketServer.parse(data: Data()) - #expect(message == nil) + #expect(AgentHookSocketServer.parse(data: Data()) == nil) } @Test func whitespaceOnlyInputReturnsNil() { - let message = AgentHookSocketServer.parse(data: Data(" \n \n ".utf8)) - #expect(message == nil) - } - - // MARK: - Agent name defaults. - - @Test func missingAgentNameDefaultsToUnknown() { - let tabID = UUID() - let surfaceID = UUID() - // Only 3 header fields + a second line → notification with no agent. - let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString)\n{\"hook_event_name\":\"Stop\"}" - let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) - - guard case .notification(_, _, _, let notification) = message else { - Issue.record("Expected notification message") - return - } - #expect(notification.agent == "unknown") + #expect(AgentHookSocketServer.parse(data: Data(" \n \n ".utf8)) == nil) } // MARK: - CLI command message parsing. @@ -217,14 +39,12 @@ struct AgentHookSocketServerTests { @Test func rejectsCommandWithInvalidScheme() { let json = #"{"deeplink":"https://example.com"}"# - let message = AgentHookSocketServer.parse(data: Data(json.utf8)) - #expect(message == nil) + #expect(AgentHookSocketServer.parse(data: Data(json.utf8)) == nil) } @Test func rejectsCommandWithMalformedJSON() { let json = #"{"not_deeplink":"supacode://test"}"# - let message = AgentHookSocketServer.parse(data: Data(json.utf8)) - #expect(message == nil) + #expect(AgentHookSocketServer.parse(data: Data(json.utf8)) == nil) } // MARK: - Query message parsing. @@ -266,8 +86,7 @@ struct AgentHookSocketServerTests { @Test func rejectsJSONWithNeitherQueryNorDeeplink() { let json = #"{"foo":"bar"}"# - let message = AgentHookSocketServer.parse(data: Data(json.utf8)) - #expect(message == nil) + #expect(AgentHookSocketServer.parse(data: Data(json.utf8)) == nil) } // MARK: - readPayload. @@ -277,13 +96,82 @@ struct AgentHookSocketServerTests { errno = EIO return -1 } - #expect(payload == nil) } - // MARK: - Hook event JSON envelope. + // MARK: - Notification payload decoding (OSC notify leg). + + // `parseNotification` is the agent-JSON body decoder the OSC notify + // leg uses; these lock the per-agent title/body precedence it relies on. + + @Test func decodesNotificationTitleAndMessageBody() { + let payload = #"{"hook_event_name":"Stop","title":"Done","message":"All tasks complete"}"# + let notification = AgentHookSocketServer.parseNotification(agent: "claude", data: Data(payload.utf8)) + #expect(notification?.agent == "claude") + #expect(notification?.event == "Stop") + #expect(notification?.title == "Done") + #expect(notification?.body == "All tasks complete") + } + + @Test func fallsBackToLastAssistantMessage() { + let payload = #"{"hook_event_name":"Stop","last_assistant_message":"fallback body"}"# + let notification = AgentHookSocketServer.parseNotification(agent: "codex", data: Data(payload.utf8)) + #expect(notification?.body == "fallback body") + } + + @Test func fallsBackToAssistantResponse() { + let payload = #"{"hook_event_name":"stop","assistant_response":"kiro body"}"# + let notification = AgentHookSocketServer.parseNotification(agent: "kiro", data: Data(payload.utf8)) + #expect(notification?.body == "kiro body") + } + + @Test func lastAssistantMessageTakesPrecedenceOverAssistantResponse() { + let payload = + #"{"hook_event_name":"Stop","last_assistant_message":"codex body","assistant_response":"kiro body"}"# + let notification = AgentHookSocketServer.parseNotification(agent: "codex", data: Data(payload.utf8)) + #expect(notification?.body == "codex body") + } + + @Test func messageFieldTakesPrecedenceOverAllFallbacks() { + let payload = + #"{"hook_event_name":"Stop","message":"primary","# + + #""last_assistant_message":"secondary","assistant_response":"tertiary"}"# + let notification = AgentHookSocketServer.parseNotification(agent: "claude", data: Data(payload.utf8)) + #expect(notification?.body == "primary") + } + + @Test func nullMessageFieldFallsThroughToLastAssistantMessage() { + let payload = #"{"hook_event_name":"Stop","message":null,"last_assistant_message":"fallback"}"# + let notification = AgentHookSocketServer.parseNotification(agent: "codex", data: Data(payload.utf8)) + #expect(notification?.body == "fallback") + } + + @Test func emptyStringMessageFieldFallsThroughToFallback() { + let payload = #"{"hook_event_name":"Stop","message":"","last_assistant_message":"real body"}"# + let notification = AgentHookSocketServer.parseNotification(agent: "codex", data: Data(payload.utf8)) + #expect(notification?.body == "real body") + } + + @Test func typeMismatchOnMessageFieldFallsThroughToFallback() { + // Claude-shape with an unexpectedly numeric message: the decoder tolerates + // the mismatch and falls through to assistant_response. + let payload = #"{"hook_event_name":"stop","message":42,"assistant_response":"kiro body"}"# + let notification = AgentHookSocketServer.parseNotification(agent: "kiro", data: Data(payload.utf8)) + #expect(notification?.body == "kiro body") + } + + @Test func invalidJSONNotificationPayloadReturnsNil() { + let notification = AgentHookSocketServer.parseNotification( + agent: "claude", data: Data("not json at all".utf8)) + #expect(notification == nil) + } + + // MARK: - AgentHookEvent decoding. - @Test func parsesValidHookEventWithRequiredFieldsOnly() { + // `AgentHookEvent` is the in-app event type the OSC ingest synthesizes; it is + // also `Decodable` from this JSON shape for test construction. + + @Test func decodesEventWithRequiredFieldsOnly() throws { let surfaceID = UUID() let json = """ { @@ -293,12 +181,7 @@ struct AgentHookSocketServerTests { "surface_id": "\(surfaceID.uuidString)" } """ - let message = AgentHookSocketServer.parse(data: Data(json.utf8)) - - guard case .event(let event) = message else { - Issue.record("Expected event message, got \(String(describing: message))") - return - } + let event = try JSONDecoder().decode(AgentHookEvent.self, from: Data(json.utf8)) #expect(event.event == "session_start") #expect(event.eventName == .sessionStart) #expect(event.agent == "claude") @@ -307,7 +190,7 @@ struct AgentHookSocketServerTests { #expect(event.data == nil) } - @Test func parsesHookEventWithPidTimestampAndOpaqueData() { + @Test func decodesEventWithPidTimestampAndOpaqueData() throws { let surfaceID = UUID() let json = """ { @@ -320,12 +203,7 @@ struct AgentHookSocketServerTests { "data": {"title": "Done", "message": "All good"} } """ - let message = AgentHookSocketServer.parse(data: Data(json.utf8)) - - guard case .event(let event) = message else { - Issue.record("Expected event message") - return - } + let event = try JSONDecoder().decode(AgentHookEvent.self, from: Data(json.utf8)) #expect(event.pid == 12345) #expect(event.timestamp != nil) @@ -333,11 +211,10 @@ struct AgentHookSocketServerTests { let title: String let message: String } - let decoded = event.decodeData(NotificationPayload.self) - #expect(decoded == NotificationPayload(title: "Done", message: "All good")) + #expect(event.decodeData(NotificationPayload.self) == NotificationPayload(title: "Done", message: "All good")) } - @Test func unknownEventNameKeepsRawStringButHasNilEventName() { + @Test func unknownEventNameKeepsRawStringButHasNilEventName() throws { let surfaceID = UUID() let json = """ { @@ -347,59 +224,24 @@ struct AgentHookSocketServerTests { "surface_id": "\(surfaceID.uuidString)" } """ - let message = AgentHookSocketServer.parse(data: Data(json.utf8)) - - guard case .event(let event) = message else { - Issue.record("Expected event message") - return - } + let event = try JSONDecoder().decode(AgentHookEvent.self, from: Data(json.utf8)) #expect(event.event == "future_event_we_dont_know_yet") #expect(event.eventName == nil) } - @Test func hookEventMissingSurfaceIDReturnsNil() { - let json = """ - { - "event": "session_start", - "agent": "claude" - } - """ - #expect(AgentHookSocketServer.parse(data: Data(json.utf8)) == nil) - } - - @Test func hookEventWithMalformedSurfaceUUIDReturnsNil() { - let json = """ - { - "event": "session_start", - "agent": "claude", - "surface_id": "not-a-uuid" - } - """ - #expect(AgentHookSocketServer.parse(data: Data(json.utf8)) == nil) + @Test func eventMissingSurfaceIDFailsToDecode() { + let json = #"{"event":"session_start","agent":"claude"}"# + #expect((try? JSONDecoder().decode(AgentHookEvent.self, from: Data(json.utf8))) == nil) } - @Test func eventDiscriminatorTakesPrecedenceOverDeeplinkInSamePayload() { - let surfaceID = UUID() - let json = """ - { - "event": "session_start", - "agent": "claude", - "surface_id": "\(surfaceID.uuidString)", - "deeplink": "supacode://worktree/test" - } - """ - let message = AgentHookSocketServer.parse(data: Data(json.utf8)) - - guard case .event = message else { - Issue.record("Expected event message, got \(String(describing: message))") - return - } + @Test func eventWithMalformedSurfaceUUIDFailsToDecode() { + let json = #"{"event":"session_start","agent":"claude","surface_id":"not-a-uuid"}"# + #expect((try? JSONDecoder().decode(AgentHookEvent.self, from: Data(json.utf8))) == nil) } - @Test func hookEventRejectsNonPositivePid() { - // `kill(0, 0)` succeeds for the caller's process group and `kill(-N, 0)` - // for group N, so a pid <= 0 in a session_start would pin a permanent - // badge in the liveness sweep. Decoder rejects them outright. + @Test func eventRejectsNonPositivePid() { + // `kill(0, 0)` succeeds for the caller's process group and `kill(-N, 0)` for + // group N, so a pid <= 0 would pin a permanent badge in the liveness sweep. for badPid in ["0", "-1", "-12345"] { let json = """ { @@ -410,9 +252,8 @@ struct AgentHookSocketServerTests { } """ #expect( - AgentHookSocketServer.parse(data: Data(json.utf8)) == nil, - "Expected nil for pid=\(badPid)" - ) + (try? JSONDecoder().decode(AgentHookEvent.self, from: Data(json.utf8))) == nil, + "Expected nil for pid=\(badPid)") } } } diff --git a/supacodeTests/AgentPresence+TestHelpers.swift b/supacodeTests/AgentPresence+TestHelpers.swift index d2f87f4e8..533727607 100644 --- a/supacodeTests/AgentPresence+TestHelpers.swift +++ b/supacodeTests/AgentPresence+TestHelpers.swift @@ -6,7 +6,7 @@ import Foundation /// Test-only harness around an `AgentPresenceFeature.State`. A background task /// drains the manager's event stream and routes `agentHookEventReceived` / /// `surfacesClosed` events into the reducer so callers can drive the manager -/// via `server.onEvent(...)` and then await `harness.drain()` to settle +/// via `state.onAgentHookEvent(...)` and then await `harness.drain()` to settle /// presence before asserting. @MainActor final class PresenceTestHarness { @@ -38,7 +38,7 @@ final class PresenceTestHarness { send(.livenessSweepResult(snapshot: snapshot, alive: alive)) } - /// Settles presence after `server.onEvent(...)` / `clock.advance(...)`. Each + /// Settles presence after `state.onAgentHookEvent(...)` / `clock.advance(...)`. Each /// pass runs `megaYield` (flushing the consume task plus any clock-awoken /// manager emit, e.g. an idle debounce resuming after `clock.advance`) and /// returns once the consumer has parked again with no reduction in the final diff --git a/supacodeTests/AgentPresenceFeatureTests.swift b/supacodeTests/AgentPresenceFeatureTests.swift index d5889dfa2..80f844b57 100644 --- a/supacodeTests/AgentPresenceFeatureTests.swift +++ b/supacodeTests/AgentPresenceFeatureTests.swift @@ -21,16 +21,19 @@ struct AgentPresenceFeatureTests { #expect(harness.state.agents(forSurface: surfaceID, badgesEnabled: true) == Set([.claude])) } - @Test func sessionStartWithoutPidIsIgnored() { - // Every bridge today (Claude/Codex/Kiro hooks, Pi extension) sends a - // pid in the envelope. A pid-less event is treated as malformed: - // accepting it would create a record the liveness sweep can't reap. + @Test func sessionStartWithoutPidSeedsPidlessRecord() { + // The OSC-over-SSH transport attributes by the receiving surface and has no + // remote pid, so a pid-less session_start is a valid signal: it seeds a + // record so the badge lights at launch. The record carries no pid, so the + // kill(2) sweep skips it; teardown is via session_end or surface close. var harness = Harness() let surfaceID = UUID() harness.send(.hookEventReceived(makeEvent(.sessionStart, agent: .claude, surfaceID: surfaceID))) - #expect(harness.state.agents(forSurface: surfaceID, badgesEnabled: true).isEmpty) + #expect(harness.state.agents(forSurface: surfaceID, badgesEnabled: true) == Set([.claude])) + let key = AgentPresenceFeature.PresenceKey(agent: .claude, surfaceID: surfaceID) + #expect(harness.state.records[key]?.pids.isEmpty == true) } @Test func sessionEndRemovesAgentForSurface() { @@ -98,7 +101,7 @@ struct AgentPresenceFeatureTests { "surface_id": "\(surfaceID.uuidString)" } """ - guard case .event(let parsed) = AgentHookSocketServer.parse(data: Data(json.utf8)) else { + guard let parsed = try? JSONDecoder().decode(AgentHookEvent.self, from: Data(json.utf8)) else { Issue.record("Expected event") return } @@ -119,6 +122,155 @@ struct AgentPresenceFeatureTests { #expect(harness.state.agents(forSurface: surfaceID, badgesEnabled: true).isEmpty) } + // MARK: - OSC (pid-less) presence. + + @Test func pidlessBusyWithoutSessionStartAutoSeeds() { + // Over SSH the user may attach to a surface whose agent is already running, + // so the first OSC seen is `busy`, not `session_start`. The pid-less path + // must auto-seed a record so the badge lights regardless of arrival order. + var harness = Harness() + let surfaceID = UUID() + + harness.send(.hookEventReceived(makeEvent(.busy, agent: .claude, surfaceID: surfaceID))) + + let claude = harness.state.agents(across: [surfaceID], badgesEnabled: true).first { $0.agent == .claude } + #expect(claude?.activity == .busy) + #expect(harness.state.hasActivity(in: [surfaceID]) == true) + } + + @Test func pidlessSessionEndRemovesPidlessRecord() { + var harness = Harness() + let surfaceID = UUID() + + harness.send(.hookEventReceived(makeEvent(.sessionStart, agent: .claude, surfaceID: surfaceID))) + harness.send(.hookEventReceived(makeEvent(.sessionEnd, agent: .claude, surfaceID: surfaceID))) + + #expect(harness.state.agents(forSurface: surfaceID, badgesEnabled: true).isEmpty) + } + + @Test func pidlessRecordSurvivesSweepThatReapsCoLocatedDeadPid() { + // A pid-less (OSC) record must survive the sweep even while a co-located dead + // pid-bearing record is reaped: the sweep only targets records with pids. + var harness = Harness() + let surfaceID = UUID() + let deadPid = makeDeadPid() + + harness.send(.hookEventReceived(makeEvent(.sessionStart, agent: .claude, surfaceID: surfaceID))) + harness.send(.hookEventReceived(makeEvent(.busy, agent: .claude, surfaceID: surfaceID))) + harness.send(.hookEventReceived(makeEvent(.sessionStart, agent: .codex, surfaceID: surfaceID, pid: deadPid))) + + harness.livenessSweep() + + #expect(harness.state.agents(forSurface: surfaceID, badgesEnabled: true) == Set([.claude])) + let claudeKey = AgentPresenceFeature.PresenceKey(agent: .claude, surfaceID: surfaceID) + #expect(harness.state.records[claudeKey]?.activity == .busy) + } + + @Test func pidBearingActivityWithoutPresenceDoesNotSeed() { + // Auto-seed is pid-less-only. A pid-bearing awaiting_input / idle + // with no session_start must still be dropped, like busyWithoutPresenceIsDropped. + var harness = Harness() + let surfaceID = UUID() + + harness.send(.hookEventReceived(makeEvent(.awaitingInput, agent: .claude, surfaceID: surfaceID, pid: getpid()))) + harness.send(.hookEventReceived(makeEvent(.idle, agent: .codex, surfaceID: surfaceID, pid: getpid()))) + + #expect(harness.state.agents(forSurface: surfaceID, badgesEnabled: true).isEmpty) + } + + @Test func pidlessSessionEndTearsDownRegardlessOfActivity() { + // Teardown keys on record.pids.isEmpty, not the activity flip: an OSC record + // that went busy then idle must still be removed by session_end. + var harness = Harness() + let surfaceID = UUID() + + harness.send(.hookEventReceived(makeEvent(.sessionStart, agent: .claude, surfaceID: surfaceID))) + harness.send(.hookEventReceived(makeEvent(.busy, agent: .claude, surfaceID: surfaceID))) + harness.send(.hookEventReceived(makeEvent(.idle, agent: .claude, surfaceID: surfaceID))) + harness.send(.hookEventReceived(makeEvent(.sessionEnd, agent: .claude, surfaceID: surfaceID))) + + #expect(harness.state.agents(forSurface: surfaceID, badgesEnabled: true).isEmpty) + } + + @Test func pidBearingRecordSurvivesPidlessSessionEnd() { + // Pid-bearing wins: a pid-less (OSC over SSH) session_end must never tear + // down a record that still carries a tracked local pid for the same + // surface/agent. + var harness = Harness() + let surfaceID = UUID() + let pid = getpid() + + harness.send(.hookEventReceived(makeEvent(.sessionStart, agent: .claude, surfaceID: surfaceID, pid: pid))) + harness.send(.hookEventReceived(makeEvent(.sessionEnd, agent: .claude, surfaceID: surfaceID))) + + #expect(harness.state.agents(forSurface: surfaceID, badgesEnabled: true) == Set([.claude])) + } + + @Test func pidlessSessionStartDoesNotClobberPidBearingRecord() { + // Pid-bearing wins on seed too: a pid-less OSC session_start arriving after + // the local hook has registered a pid must not reset the record. + var harness = Harness() + let surfaceID = UUID() + let pid = getpid() + + harness.send(.hookEventReceived(makeEvent(.sessionStart, agent: .claude, surfaceID: surfaceID, pid: pid))) + harness.send(.hookEventReceived(makeEvent(.sessionStart, agent: .claude, surfaceID: surfaceID))) + + let key = AgentPresenceFeature.PresenceKey(agent: .claude, surfaceID: surfaceID) + #expect(harness.state.records[key]?.pids == [pid]) + } + + @Test func pidlessSessionStartUpgradedByPidSessionStartCarriesPid() { + // OSC pid-less arrives first (SSH attach), then the local hook lands with a + // pid: the existing pid-less record must be upgraded in place so the sweep + // can reap it. + var harness = Harness() + let surfaceID = UUID() + let pid: pid_t = 1234 + + harness.send(.hookEventReceived(makeEvent(.sessionStart, agent: .claude, surfaceID: surfaceID))) + harness.send(.hookEventReceived(makeEvent(.sessionStart, agent: .claude, surfaceID: surfaceID, pid: pid))) + + let key = AgentPresenceFeature.PresenceKey(agent: .claude, surfaceID: surfaceID) + #expect(harness.state.records[key]?.pids == [pid]) + } + + @Test func pidlessSessionEndFollowedByIdleDoesNotReseedRecord() { + // Both Claude's SessionEnd hook and the Pi `session_shutdown` extension emit + // a `session_end` + `idle` composite. After the pid-less session_end clears + // the record, the debounced idle must NOT auto-seed a new pid-less record: + // the sweep can never reap such a record (no pid to check) and no further + // session_end follows, so the badge would stay until surface close. + var harness = Harness() + let surfaceID = UUID() + + harness.send(.hookEventReceived(makeEvent(.sessionStart, agent: .claude, surfaceID: surfaceID))) + harness.send(.hookEventReceived(makeEvent(.busy, agent: .claude, surfaceID: surfaceID))) + harness.send(.hookEventReceived(makeEvent(.sessionEnd, agent: .claude, surfaceID: surfaceID))) + harness.send(.hookEventReceived(makeEvent(.idle, agent: .claude, surfaceID: surfaceID))) + + #expect(harness.state.agents(forSurface: surfaceID, badgesEnabled: true).isEmpty) + let key = AgentPresenceFeature.PresenceKey(agent: .claude, surfaceID: surfaceID) + #expect(harness.state.records[key] == nil) + } + + @Test func pidUpgradedRecordIsReapedByMatchingPidSessionEnd() { + // Pid-less seed (SSH attach) followed by a pid-bearing session_start that + // upgrades the record. The matching pid-bearing session_end must remove the + // record entirely; otherwise the badge sticks once the pid set goes empty. + var harness = Harness() + let surfaceID = UUID() + let pid: pid_t = 1234 + + harness.send(.hookEventReceived(makeEvent(.sessionStart, agent: .claude, surfaceID: surfaceID))) + harness.send(.hookEventReceived(makeEvent(.sessionStart, agent: .claude, surfaceID: surfaceID, pid: pid))) + harness.send(.hookEventReceived(makeEvent(.sessionEnd, agent: .claude, surfaceID: surfaceID, pid: pid))) + + #expect(harness.state.agents(forSurface: surfaceID, badgesEnabled: true).isEmpty) + let key = AgentPresenceFeature.PresenceKey(agent: .claude, surfaceID: surfaceID) + #expect(harness.state.records[key] == nil) + } + // MARK: - Liveness. @Test func livenessSweepEvictsRecordsForDeadPid() { @@ -668,7 +820,7 @@ struct AgentPresenceFeatureTests { "surface_id": "\(surfaceID.uuidString)"\(pidLine) } """ - guard case .event(let event) = AgentHookSocketServer.parse(data: Data(json.utf8)) else { + guard let event = try? JSONDecoder().decode(AgentHookEvent.self, from: Data(json.utf8)) else { preconditionFailure("Failed to parse test event") } return event diff --git a/supacodeTests/AgentPresenceOSCTests.swift b/supacodeTests/AgentPresenceOSCTests.swift new file mode 100644 index 000000000..3cb99a3a6 --- /dev/null +++ b/supacodeTests/AgentPresenceOSCTests.swift @@ -0,0 +1,377 @@ +import Foundation +import Testing + +@testable import SupacodeSettingsShared +@testable import supacode + +struct AgentPresenceOSCTests { + // MARK: - parse. + + @Test func parsesValidSignal() { + let signal = AgentPresenceOSC.parse(id: "claude", metadata: "event=busy;token=abc123") + #expect(signal?.agent == "claude") + #expect(signal?.eventRawValue == "busy") + #expect(signal?.token == "abc123") + } + + @Test func rejectsEmptyId() { + #expect(AgentPresenceOSC.parse(id: "", metadata: "event=busy;token=abc") == nil) + } + + @Test func rejectsMissingEvent() { + #expect(AgentPresenceOSC.parse(id: "claude", metadata: "token=abc") == nil) + } + + @Test func rejectsUnknownEvent() { + #expect(AgentPresenceOSC.parse(id: "claude", metadata: "event=not_a_real_event;token=abc") == nil) + } + + @Test func rejectsMissingToken() { + #expect(AgentPresenceOSC.parse(id: "claude", metadata: "event=busy") == nil) + } + + @Test func rejectsEmptyToken() { + #expect(AgentPresenceOSC.parse(id: "claude", metadata: "event=busy;token=") == nil) + } + + @Test func ignoresUnknownFieldsAndOrdering() { + let signal = AgentPresenceOSC.parse(id: "codex", metadata: "extra=1;token=zzz;event=session_start") + #expect(signal?.eventRawValue == "session_start") + #expect(signal?.token == "zzz") + #expect(signal?.agent == "codex") + } + + @Test func skipsBareSegmentWithoutEquals() { + // A segment with no '=' (a stray sentinel byte) is skipped, not fatal. + let signal = AgentPresenceOSC.parse(id: "claude", metadata: "garbage;event=idle;token=t") + #expect(signal?.eventRawValue == "idle") + } + + // MARK: - emit / parse round-trip. + + @Test func emitMetadataRoundTripsThroughParse() { + for event in [HookEvent.sessionStart, .sessionEnd, .busy, .awaitingInput, .idle] { + let metadata = AgentPresenceOSC.metadata(event: event, token: "tok123") + let signal = AgentPresenceOSC.parse(id: "claude", metadata: metadata) + #expect(signal?.eventRawValue == event.rawValue) + #expect(signal?.token == "tok123") + } + } + + // MARK: - pid field (local-host liveness). + + @Test func parsesPositivePidField() { + let signal = AgentPresenceOSC.parse(id: "claude", metadata: "event=busy;token=tok;pid=4321") + #expect(signal?.pid == 4321) + } + + @Test func absentPidParsesAsNil() { + let signal = AgentPresenceOSC.parse(id: "claude", metadata: "event=busy;token=tok") + #expect(signal?.eventRawValue == "busy") + #expect(signal?.pid == nil) + } + + @Test func rejectsNonPositiveAndGarbagePid() { + // 0 / negatives would let `kill(_:0)` match the caller's process group and + // pin a permanent badge; a non-numeric pid is dropped, not fatal. + for raw in ["0", "-7", "abc", ""] { + let signal = AgentPresenceOSC.parse(id: "claude", metadata: "event=busy;token=tok;pid=\(raw)") + #expect(signal?.eventRawValue == "busy") + #expect(signal?.pid == nil) + } + } + + @Test func rejectsPidThatOverflowsPidT() { + // Defense in depth against a future change from `pid_t(raw)` to `Int(raw)`: + // a value beyond pid_t's range must drop, not wrap. + let signal = AgentPresenceOSC.parse(id: "claude", metadata: "event=busy;token=t;pid=99999999999999") + #expect(signal?.pid == nil) + } + + @Test func metadataPidSuffixRoundTripsThroughParse() { + let metadata = AgentPresenceOSC.metadata(event: .busy, token: "tok", pidSuffix: ";pid=99") + let signal = AgentPresenceOSC.parse(id: "claude", metadata: metadata) + #expect(signal?.pid == 99) + } + + @Test func presenceEventThreadsLocalPid() { + let metadata = AgentPresenceOSC.metadata(event: .busy, token: "tok", pidSuffix: ";pid=4242") + let result = WorktreeTerminalState.presenceEvent( + id: "claude", metadata: metadata, expectedToken: "tok", surfaceID: UUID(), surfaceExists: true) + #expect((try? result.get())?.pid == 4242) + } + + @Test func actionMapsSessionEndToEndElseStart() { + #expect(AgentPresenceOSC.action(for: .sessionEnd) == "end") + for event in [HookEvent.sessionStart, .busy, .awaitingInput, .idle] { + #expect(AgentPresenceOSC.action(for: event) == "start") + } + } + + // MARK: - tokensMatch (anti-spoof compare). + + @Test func tokensMatchEqual() { + #expect(AgentPresenceOSC.tokensMatch("abc123", "abc123")) + } + + @Test func tokensMatchEmpty() { + #expect(AgentPresenceOSC.tokensMatch("", "")) + } + + @Test func tokensMatchRejectsOneByteDifference() { + #expect(!AgentPresenceOSC.tokensMatch("abc123", "abc124")) + } + + @Test func tokensMatchRejectsDifferentLengths() { + #expect(!AgentPresenceOSC.tokensMatch("abc", "abc1")) + } + + // MARK: - makeOSCToken (fixed-length hex invariant). + + @MainActor + @Test func makeOSCTokenAlwaysReturns32LowercaseHexChars() { + // Guards `tokensMatch`'s fixed-length contract against regressions on either + // the SecRandomCopyBytes path or the arc4random_buf fallback. + let allowed = Set("0123456789abcdef") + for _ in 0..<100 { + let token = WorktreeTerminalState.makeOSCToken() + #expect(token.count == 32) + #expect(token.allSatisfy { allowed.contains($0) }) + } + } + + // MARK: - presenceEvent (trust boundary + attribution). + + @Test func presenceEventTrustsMatchingTokenAndAttributesToReceivingSurface() { + let surfaceID = UUID() + let metadata = AgentPresenceOSC.metadata(event: .busy, token: "tok") + let result = WorktreeTerminalState.presenceEvent( + id: "claude", metadata: metadata, expectedToken: "tok", surfaceID: surfaceID, surfaceExists: true) + let event = try? result.get() + #expect(event?.surfaceID == surfaceID) + #expect(event?.agent == "claude") + #expect(event?.event == "busy") + #expect(event?.pid == nil) + } + + @Test func presenceEventDropsMismatchedToken() { + let metadata = AgentPresenceOSC.metadata(event: .busy, token: "wrong") + let result = WorktreeTerminalState.presenceEvent( + id: "claude", metadata: metadata, expectedToken: "right", surfaceID: UUID(), surfaceExists: true) + guard case .failure(.tokenMismatch(let agent, let event)) = result else { + Issue.record("expected tokenMismatch, got \(result)") + return + } + #expect(agent == "claude") + #expect(event == "busy") + } + + @Test func presenceEventDropsUnknownSurface() { + let metadata = AgentPresenceOSC.metadata(event: .busy, token: "tok") + let result = WorktreeTerminalState.presenceEvent( + id: "claude", metadata: metadata, expectedToken: nil, surfaceID: UUID(), surfaceExists: false) + #expect(result == .failure(.unknownSurface)) + } + + // MARK: - AgentHookEvent synthesis. + + @Test func synthesizedHookEventDefaultsToPidlessAndKeepsSurface() { + let surfaceID = UUID() + let event = AgentHookEvent(agent: "claude", event: "busy", surfaceID: surfaceID) + #expect(event.pid == nil) + #expect(event.surfaceID == surfaceID) + #expect(event.agent == "claude") + #expect(event.event == "busy") + #expect(event.version == 1) + } + + // MARK: - parseNotify. + + @Test func parsesValidNotify() { + let json = #"{"hook_event_name":"Stop","message":"hi"}"# + let b64 = Data(json.utf8).base64EncodedString() + let signal = AgentPresenceOSC.parseNotify(id: "claude", metadata: "kind=notify;token=tok;data=\(b64)") + #expect(signal?.agent == "claude") + #expect(signal?.token == "tok") + #expect(signal?.payload == Data(json.utf8)) + } + + @Test func rejectsNotifyWithoutKind() { + let b64 = Data("x".utf8).base64EncodedString() + #expect(AgentPresenceOSC.parseNotify(id: "claude", metadata: "token=tok;data=\(b64)") == nil) + } + + @Test func rejectsNotifyWithoutToken() { + let b64 = Data("x".utf8).base64EncodedString() + #expect(AgentPresenceOSC.parseNotify(id: "claude", metadata: "kind=notify;data=\(b64)") == nil) + } + + @Test func rejectsNotifyWithoutData() { + #expect(AgentPresenceOSC.parseNotify(id: "claude", metadata: "kind=notify;token=tok") == nil) + } + + @Test func rejectsNotifyWithInvalidBase64() { + #expect(AgentPresenceOSC.parseNotify(id: "claude", metadata: "kind=notify;token=tok;data=!!notb64") == nil) + } + + @Test func rejectsNotifyWithEmptyId() { + let b64 = Data("x".utf8).base64EncodedString() + #expect(AgentPresenceOSC.parseNotify(id: "", metadata: "kind=notify;token=tok;data=\(b64)") == nil) + } + + @Test func notifyMetadataRoundTripsThroughParseNotify() { + let json = #"{"message":"round trip"}"# + let metadata = AgentPresenceOSC.notifyMetadata(token: "tok", data: Data(json.utf8).base64EncodedString()) + let signal = AgentPresenceOSC.parseNotify(id: "codex", metadata: metadata) + #expect(signal?.payload == Data(json.utf8)) + #expect(signal?.token == "tok") + } + + @Test func presenceParseRejectsNotifyMetadata() { + // Presence and notify are disjoint: a notify payload must not parse as presence. + let b64 = Data("x".utf8).base64EncodedString() + #expect(AgentPresenceOSC.parse(id: "claude", metadata: "kind=notify;token=tok;data=\(b64)") == nil) + } + + // MARK: - notification (trust + sanitize). + + @Test func notificationTrustsMatchingTokenAndExtractsBody() { + let json = #"{"hook_event_name":"Stop","message":"all done"}"# + let metadata = "kind=notify;token=tok;data=\(Data(json.utf8).base64EncodedString())" + let resolved = WorktreeTerminalState.notification( + id: "claude", metadata: metadata, expectedToken: "tok", surfaceExists: true) + guard case .success(let value) = resolved else { + Issue.record("expected success, got \(resolved)") + return + } + #expect(value.body == "all done") + } + + @Test func notificationDropsMismatchedToken() { + let metadata = "kind=notify;token=wrong;data=\(Data(#"{"message":"x"}"#.utf8).base64EncodedString())" + let result = WorktreeTerminalState.notification( + id: "claude", metadata: metadata, expectedToken: "right", surfaceExists: true) + guard case .failure(.tokenMismatch(let agent)) = result else { + Issue.record("expected tokenMismatch, got \(result)") + return + } + #expect(agent == "claude") + } + + @Test func notificationDropsUnknownSurface() { + let metadata = "kind=notify;token=tok;data=\(Data(#"{"message":"x"}"#.utf8).base64EncodedString())" + let result = WorktreeTerminalState.notification( + id: "claude", metadata: metadata, expectedToken: nil, surfaceExists: false) + if case .failure(.unknownSurface) = result {} else { Issue.record("expected unknownSurface, got \(result)") } + } + + @Test func notificationDropsClosedSurfaceWithNilExpectedTokenWithoutWarning() { + // A signal targeting a closed surface (no expected token, surface gone) is + // benign, not a spoof: the call site routes `.unknownSurface` to `.debug`, + // never `.warning`. Asserting the exact failure case locks that mapping in + // since `tokenMismatch` / `parseFailed` are the only warn-level branches. + let metadata = "kind=notify;token=tok;data=\(Data(#"{"message":"x"}"#.utf8).base64EncodedString())" + let result = WorktreeTerminalState.notification( + id: "claude", metadata: metadata, expectedToken: nil, surfaceExists: false) + guard case .failure(let drop) = result else { + Issue.record("expected failure, got \(result)") + return + } + if case .unknownSurface = drop { + } else { + Issue.record("expected unknownSurface, got \(drop)") + } + if case .tokenMismatch = drop { Issue.record("unknown surface must not log as a spoof warning") } + if case .parseFailed = drop { Issue.record("unknown surface must not log as a malformed warning") } + } + + @Test func notificationFallsBackToAgentTitleWhenAbsent() { + let metadata = "kind=notify;token=tok;data=\(Data(#"{"message":"body only"}"#.utf8).base64EncodedString())" + let resolved = WorktreeTerminalState.notification( + id: "codex", metadata: metadata, expectedToken: "tok", surfaceExists: true) + guard case .success(let value) = resolved else { + Issue.record("expected success, got \(resolved)") + return + } + #expect(value.title == "codex") + } + + @Test func notificationDropsPayloadThatSanitizesEmpty() { + // Body of only control / whitespace and no usable title sanitizes to empty, + // so the toast is suppressed rather than shown blank. + let metadata = "kind=notify;token=tok;data=\(Data(#"{"message":"\n"}"#.utf8).base64EncodedString())" + let result = WorktreeTerminalState.notification( + id: " ", metadata: metadata, expectedToken: "tok", surfaceExists: true) + if case .failure(.empty) = result {} else { Issue.record("expected empty, got \(result)") } + } + + @Test func sanitizeStripsControlCharsAndCollapsesNewlines() { + let dirty = "a\u{1B}[31mred\u{07}\nline" + #expect(WorktreeTerminalState.sanitizeNotificationText(dirty, max: 1000) == "a[31mred line") + } + + @Test func sanitizeCapsToMaxScalars() { + let long = String(repeating: "x", count: 500) + #expect(WorktreeTerminalState.sanitizeNotificationText(long, max: 100).count == 100) + } + + @Test func notificationStripsEmbeddedOSCSequenceFromBody() { + // A notify body that smuggles a nested OSC 3008 presence sequence must not + // forward the ESC or the framing bytes to the toast: the C0 strip is the + // only line of defense between an attacker-controlled message and the + // terminal's own escape parser. The ESC bytes ride in via JSON's six-char + // unicode escape (raw 0x1B is illegal in a JSON string); the C0 strip + // must drop both the opening ESC and the trailing ST ESC before the + // toast sees them. + let json = + #"{"hook_event_name":"Stop","message":"before\u001b]3008;start=evil;event=busy;token=X\u001b\\after"}"# + let metadata = "kind=notify;token=tok;data=\(Data(json.utf8).base64EncodedString())" + let resolved = WorktreeTerminalState.notification( + id: "claude", metadata: metadata, expectedToken: "tok", surfaceExists: true) + guard case .success(let value) = resolved else { + Issue.record("expected success, got \(resolved)") + return + } + // No ESC byte may reach the toast, no matter where it sat in the payload. + #expect(!value.body.unicodeScalars.contains { $0.value == 0x1B }) + // Printable framing bytes survive (they are not C0); the load-bearing + // assertion is that the ESC is gone, so a downstream renderer cannot + // re-trigger an escape parser. + #expect(value.body == #"before]3008;start=evil;event=busy;token=X\after"#) + // The standalone sanitize entry point pins the same contract directly. + let dirty = "before\u{1B}]3008;start=evil;event=busy;token=X\u{1B}\\after" + #expect( + WorktreeTerminalState.sanitizeNotificationText(dirty, max: 1000) + == #"before]3008;start=evil;event=busy;token=X\after"#) + } + + // MARK: - large payload (metadata cap headroom). + + @Test func largeNotifyPayloadNearMetadataCapRoundTripsEndToEnd() { + // Locks the design margin against a future Ghostty cap reduction or a Claude + // payload growth that would silently drop notifications: a ~1.5KB Codex + // `last_assistant_message` must survive parseNotify + notification(...) with + // the title / body resolving correctly. Sized so the base64-expanded + // metadata stays just under the 2047-byte OSC cap. + let bodyText = String(repeating: "y", count: 1400) + let json = #"{"hook_event_name":"Stop","title":"Big","last_assistant_message":"\#(bodyText)"}"# + let metadata = AgentPresenceOSC.notifyMetadata( + token: "tok", data: Data(json.utf8).base64EncodedString()) + // Stay under Ghostty's 2047-byte metadata cap so a real terminal would not truncate. + #expect(metadata.utf8.count < 2047) + + let signal = AgentPresenceOSC.parseNotify(id: "codex", metadata: metadata) + #expect(signal?.payload == Data(json.utf8)) + + let resolved = WorktreeTerminalState.notification( + id: "codex", metadata: metadata, expectedToken: "tok", surfaceExists: true) + guard case .success(let value) = resolved else { + Issue.record("expected success, got \(resolved)") + return + } + #expect(value.title == "Big") + // Body is sanitized + capped at 1000 scalars (see WorktreeTerminalState.notification). + #expect(value.body.count == 1000) + #expect(value.body.allSatisfy { $0 == "y" }) + } +} diff --git a/supacodeTests/GhosttySurfaceBridgeTests.swift b/supacodeTests/GhosttySurfaceBridgeTests.swift index 528e229b3..b4ded231c 100644 --- a/supacodeTests/GhosttySurfaceBridgeTests.swift +++ b/supacodeTests/GhosttySurfaceBridgeTests.swift @@ -87,7 +87,7 @@ struct GhosttySurfaceBridgeTests { @Test func desktopNotificationEmitsCallback() { let bridge = GhosttySurfaceBridge() - var received: (String, String)? + var received: (title: String, body: String)? bridge.onDesktopNotification = { title, body in received = (title, body) } @@ -106,8 +106,71 @@ struct GhosttySurfaceBridgeTests { } } - #expect(received?.0 == "Title") - #expect(received?.1 == "Body") + #expect(received?.title == "Title") + #expect(received?.body == "Body") + } + + @Test func contextSignalEmitsCallback() { + let bridge = GhosttySurfaceBridge() + var receivedAction: UInt8? + var receivedID: String? + var receivedMetadata: String? + bridge.onContextSignal = { action, id, metadata in + receivedAction = action + receivedID = id + receivedMetadata = metadata + } + + var action = ghostty_action_s() + action.tag = GHOSTTY_ACTION_CONTEXT_SIGNAL + let target = ghostty_target_s() + + "claude".withCString { idPtr in + "event=busy;token=abc".withCString { metaPtr in + action.action.context_signal = ghostty_action_context_signal_s( + action: 0, + id: idPtr, + metadata: metaPtr + ) + _ = bridge.handleAction(target: target, action: action) + } + } + + #expect(receivedAction == 0) + #expect(receivedID == "claude") + #expect(receivedMetadata == "event=busy;token=abc") + } + + @Test func contextSignalDropsNullIDOrMetadata() { + let bridge = GhosttySurfaceBridge() + var invoked = false + bridge.onContextSignal = { _, _, _ in invoked = true } + + var action = ghostty_action_s() + action.tag = GHOSTTY_ACTION_CONTEXT_SIGNAL + let target = ghostty_target_s() + + // Null id with valid metadata. + "event=busy;token=abc".withCString { metaPtr in + action.action.context_signal = ghostty_action_context_signal_s( + action: 0, + id: nil, + metadata: metaPtr + ) + _ = bridge.handleAction(target: target, action: action) + } + #expect(invoked == false) + + // Valid id with null metadata. + "claude".withCString { idPtr in + action.action.context_signal = ghostty_action_context_signal_s( + action: 0, + id: idPtr, + metadata: nil + ) + _ = bridge.handleAction(target: target, action: action) + } + #expect(invoked == false) } @Test func coalescesBurstOfProgressReports() async { diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 6ef70cf66..3f56b062b 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -142,7 +142,7 @@ struct WorktreeTerminalManagerTests { #expect(state.socketPath == nil) } - @Test func socketActivityEventRoutesToDecodedWorktreeState() async { + @Test func oscHookActivityEventRoutesToWorktreeState() async { let server = AgentHookSocketServer() let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server) let worktree = makeWorktree(id: "/tmp/repo/wt with spaces") @@ -157,14 +157,14 @@ struct WorktreeTerminalManagerTests { return } - server.onEvent?(makeHookEvent(.sessionStart, surfaceID: surface.id, pid: getpid())) - server.onEvent?(makeHookEvent(.busy, surfaceID: surface.id)) + state.onAgentHookEvent?(makeHookEvent(.sessionStart, surfaceID: surface.id, pid: getpid())) + state.onAgentHookEvent?(makeHookEvent(.busy, surfaceID: surface.id)) await presence.drain() #expect(presence.state.hasActivity(in: [surface.id])) } - @Test func socketIdleEventIsDebouncedAcrossToolStorm() async { + @Test func oscIdleEventIsDebouncedAcrossToolStorm() async { let clock = TestClock() let server = AgentHookSocketServer() let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server, clock: clock) @@ -179,23 +179,23 @@ struct WorktreeTerminalManagerTests { return } - server.onEvent?(makeHookEvent(.sessionStart, surfaceID: surface.id, pid: getpid())) - server.onEvent?(makeHookEvent(.busy, surfaceID: surface.id)) + state.onAgentHookEvent?(makeHookEvent(.sessionStart, surfaceID: surface.id, pid: getpid())) + state.onAgentHookEvent?(makeHookEvent(.busy, surfaceID: surface.id)) await presence.drain() #expect(presence.state.hasActivity(in: [surface.id])) - server.onEvent?(makeHookEvent(.idle, surfaceID: surface.id)) + state.onAgentHookEvent?(makeHookEvent(.idle, surfaceID: surface.id)) await clock.advance(by: .milliseconds(100)) await presence.drain() #expect(presence.state.hasActivity(in: [surface.id])) - server.onEvent?(makeHookEvent(.busy, surfaceID: surface.id)) + state.onAgentHookEvent?(makeHookEvent(.busy, surfaceID: surface.id)) await clock.advance(by: .milliseconds(500)) await presence.drain() #expect(presence.state.hasActivity(in: [surface.id])) } - @Test func socketIdleCommitsAfterDebounceWindow() async { + @Test func oscIdleCommitsAfterDebounceWindow() async { let clock = TestClock() let server = AgentHookSocketServer() let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server, clock: clock) @@ -210,9 +210,9 @@ struct WorktreeTerminalManagerTests { return } - server.onEvent?(makeHookEvent(.sessionStart, surfaceID: surface.id, pid: getpid())) - server.onEvent?(makeHookEvent(.busy, surfaceID: surface.id)) - server.onEvent?(makeHookEvent(.idle, surfaceID: surface.id)) + state.onAgentHookEvent?(makeHookEvent(.sessionStart, surfaceID: surface.id, pid: getpid())) + state.onAgentHookEvent?(makeHookEvent(.busy, surfaceID: surface.id)) + state.onAgentHookEvent?(makeHookEvent(.idle, surfaceID: surface.id)) await clock.advance(by: .milliseconds(399)) await presence.drain() @@ -223,7 +223,7 @@ struct WorktreeTerminalManagerTests { #expect(!presence.state.hasActivity(in: [surface.id])) } - @Test func socketIdleDebouncesPerAgentIndependently() async { + @Test func oscIdleDebouncesPerAgentIndependently() async { let clock = TestClock() let server = AgentHookSocketServer() let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server, clock: clock) @@ -238,13 +238,13 @@ struct WorktreeTerminalManagerTests { return } - server.onEvent?(makeHookEvent(.sessionStart, agent: .claude, surfaceID: surface.id, pid: getpid())) - server.onEvent?(makeHookEvent(.sessionStart, agent: .codex, surfaceID: surface.id, pid: getpid())) - server.onEvent?(makeHookEvent(.busy, agent: .claude, surfaceID: surface.id)) - server.onEvent?(makeHookEvent(.busy, agent: .codex, surfaceID: surface.id)) + state.onAgentHookEvent?(makeHookEvent(.sessionStart, agent: .claude, surfaceID: surface.id, pid: getpid())) + state.onAgentHookEvent?(makeHookEvent(.sessionStart, agent: .codex, surfaceID: surface.id, pid: getpid())) + state.onAgentHookEvent?(makeHookEvent(.busy, agent: .claude, surfaceID: surface.id)) + state.onAgentHookEvent?(makeHookEvent(.busy, agent: .codex, surfaceID: surface.id)) // Codex idles; Claude stays busy. After window, only Codex should commit idle. - server.onEvent?(makeHookEvent(.idle, agent: .codex, surfaceID: surface.id)) + state.onAgentHookEvent?(makeHookEvent(.idle, agent: .codex, surfaceID: surface.id)) await clock.advance(by: .milliseconds(400)) await presence.drain() @@ -256,7 +256,7 @@ struct WorktreeTerminalManagerTests { #expect(presence.state.hasActivity(in: [surface.id])) } - @Test func socketSessionEndCancelsPendingIdle() async { + @Test func oscSessionEndCancelsPendingIdle() async { let clock = TestClock() let server = AgentHookSocketServer() let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server, clock: clock) @@ -272,10 +272,10 @@ struct WorktreeTerminalManagerTests { } let pid = getpid() - server.onEvent?(makeHookEvent(.sessionStart, surfaceID: surface.id, pid: pid)) - server.onEvent?(makeHookEvent(.busy, surfaceID: surface.id)) - server.onEvent?(makeHookEvent(.idle, surfaceID: surface.id)) - server.onEvent?(makeHookEvent(.sessionEnd, surfaceID: surface.id, pid: pid)) + state.onAgentHookEvent?(makeHookEvent(.sessionStart, surfaceID: surface.id, pid: pid)) + state.onAgentHookEvent?(makeHookEvent(.busy, surfaceID: surface.id)) + state.onAgentHookEvent?(makeHookEvent(.idle, surfaceID: surface.id)) + state.onAgentHookEvent?(makeHookEvent(.sessionEnd, surfaceID: surface.id, pid: pid)) await clock.advance(by: .milliseconds(500)) await presence.drain() @@ -284,7 +284,7 @@ struct WorktreeTerminalManagerTests { #expect(!presence.state.hasActivity(in: [surface.id])) } - @Test func socketSurfaceClosedWhileIdlePendingIsHarmless() async { + @Test func oscSurfaceClosedWhileIdlePendingIsHarmless() async { let clock = TestClock() let server = AgentHookSocketServer() let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server, clock: clock) @@ -299,9 +299,9 @@ struct WorktreeTerminalManagerTests { return } - server.onEvent?(makeHookEvent(.sessionStart, surfaceID: surface.id, pid: getpid())) - server.onEvent?(makeHookEvent(.busy, surfaceID: surface.id)) - server.onEvent?(makeHookEvent(.idle, surfaceID: surface.id)) + state.onAgentHookEvent?(makeHookEvent(.sessionStart, surfaceID: surface.id, pid: getpid())) + state.onAgentHookEvent?(makeHookEvent(.busy, surfaceID: surface.id)) + state.onAgentHookEvent?(makeHookEvent(.idle, surfaceID: surface.id)) // Settle the stream-delivered events before the direct close so a buffered // busy can't resurrect activity after the surface is gone. @@ -313,31 +313,27 @@ struct WorktreeTerminalManagerTests { #expect(!presence.state.hasActivity(in: [surface.id])) } - @Test func socketNotificationRoutesToDecodedWorktreeState() { + @Test func oscHookNotificationLandsInWorktreeState() { withDependencies { $0.date.now = Date(timeIntervalSince1970: 1_234) + $0.continuousClock = ImmediateClock() } operation: { - let server = AgentHookSocketServer() - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime(), socketServer: server) + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree(id: "/tmp/repo/wt with spaces") manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf(), - let encodedID = worktree.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) + let surface = state.splitTree(for: tabId).root?.leftmostLeaf() else { - Issue.record("Expected blocking script tab and socket server") + Issue.record("Expected blocking script tab") return } - server.onNotification?( - encodedID, - tabId.rawValue, - surface.id, - AgentHookNotification(agent: "codex", event: "Stop", title: "Done", body: "All complete") - ) + // The OSC notify path decodes + sanitizes app-side, then appends to the + // surface's worktree state via this same entry point. + state.appendHookNotification(title: "Done", body: "All complete", surfaceID: surface.id) #expect( state.notifications.contains { @@ -616,6 +612,7 @@ struct WorktreeTerminalManagerTests { @Test func appendNotificationFlipsPerSurfaceFlagOnArrival() { withDependencies { $0.date.now = Date(timeIntervalSince1970: 1_234) + $0.continuousClock = ImmediateClock() } operation: { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() @@ -636,6 +633,7 @@ struct WorktreeTerminalManagerTests { @Test func appendNotificationDoesNotFlipFlagWhenFocusedAndSelected() { withDependencies { $0.date.now = Date(timeIntervalSince1970: 1_234) + $0.continuousClock = ImmediateClock() } operation: { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() @@ -726,6 +724,7 @@ struct WorktreeTerminalManagerTests { @Test func notificationsDisabledSkipsPerSurfaceFlag() { withDependencies { $0.date.now = Date(timeIntervalSince1970: 1_234) + $0.continuousClock = ImmediateClock() } operation: { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() @@ -1460,7 +1459,7 @@ struct WorktreeTerminalManagerTests { "surface_id": "\(surfaceID.uuidString)"\(pidLine) } """ - guard case .event(let event) = AgentHookSocketServer.parse(data: Data(json.utf8)) else { + guard let event = try? JSONDecoder().decode(AgentHookEvent.self, from: Data(json.utf8)) else { preconditionFailure("Failed to parse test event") } return event From 0e26cbfe4f81e889d1442c47bc2b5cb5b3c930b7 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 10 Jun 2026 17:44:17 +0200 Subject: [PATCH 12/56] Carry OSC 3008 notifications as extracted title/body (#392) 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=;body=. 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. --- .../BusinessLogic/AgentPresenceOSC.swift | 121 ++++++++++---- .../BusinessLogic/PiExtensionContent.swift | 31 ++-- patches/ghostty-osc3008-context-signal.patch | 15 ++ .../Models/WorktreeTerminalState.swift | 37 +++-- .../AgentHookSocketServer.swift | 78 --------- supacodeTests/AgentHookCommandTests.swift | 106 +++++++++++-- .../AgentHookSocketServerTests.swift | 67 -------- supacodeTests/AgentPresenceOSCTests.swift | 150 ++++++++++++------ 8 files changed, 339 insertions(+), 266 deletions(-) diff --git a/SupacodeSettingsShared/BusinessLogic/AgentPresenceOSC.swift b/SupacodeSettingsShared/BusinessLogic/AgentPresenceOSC.swift index 499e1ac6d..ee10ac3e6 100644 --- a/SupacodeSettingsShared/BusinessLogic/AgentPresenceOSC.swift +++ b/SupacodeSettingsShared/BusinessLogic/AgentPresenceOSC.swift @@ -19,8 +19,9 @@ import Foundation /// sweep so a crashed local agent is reaped. Omitted over SSH. /// /// The same transport also carries the rich notification leg -/// (`kind=notify;token=;data=`); presence and notify are -/// disjoint metadata shapes routed by which `parse*` succeeds. +/// (`kind=notify;token=;title=;body=`); the emitter +/// extracts the display title/body so the wire stays small and the app carries no +/// agent-specific JSON shape. Presence and notify are disjoint metadata shapes. /// /// Single source of truth for both the emit side (the agent hook) and the parse /// side (the app), so the field names can't drift. @@ -33,9 +34,19 @@ public nonisolated enum AgentPresenceOSC { static let tokenField = "token" static let pidField = "pid" static let kindField = "kind" - static let dataField = "data" + static let titleField = "title" + static let bodyField = "body" static let notifyKind = "notify" + /// Notify body source keys, in display precedence. Used by the shell extractor + /// (`emitNotifyShell`); the Pi extension sends its body directly. + public static let notifyBodyKeys = ["message", "last_assistant_message", "assistant_response"] + + /// Emit-side byte caps. Keep the notify metadata under libghostty's 2048-byte + /// OSC buffer, over which the whole sequence is discarded (not truncated). + static let notifyBodyByteBudget = 1000 + static let notifyTitleByteBudget = 160 + /// A parsed, NOT-yet-trusted presence signal. The caller must verify `token` /// against the receiving surface's nonce before acting on it. public struct Signal: Equatable, Sendable { @@ -82,7 +93,7 @@ public nonisolated enum AgentPresenceOSC { /// True when the metadata carries `kind=notify`. Cheap routing check (presence /// vs notify) that inspects the `kind` field, not a raw substring, so a base64 - /// `data` value that happens to contain "kind=notify" can't misroute. + /// `body` value that happens to contain "kind=notify" can't misroute. public static func isNotifyMetadata(_ metadata: String) -> Bool { parseFields(metadata)?[Substring(kindField)] == Substring(notifyKind) } @@ -97,7 +108,7 @@ public nonisolated enum AgentPresenceOSC { /// which an attacker who can splice into the wire could exploit to flip /// `event=` or to pair a valid `token=` with an injected `kind=notify`. All /// other duplicate keys keep the historical last-write-wins behavior. - static func parseFields(_ metadata: String) -> [Substring: Substring]? { + public static func parseFields(_ metadata: String) -> [Substring: Substring]? { var fields: [Substring: Substring] = [:] for pair in metadata.split(separator: ";", omittingEmptySubsequences: true) { guard let equalsIndex = pair.firstIndex(of: "=") else { continue } @@ -114,30 +125,57 @@ public nonisolated enum AgentPresenceOSC { Substring(tokenField), Substring(eventField), Substring(kindField), ] - /// A parsed, NOT-yet-trusted notification signal. `payload` is the decoded - /// agent JSON (the same shape the socket notify pipeline forwards). The caller - /// must verify `token` against the receiving surface's nonce before acting. + /// A parsed, NOT-yet-trusted notification signal with already-decoded display + /// text. The caller must verify `token` against the surface nonce before acting. public struct NotifySignal: Equatable, Sendable { - /// Context id, i.e. the agent rawValue. public let agent: String public let token: String - /// The base64-decoded agent notification JSON. - public let payload: Data + /// Both nil-on-empty; the caller falls back to the agent name for a missing + /// title and shows a title-only toast for a missing body. + public let title: String? + public let body: String? } - /// Parse an OSC 3008 notify signal (`kind=notify;token=;data=`). - /// Returns nil unless it carries the notify kind, a non-empty token, and a - /// base64-decodable payload. Does NOT verify the token. + /// Parse `kind=notify;token=;title=;body=`. Requires the + /// notify kind and a non-empty token; title/body are optional. Does NOT verify + /// the token. public static func parseNotify(id: String, metadata: String) -> NotifySignal? { guard !id.isEmpty else { return nil } guard let fields = parseFields(metadata) else { return nil } guard fields[Substring(kindField)] == Substring(notifyKind) else { return nil } guard let token = fields[Substring(tokenField)], !token.isEmpty else { return nil } - guard - let rawData = fields[Substring(dataField)], - let payload = Data(base64Encoded: String(rawData)) - else { return nil } - return NotifySignal(agent: id, token: String(token), payload: payload) + return NotifySignal( + agent: id, + token: String(token), + title: decodedNotifyField(fields[Substring(titleField)]), + body: decodedNotifyField(fields[Substring(bodyField)]), + ) + } + + private static func decodedNotifyField(_ raw: Substring?) -> String? { + guard let raw, let text = decodeNotifyValue(String(raw)), !text.isEmpty else { return nil } + return text + } + + /// Reverse one base64 notify field back to display text. A field byte-capped at + /// emit can end mid-escape or mid-UTF8, so trailing bytes are shed until the + /// quoted value parses as a JSON string (`JSONDecoder` rejects both an invalid + /// UTF-8 tail and a dangling escape). nil only on non-base64 input; an + /// undecodable-but-base64 field collapses to "" (treated as absent, not a + /// parse failure). + static func decodeNotifyValue(_ base64: String) -> String? { + guard var data = Data(base64Encoded: base64) else { return nil } + let quote = Data([0x22]) + // 12 = longest dangling escape (a `\uXXXX\uXXXX` surrogate pair), so a cut + // mid-pair still sheds back to valid text instead of dropping the whole body. + for _ in 0...min(12, data.count) { + if let text = try? JSONDecoder().decode(String.self, from: quote + data + quote) { + return text + } + if data.isEmpty { break } + data.removeLast() + } + return "" } /// The OSC 3008 action for an event: session_end ends a context, everything @@ -187,21 +225,42 @@ public nonisolated enum AgentPresenceOSC { + #"printf '\#(payload)' "$\#(tokenEnvVar)" "$__sp" > "$__tty""# } - /// The `key=value` metadata a notify signal carries. `parseNotify` recovers the - /// payload from this exact shape. - static func notifyMetadata(token: String, data: String) -> String { - "\(kindField)=\(notifyKind);\(tokenField)=\(token);\(dataField)=\(data)" + /// The `key=value` metadata a notify signal carries; `title` / `body` are base64. + static func notifyMetadata(token: String, title: String, body: String) -> String { + "\(kindField)=\(notifyKind);\(tokenField)=\(token);\(titleField)=\(title);\(bodyField)=\(body)" } - /// Shell snippet that reads the agent notification JSON from stdin, - /// base64-encodes it, and emits the OSC 3008 notify sequence echoing - /// `$SUPACODE_OSC_TOKEN`. `base64 | tr -d '\n'` is portable (macOS + Linux) and - /// strips the wrapping newlines so the payload stays a single OSC field. The - /// emit/parse pair is locked to STANDARD base64 (`Data(base64Encoded:)` rejects - /// the URL-safe alphabet a busybox/alpine `base64` might default to). + /// Portable awk that extracts one JSON string value from the agent's hook JSON + /// on stdin. `keys` is a comma-separated precedence list (first non-empty wins); + /// the raw escaped value is copied verbatim up to the first unescaped `"` and + /// capped to `budget` (a mid-escape cut is tolerated by `decodeNotifyValue`). + /// Matches the first `"key":` occurrence, assuming a flat top-level payload. + /// No `RS`/`\x` tricks and no single quote, so it is portable and shell-safe. + /// The caller runs it under `LC_ALL=C` so `length`/`substr` are byte-based + /// (gawk in a UTF-8 locale would otherwise count characters and overshoot 2048). + static let notifyExtractAwk = + #"function ws(c){return c==" "||c=="\t"||c=="\n"||c=="\r"}"# + + #"function fv(s,key, p,i,n,c,o,e){p="\""key"\"";i=index(s,p);if(i==0)return "";"# + + #"i+=length(p);n=length(s);while(i<=n){if(ws(substr(s,i,1)))i++;else break}"# + + #"if(substr(s,i,1)!=":")return "";i++;while(i<=n){if(ws(substr(s,i,1)))i++;else break}"# + + #"if(substr(s,i,1)!="\"")return "";i++;o="";e=0;while(i<=n){c=substr(s,i,1);"# + + #"if(e){o=o c;e=0;i++;continue}if(c=="\\"){o=o c;e=1;i++;continue}if(c=="\"")break;o=o c;i++}return o}"# + + #"{d=d $0}END{n=split(keys,ks,",");v="";for(j=1;j<=n;j++){v=fv(d,ks[j]);if(v!="")break}"# + + #"if(length(v)>budget+0)v=substr(v,1,budget+0);printf "%s",v}"# + + /// Reads the hook JSON from stdin once, extracts a bounded title/body via a + /// portable `awk` pass (no `jq`/`python`, so it works over SSH), base64s each, + /// and emits the OSC 3008 notify. Sending only the display fields keeps the wire + /// under libghostty's 2048-byte OSC ceiling. Locked to STANDARD base64. static func emitNotifyShell(agent: SkillAgent) -> String { - let payload = #"\033]3008;start=\#(agent.rawValue);\#(notifyMetadata(token: "%s", data: "%s"))\033\\"# - return #"__osc_d=$(base64 | tr -d '\n'); printf '\#(payload)' "$\#(tokenEnvVar)" "$__osc_d" > "$__tty""# + let payload = #"\033]3008;start=\#(agent.rawValue);\#(notifyMetadata(token: "%s", title: "%s", body: "%s"))\033\\"# + let bodyKeys = notifyBodyKeys.joined(separator: ",") + return #"__in=$(cat); "# + + #"__t=$(printf '%s' "$__in" | LC_ALL=C awk -v keys="\#(titleField)" "# + + #"-v budget=\#(notifyTitleByteBudget) '\#(notifyExtractAwk)' | base64 | tr -d '\n'); "# + + #"__b=$(printf '%s' "$__in" | LC_ALL=C awk -v keys="\#(bodyKeys)" "# + + #"-v budget=\#(notifyBodyByteBudget) '\#(notifyExtractAwk)' | base64 | tr -d '\n'); "# + + #"printf '\#(payload)' "$\#(tokenEnvVar)" "$__t" "$__b" > "$__tty""# } /// Equal-length constant-time compare. A length mismatch returns immediately; diff --git a/SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift b/SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift index a24f7df86..e15a01324 100644 --- a/SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift +++ b/SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift @@ -35,11 +35,9 @@ nonisolated enum PiExtensionContent { import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { openSync, writeSync, closeSync } from "node:fs"; - interface HookPayload { - hook_event_name: string; + interface NotifyContent { title?: string; - message?: string; - last_assistant_message?: string; + body?: string; } const AGENT = "pi"; @@ -114,9 +112,21 @@ nonisolated enum PiExtensionContent { writeToTerminal(`\\x1b]3008;${action}=${AGENT};${meta}\\x1b\\\\`); } - function emitNotification(token: string, payload: HookPayload): void { - const data = Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); - const meta = `kind=notify;token=${token};data=${data}`; + // JSON-escape (minus the surrounding quotes) so the wire matches the shell + // awk path, byte-cap to the same budget, then base64. App-side + // decodeNotifyValue reverses both and tolerates a mid-escape cut. + function notifyField(value: string, budget: number): string { + const escaped = JSON.stringify(value).slice(1, -1); + const buf = Buffer.from(escaped, "utf8"); + const capped = buf.length > budget ? buf.subarray(0, budget) : buf; + return capped.toString("base64"); + } + + function emitNotification(token: string, content: NotifyContent): void { + const meta = + `kind=notify;token=${token}` + + `;title=${notifyField(content.title ?? "", \(AgentPresenceOSC.notifyTitleByteBudget))}` + + `;body=${notifyField(content.body ?? "", \(AgentPresenceOSC.notifyBodyByteBudget))}`; writeToTerminal(`\\x1b]3008;start=${AGENT};${meta}\\x1b\\\\`); } @@ -159,12 +169,7 @@ nonisolated enum PiExtensionContent { // Atomic state-set: `idle` overwrites whatever was running on the // Supacode side (turn-level Stop equivalent). emitPresence(token, "idle"); - - const lastMessage = lastAssistantText(ctx); - emitNotification(token, { - hook_event_name: "Stop", - last_assistant_message: lastMessage, - }); + emitNotification(token, { body: lastAssistantText(ctx) }); }); pi.on("session_shutdown", (_event, _ctx) => { diff --git a/patches/ghostty-osc3008-context-signal.patch b/patches/ghostty-osc3008-context-signal.patch index e2ae75823..4f126025f 100644 --- a/patches/ghostty-osc3008-context-signal.patch +++ b/patches/ghostty-osc3008-context-signal.patch @@ -273,3 +273,18 @@ index fb3a6b3ff..08084ca69 100644 /// Send a report to the pty. pub fn sendSizeReport(self: *StreamHandler, style: terminal.SizeReportStyle) void { switch (style) { +diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig +index 36cfd7f82..f610bf4e2 100644 +--- a/src/terminal/osc.zig ++++ b/src/terminal/osc.zig +@@ -583,7 +583,9 @@ pub const Parser = struct { + }, + + .@"3008" => switch (c) { +- ';' => self.captureTrailing(.fixed), ++ // Supacode: allocating (like OSC 52) so an over-2048 notify keeps its ++ // title/earlier fields instead of being discarded whole (body may drop). ++ ';' => self.captureTrailing(.allocating), + else => self.state = .invalid, + }, + diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 07d7c57f7..14a867108 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -1491,9 +1491,8 @@ final class WorktreeTerminalState { /// Routes an OSC 3008 context signal to the presence or notify handler. private func handleContextSignal(surfaceID: UUID, id: String, metadata: String) { - // Route by notify INTENT, not by parse success: a notify whose payload was - // truncated past the 2047-byte cap fails parseNotify, and must log as a - // notify drop rather than silently falling through to the presence handler. + // Route by notify INTENT, not by parse success, so a malformed notify logs as + // a notify drop rather than silently falling through to the presence handler. if AgentPresenceOSC.isNotifyMetadata(metadata) { handleNotifySignal(surfaceID: surfaceID, id: id, metadata: metadata) } else { @@ -1565,8 +1564,7 @@ final class WorktreeTerminalState { } /// Verify an OSC 3008 notify signal against the receiving surface's nonce, then - /// decode + sanitize the agent notification and display it via the same hook - /// path the socket uses. Gated by the rich-notifications setting like the socket. + /// sanitize and display it. Gated by the rich-notifications setting. private func handleNotifySignal(surfaceID: UUID, id: String, metadata: String) { switch Self.notification( id: id, @@ -1581,16 +1579,26 @@ final class WorktreeTerminalState { terminalStateLogger.debug("Dropped trusted OSC notify; rich notifications disabled.") return } + // A body present on the wire but decoded empty means a ghostty truncation or + // an escape-cut the shed loop couldn't recover: keep it out of silent-failure + // territory by logging, even though we still show the title-only toast. + if resolved.body.isEmpty, let wireBody = AgentPresenceOSC.parseFields(metadata)?[Substring("body")], + !wireBody.isEmpty + { + terminalStateLogger.debug( + "OSC notify body present on wire (\(wireBody.utf8.count) b64 bytes) but decoded empty: surface \(surfaceID)." + ) + } appendHookNotification(title: resolved.title, body: resolved.body, surfaceID: surfaceID) case .failure(.tokenMismatch(let agent)): // A wrong token is a potential spoof over the wider SSH capability; warn. terminalStateLogger.warning( "Rejected OSC notify with mismatched token for surface \(surfaceID), agent=\(agent).") case .failure(.parseFailed): - // A payload truncated past the metadata cap fails base64 decode here. - // Log the byte count so a Ghostty truncation can be correlated with the drop. + // parseNotify only fails now on missing token / empty id (not a truncated body, + // which decodes to an empty field, logged in the success arm above). terminalStateLogger.warning( - "Dropped malformed OSC notify (metadata bytes: \(metadata.utf8.count), cap 2047) for surface \(surfaceID).") + "Dropped malformed OSC notify (metadata bytes: \(metadata.utf8.count)) for surface \(surfaceID).") case .failure(.unknownSurface), .failure(.missingToken), .failure(.empty): terminalStateLogger.debug("Dropped OSC notify signal for surface \(surfaceID).") } @@ -1606,10 +1614,9 @@ final class WorktreeTerminalState { case empty } - /// Pure trust + parse decision for an OSC notify signal: returns the sanitized - /// (title, body) on success, or a typed `NotifyDrop` so the caller can log per - /// cause. Title/body are bounded and stripped of control characters since the - /// OSC leg is a wider capability than the local socket. + /// Pure trust + parse decision for an OSC notify signal. Title/body are bounded + /// and stripped of control characters since the OSC leg is a wider capability + /// than a local socket. Title falls back to the agent name; body may be empty. nonisolated static func notification( id: String, metadata: String, @@ -1624,10 +1631,8 @@ final class WorktreeTerminalState { guard AgentPresenceOSC.tokensMatch(notify.token, expectedToken) else { return .failure(.tokenMismatch(agent: notify.agent)) } - guard let parsed = AgentHookSocketServer.parseNotification(agent: notify.agent, data: notify.payload) - else { return .failure(.parseFailed) } - let title = sanitizeNotificationText(parsed.title ?? notify.agent, max: 200) - let body = sanitizeNotificationText(parsed.body ?? "", max: 1000) + let title = sanitizeNotificationText(notify.title ?? notify.agent, max: 200) + let body = sanitizeNotificationText(notify.body ?? "", max: 1000) guard !(title.isEmpty && body.isEmpty) else { return .failure(.empty) } return .success((title, body)) } diff --git a/supacode/Infrastructure/AgentHookSocketServer.swift b/supacode/Infrastructure/AgentHookSocketServer.swift index 0e4280975..71da74d69 100644 --- a/supacode/Infrastructure/AgentHookSocketServer.swift +++ b/supacode/Infrastructure/AgentHookSocketServer.swift @@ -330,31 +330,6 @@ final class AgentHookSocketServer { return parseJSONMessage(data: data) } - /// Parses an agent notification JSON payload into an `AgentHookNotification`, - /// decoding the body from whichever agent-specific field is present. The OSC - /// notify leg is the sole caller; the socket no longer carries notifications. - nonisolated static func parseNotification( - agent: String, - data: Data - ) -> AgentHookNotification? { - guard let payload = try? JSONDecoder().decode(AgentHookPayload.self, from: data) else { - let preview = String(data: data.prefix(200), encoding: .utf8) ?? "" - socketLogger.warning("Failed to decode \(agent) notification payload: \(preview)") - return nil - } - - if payload.body == nil { - socketLogger.warning( - "All body fields nil in \(agent) \(payload.hookEventName ?? "unknown") notification") - } - return AgentHookNotification( - agent: agent, - event: payload.hookEventName ?? "unknown", - title: payload.title, - body: payload.body - ) - } - /// Parses a CLI JSON message into a query or command. The placeholder /// `clientFD` of `-1` is replaced with the real FD in `acceptAndParse`. private nonisolated static func parseJSONMessage(data: Data) -> Message? { @@ -375,14 +350,6 @@ final class AgentHookSocketServer { } } -/// Parsed notification from a coding agent hook event. -nonisolated struct AgentHookNotification: Equatable, Sendable { - let agent: String - let event: String - let title: String? - let body: String? -} - /// An agent presence/activity event. Built by the OSC ingest from a verified /// presence signal (memberwise init, attributed to the receiving surface), and /// also `Decodable` from the legacy JSON envelope shape below for test @@ -525,51 +492,6 @@ nonisolated struct AgentHookEvent: Equatable, Sendable, Decodable { } } -/// Raw JSON payload from a coding agent hook event. The `body` is decoded from -/// whichever agent-specific field is present: Claude uses `message`, Codex uses -/// `last_assistant_message`, Kiro uses `assistant_response`. Precedence favors -/// `message` so unknown agents that speak the Claude shape keep working. -private nonisolated struct AgentHookPayload: Decodable { - let hookEventName: String? - let title: String? - let body: String? - - private enum CodingKeys: String, CodingKey { - case hookEventName = "hook_event_name" - case title - case message - case lastAssistantMessage = "last_assistant_message" - case assistantResponse = "assistant_response" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - hookEventName = try container.decodeIfPresent(String.self, forKey: .hookEventName) - title = try container.decodeIfPresent(String.self, forKey: .title) - // Tolerate per-field decode errors (e.g. `"message": 42`) so a single - // malformed field does not drop the whole notification; fall through - // to the next candidate instead. - let candidates = [CodingKeys.message, .lastAssistantMessage, .assistantResponse] - .map { key in Self.decodeOptionalString(container, forKey: key) } - // Skip empty strings too: Claude occasionally emits `"message": ""`, in - // which case Codex's `last_assistant_message` / Kiro's `assistant_response` - // still hold the useful body. - body = candidates.compactMap { $0 }.first { !$0.isEmpty } - } - - private static func decodeOptionalString( - _ container: KeyedDecodingContainer, - forKey key: CodingKeys - ) -> String? { - do { - return try container.decodeIfPresent(String.self, forKey: key) - } catch { - socketLogger.warning("Failed to decode hook payload field \(key.rawValue): \(error)") - return nil - } - } -} - /// Parsed CLI request payload: either a deeplink command or a query with params. private nonisolated enum SocketCommandRequest { case command(deeplink: String, params: [String: String]) diff --git a/supacodeTests/AgentHookCommandTests.swift b/supacodeTests/AgentHookCommandTests.swift index e5b58bd7b..7cd8f5b93 100644 --- a/supacodeTests/AgentHookCommandTests.swift +++ b/supacodeTests/AgentHookCommandTests.swift @@ -87,8 +87,8 @@ struct AgentHookCommandTests { @Test func notifyDoesNotReferenceWorktreeOrTabIDs() { // The notify leg used to prefix a `worktree tab surface agent` header for - // the socket text proto. The OSC notify carries only the base64 payload and - // the per-surface token, so those ids must be gone. + // the socket text proto. The OSC notify carries only the per-surface token + // and base64 title/body, so those ids must be gone. let command = AgentHookSettingsCommand.compositeCommand( events: [], forwardStdinAsNotification: true, agent: .codex) #expect(!command.contains("SUPACODE_WORKTREE_ID")) @@ -348,7 +348,7 @@ struct AgentHookCommandTests { @Test func notifyComposesOSCNotify() { let command = AgentHookSettingsCommand.compositeCommand( events: [.idle], forwardStdinAsNotification: true, agent: .claude) - #expect(command.contains("]3008;start=claude;kind=notify;token=%s;data=%s")) + #expect(command.contains("]3008;start=claude;kind=notify;token=%s;title=%s;body=%s")) #expect(command.contains("base64 | tr -d")) } @@ -387,7 +387,9 @@ struct AgentHookCommandTests { #expect(remoteSignal.pid == nil) } - @Test func notifyBase64sStdinPayload() throws { + @Test func notifyExtractsBodyFromStdinThroughAwk() throws { + // End-to-end: the real shell hook runs the awk extractor over Claude's stdin + // JSON and the resulting OSC parses back with the body intact. let json = #"{"hook_event_name":"Stop","message":"hi there"}"# let base: [String: String] = [ "SUPACODE_OSC_TOKEN": "tok", @@ -396,14 +398,17 @@ struct AgentHookCommandTests { let command = AgentHookSettingsCommand.compositeCommand( events: [], forwardStdinAsNotification: true, agent: .claude) let tty = try runHookCommandCapturingTTY(command, env: base, stdin: json) - #expect(tty.contains("]3008;start=claude;kind=notify;token=tok;data=")) - #expect(tty.contains("data=\(Data(json.utf8).base64EncodedString())")) + let signal = try #require(Self.parseNotify(fromTTY: tty)) + #expect(signal.token == "tok") + #expect(signal.body == "hi there") } - @Test func eventsPlusNotifyDeliverPresenceAndFullStdin() throws { - // events + notify: both legs fire, and the notify leg must receive the - // COMPLETE JSON (not a partial read), proving the guarded-body stdin handoff. - let json = #"{"hook_event_name":"Stop","message":"the full message survives intact"}"# + @Test func notifyAwkPreservesEscapedQuotesNewlinesAndUnicode() throws { + // The awk extractor must copy the escaped JSON value verbatim so embedded + // quotes / newlines / unicode survive the round-trip, and pick the body via + // the precedence list (here `last_assistant_message`, with `message` empty). + let json = + #"{"hook_event_name":"Stop","title":"Done","message":"","last_assistant_message":"line \"one\"\nDONE ✓"}"# let base: [String: String] = [ "SUPACODE_OSC_TOKEN": "tok", "SUPACODE_SURFACE_ID": UUID().uuidString, @@ -412,7 +417,55 @@ struct AgentHookCommandTests { events: [.idle], forwardStdinAsNotification: true, agent: .claude) let tty = try runHookCommandCapturingTTY(command, env: base, stdin: json) #expect(tty.contains("]3008;start=claude;event=idle;")) - #expect(tty.contains("data=\(Data(json.utf8).base64EncodedString())")) + let signal = try #require(Self.parseNotify(fromTTY: tty)) + #expect(signal.title == "Done") + #expect(signal.body == "line \"one\"\nDONE ✓") + } + + @Test(arguments: [ + // message wins over the fallbacks. + (#"{"message":"primary","last_assistant_message":"secondary","assistant_response":"tertiary"}"#, "primary"), + // The awk is not type-aware: empty / null / numeric `message` falls through only + // because `fv` requires an opening `"` after the colon and finds none. + (#"{"message":"","last_assistant_message":"fallback"}"#, "fallback"), + (#"{"message":null,"last_assistant_message":"fallback"}"#, "fallback"), + (#"{"message":42,"assistant_response":"kiro body"}"#, "kiro body"), + (#"{"assistant_response":"kiro body"}"#, "kiro body"), + ]) + func notifyAwkResolvesBodyByPrecedence(json: String, expectedBody: String) throws { + let base: [String: String] = [ + "SUPACODE_OSC_TOKEN": "tok", + "SUPACODE_SURFACE_ID": UUID().uuidString, + ] + let command = AgentHookSettingsCommand.compositeCommand( + events: [], forwardStdinAsNotification: true, agent: .claude) + let tty = try runHookCommandCapturingTTY(command, env: base, stdin: json) + let signal = try #require(Self.parseNotify(fromTTY: tty)) + #expect(signal.body == expectedBody) + } + + @Test func notifyByteCapFiresAndWireStaysUnderOSCCeiling() throws { + // Drive a body past notifyBodyByteBudget through the REAL awk and assert the + // emitted metadata stays under libghostty's 2048-byte OSC ceiling (the headline + // guarantee) and the decoded body is a sane truncated prefix. Exercises the + // `length(v)>budget` branch and the LC_ALL=C byte cap end to end. + let bodyText = String(repeating: "a", count: 4000) + let json = #"{"hook_event_name":"Stop","message":"\#(bodyText)"}"# + let base: [String: String] = [ + "SUPACODE_OSC_TOKEN": "tok", + "SUPACODE_SURFACE_ID": UUID().uuidString, + ] + let command = AgentHookSettingsCommand.compositeCommand( + events: [], forwardStdinAsNotification: true, agent: .claude) + let tty = try runHookCommandCapturingTTY(command, env: base, stdin: json) + // Metadata is everything after `]3008;` up to ST; assert it is under the cap. + let marker = try #require(tty.range(of: "]3008;")) + let afterMarker = tty[marker.upperBound...] + let stRange = try #require(afterMarker.range(of: "\u{1b}\\")) + #expect(afterMarker[.. AgentPresenceOSC.NotifySignal? { + guard let kindRange = tty.range(of: "kind=notify") else { return nil } + guard + let marker = tty.range( + of: "]3008;", options: .backwards, range: tty.startIndex.. "$__tty"; "# + let bodyKeys = AgentPresenceOSC.notifyBodyKeys.joined(separator: ",") + let awk = AgentPresenceOSC.notifyExtractAwk + return #"__in=$(cat); "# + + #"__t=$(printf '%s' "$__in" | LC_ALL=C awk -v keys="\#(AgentPresenceOSC.titleField)" "# + + #"-v budget=\#(AgentPresenceOSC.notifyTitleByteBudget) '\#(awk)' | base64 | tr -d '\n'); "# + + #"__b=$(printf '%s' "$__in" | LC_ALL=C awk -v keys="\#(bodyKeys)" "# + + #"-v budget=\#(AgentPresenceOSC.notifyBodyByteBudget) '\#(awk)' | base64 | tr -d '\n'); "# + + #"printf '\033]3008;start=\#(agent);kind=notify;token=%s;title=%s;body=%s\033\\' "# + + #""$SUPACODE_OSC_TOKEN" "$__t" "$__b" > "$__tty"; "# } static let snapshotClaudeBusy = diff --git a/supacodeTests/AgentHookSocketServerTests.swift b/supacodeTests/AgentHookSocketServerTests.swift index a74eb9f97..e8f690171 100644 --- a/supacodeTests/AgentHookSocketServerTests.swift +++ b/supacodeTests/AgentHookSocketServerTests.swift @@ -99,73 +99,6 @@ struct AgentHookSocketServerTests { #expect(payload == nil) } - // MARK: - Notification payload decoding (OSC notify leg). - - // `parseNotification` is the agent-JSON body decoder the OSC notify - // leg uses; these lock the per-agent title/body precedence it relies on. - - @Test func decodesNotificationTitleAndMessageBody() { - let payload = #"{"hook_event_name":"Stop","title":"Done","message":"All tasks complete"}"# - let notification = AgentHookSocketServer.parseNotification(agent: "claude", data: Data(payload.utf8)) - #expect(notification?.agent == "claude") - #expect(notification?.event == "Stop") - #expect(notification?.title == "Done") - #expect(notification?.body == "All tasks complete") - } - - @Test func fallsBackToLastAssistantMessage() { - let payload = #"{"hook_event_name":"Stop","last_assistant_message":"fallback body"}"# - let notification = AgentHookSocketServer.parseNotification(agent: "codex", data: Data(payload.utf8)) - #expect(notification?.body == "fallback body") - } - - @Test func fallsBackToAssistantResponse() { - let payload = #"{"hook_event_name":"stop","assistant_response":"kiro body"}"# - let notification = AgentHookSocketServer.parseNotification(agent: "kiro", data: Data(payload.utf8)) - #expect(notification?.body == "kiro body") - } - - @Test func lastAssistantMessageTakesPrecedenceOverAssistantResponse() { - let payload = - #"{"hook_event_name":"Stop","last_assistant_message":"codex body","assistant_response":"kiro body"}"# - let notification = AgentHookSocketServer.parseNotification(agent: "codex", data: Data(payload.utf8)) - #expect(notification?.body == "codex body") - } - - @Test func messageFieldTakesPrecedenceOverAllFallbacks() { - let payload = - #"{"hook_event_name":"Stop","message":"primary","# - + #""last_assistant_message":"secondary","assistant_response":"tertiary"}"# - let notification = AgentHookSocketServer.parseNotification(agent: "claude", data: Data(payload.utf8)) - #expect(notification?.body == "primary") - } - - @Test func nullMessageFieldFallsThroughToLastAssistantMessage() { - let payload = #"{"hook_event_name":"Stop","message":null,"last_assistant_message":"fallback"}"# - let notification = AgentHookSocketServer.parseNotification(agent: "codex", data: Data(payload.utf8)) - #expect(notification?.body == "fallback") - } - - @Test func emptyStringMessageFieldFallsThroughToFallback() { - let payload = #"{"hook_event_name":"Stop","message":"","last_assistant_message":"real body"}"# - let notification = AgentHookSocketServer.parseNotification(agent: "codex", data: Data(payload.utf8)) - #expect(notification?.body == "real body") - } - - @Test func typeMismatchOnMessageFieldFallsThroughToFallback() { - // Claude-shape with an unexpectedly numeric message: the decoder tolerates - // the mismatch and falls through to assistant_response. - let payload = #"{"hook_event_name":"stop","message":42,"assistant_response":"kiro body"}"# - let notification = AgentHookSocketServer.parseNotification(agent: "kiro", data: Data(payload.utf8)) - #expect(notification?.body == "kiro body") - } - - @Test func invalidJSONNotificationPayloadReturnsNil() { - let notification = AgentHookSocketServer.parseNotification( - agent: "claude", data: Data("not json at all".utf8)) - #expect(notification == nil) - } - // MARK: - AgentHookEvent decoding. // `AgentHookEvent` is the in-app event type the OSC ingest synthesizes; it is diff --git a/supacodeTests/AgentPresenceOSCTests.swift b/supacodeTests/AgentPresenceOSCTests.swift index 3cb99a3a6..64ef93e81 100644 --- a/supacodeTests/AgentPresenceOSCTests.swift +++ b/supacodeTests/AgentPresenceOSCTests.swift @@ -187,59 +187,115 @@ struct AgentPresenceOSCTests { // MARK: - parseNotify. + /// base64 of the JSON-escaped content of `text`, matching the wire the emitter + /// ships (`awk`-extracted escaped value / Pi's `JSON.stringify(...).slice(1,-1)`). + private static func field(_ text: String) -> String { + guard let json = try? JSONEncoder().encode(text) else { return "" } + // `json` is `"..."`; drop the surrounding quote bytes, keep the escaped content. + return Data(json.dropFirst().dropLast()).base64EncodedString() + } + + private static func notifyMeta(token: String = "tok", title: String? = nil, body: String? = nil) -> String { + AgentPresenceOSC.notifyMetadata( + token: token, title: title.map(field) ?? "", body: body.map(field) ?? "") + } + @Test func parsesValidNotify() { - let json = #"{"hook_event_name":"Stop","message":"hi"}"# - let b64 = Data(json.utf8).base64EncodedString() - let signal = AgentPresenceOSC.parseNotify(id: "claude", metadata: "kind=notify;token=tok;data=\(b64)") + let signal = AgentPresenceOSC.parseNotify(id: "claude", metadata: Self.notifyMeta(body: "hi")) #expect(signal?.agent == "claude") #expect(signal?.token == "tok") - #expect(signal?.payload == Data(json.utf8)) + #expect(signal?.title == nil) + #expect(signal?.body == "hi") + } + + @Test func parsesNotifyWithTitleAndBody() { + let signal = AgentPresenceOSC.parseNotify( + id: "claude", metadata: Self.notifyMeta(title: "Done", body: "all good")) + #expect(signal?.title == "Done") + #expect(signal?.body == "all good") + } + + @Test func decodesEscapedQuotesNewlinesAndUnicode() { + let signal = AgentPresenceOSC.parseNotify( + id: "claude", metadata: Self.notifyMeta(body: "line \"one\"\nDONE ✓")) + #expect(signal?.body == "line \"one\"\nDONE ✓") } @Test func rejectsNotifyWithoutKind() { - let b64 = Data("x".utf8).base64EncodedString() - #expect(AgentPresenceOSC.parseNotify(id: "claude", metadata: "token=tok;data=\(b64)") == nil) + #expect(AgentPresenceOSC.parseNotify(id: "claude", metadata: "token=tok;body=\(Self.field("x"))") == nil) } @Test func rejectsNotifyWithoutToken() { - let b64 = Data("x".utf8).base64EncodedString() - #expect(AgentPresenceOSC.parseNotify(id: "claude", metadata: "kind=notify;data=\(b64)") == nil) + #expect(AgentPresenceOSC.parseNotify(id: "claude", metadata: "kind=notify;body=\(Self.field("x"))") == nil) + } + + @Test func notifyWithoutBodyParsesAsTitleOnly() { + // Body is optional: a missing body yields a title-only signal (macOS shows a + // body-less toast), not a dropped notify. + let signal = AgentPresenceOSC.parseNotify(id: "claude", metadata: Self.notifyMeta(title: "Heads up")) + #expect(signal?.title == "Heads up") + #expect(signal?.body == nil) } - @Test func rejectsNotifyWithoutData() { - #expect(AgentPresenceOSC.parseNotify(id: "claude", metadata: "kind=notify;token=tok") == nil) + @Test func rejectsNotifyWithEmptyId() { + #expect(AgentPresenceOSC.parseNotify(id: "", metadata: Self.notifyMeta(body: "x")) == nil) } - @Test func rejectsNotifyWithInvalidBase64() { - #expect(AgentPresenceOSC.parseNotify(id: "claude", metadata: "kind=notify;token=tok;data=!!notb64") == nil) + @Test func invalidBase64FieldDecodesToNil() { + let signal = AgentPresenceOSC.parseNotify(id: "claude", metadata: "kind=notify;token=tok;body=!!notb64") + #expect(signal?.body == nil) } - @Test func rejectsNotifyWithEmptyId() { - let b64 = Data("x".utf8).base64EncodedString() - #expect(AgentPresenceOSC.parseNotify(id: "", metadata: "kind=notify;token=tok;data=\(b64)") == nil) + @Test func decodeToleratesTrailingPartialEscapeFromEmitCut() { + // A body byte-capped mid-`\"` leaves a dangling backslash; decoding must shed + // it and preview the rest rather than dropping the notify (the >2048 truncate + // path relies on this). + let escaped = #"say \"# // ends with a lone backslash (a cut `\"`) + let signal = AgentPresenceOSC.parseNotify( + id: "claude", metadata: "kind=notify;token=tok;body=\(Data(escaped.utf8).base64EncodedString())") + #expect(signal?.body == "say ") + } + + @Test func decodeShedsCutSurrogatePairEscapeToPreview() { + // A body cut mid surrogate-pair escape (`\uD83D\uDE0` is 11 dangling bytes) + // must shed back to the recoverable prefix, not drop the whole body to empty. + let escaped = #"done \uD83D\uDE0"# + let signal = AgentPresenceOSC.parseNotify( + id: "claude", metadata: "kind=notify;token=tok;body=\(Data(escaped.utf8).base64EncodedString())") + // The recoverable prefix survives (not dropped to empty); exact trailing + // depends on Foundation's lone-surrogate handling, so assert the prefix. + #expect(signal?.body?.hasPrefix("done") == true) + } + + @Test func base64TruncatedBodyDecodesToNilTitleOnly() { + // A mid-base64 cut (length not a multiple of 4, the ghostty .allocating path) + // is not decodable: the body drops and the toast falls back to the title. + let valid = Data("hello world body".utf8).base64EncodedString() + let cut = String(valid.dropLast()) // break base64 alignment + let signal = AgentPresenceOSC.parseNotify( + id: "claude", metadata: "kind=notify;token=tok;title=\(Self.field("Done"));body=\(cut)") + #expect(signal?.title == "Done") + #expect(signal?.body == nil) } @Test func notifyMetadataRoundTripsThroughParseNotify() { - let json = #"{"message":"round trip"}"# - let metadata = AgentPresenceOSC.notifyMetadata(token: "tok", data: Data(json.utf8).base64EncodedString()) + let metadata = Self.notifyMeta(title: "T", body: "round trip") let signal = AgentPresenceOSC.parseNotify(id: "codex", metadata: metadata) - #expect(signal?.payload == Data(json.utf8)) + #expect(signal?.title == "T") + #expect(signal?.body == "round trip") #expect(signal?.token == "tok") } @Test func presenceParseRejectsNotifyMetadata() { // Presence and notify are disjoint: a notify payload must not parse as presence. - let b64 = Data("x".utf8).base64EncodedString() - #expect(AgentPresenceOSC.parse(id: "claude", metadata: "kind=notify;token=tok;data=\(b64)") == nil) + #expect(AgentPresenceOSC.parse(id: "claude", metadata: Self.notifyMeta(body: "x")) == nil) } // MARK: - notification (trust + sanitize). @Test func notificationTrustsMatchingTokenAndExtractsBody() { - let json = #"{"hook_event_name":"Stop","message":"all done"}"# - let metadata = "kind=notify;token=tok;data=\(Data(json.utf8).base64EncodedString())" let resolved = WorktreeTerminalState.notification( - id: "claude", metadata: metadata, expectedToken: "tok", surfaceExists: true) + id: "claude", metadata: Self.notifyMeta(body: "all done"), expectedToken: "tok", surfaceExists: true) guard case .success(let value) = resolved else { Issue.record("expected success, got \(resolved)") return @@ -248,9 +304,9 @@ struct AgentPresenceOSCTests { } @Test func notificationDropsMismatchedToken() { - let metadata = "kind=notify;token=wrong;data=\(Data(#"{"message":"x"}"#.utf8).base64EncodedString())" let result = WorktreeTerminalState.notification( - id: "claude", metadata: metadata, expectedToken: "right", surfaceExists: true) + id: "claude", metadata: Self.notifyMeta(token: "wrong", body: "x"), + expectedToken: "right", surfaceExists: true) guard case .failure(.tokenMismatch(let agent)) = result else { Issue.record("expected tokenMismatch, got \(result)") return @@ -259,9 +315,8 @@ struct AgentPresenceOSCTests { } @Test func notificationDropsUnknownSurface() { - let metadata = "kind=notify;token=tok;data=\(Data(#"{"message":"x"}"#.utf8).base64EncodedString())" let result = WorktreeTerminalState.notification( - id: "claude", metadata: metadata, expectedToken: nil, surfaceExists: false) + id: "claude", metadata: Self.notifyMeta(body: "x"), expectedToken: nil, surfaceExists: false) if case .failure(.unknownSurface) = result {} else { Issue.record("expected unknownSurface, got \(result)") } } @@ -270,9 +325,8 @@ struct AgentPresenceOSCTests { // benign, not a spoof: the call site routes `.unknownSurface` to `.debug`, // never `.warning`. Asserting the exact failure case locks that mapping in // since `tokenMismatch` / `parseFailed` are the only warn-level branches. - let metadata = "kind=notify;token=tok;data=\(Data(#"{"message":"x"}"#.utf8).base64EncodedString())" let result = WorktreeTerminalState.notification( - id: "claude", metadata: metadata, expectedToken: nil, surfaceExists: false) + id: "claude", metadata: Self.notifyMeta(body: "x"), expectedToken: nil, surfaceExists: false) guard case .failure(let drop) = result else { Issue.record("expected failure, got \(result)") return @@ -286,9 +340,8 @@ struct AgentPresenceOSCTests { } @Test func notificationFallsBackToAgentTitleWhenAbsent() { - let metadata = "kind=notify;token=tok;data=\(Data(#"{"message":"body only"}"#.utf8).base64EncodedString())" let resolved = WorktreeTerminalState.notification( - id: "codex", metadata: metadata, expectedToken: "tok", surfaceExists: true) + id: "codex", metadata: Self.notifyMeta(body: "body only"), expectedToken: "tok", surfaceExists: true) guard case .success(let value) = resolved else { Issue.record("expected success, got \(resolved)") return @@ -296,12 +349,23 @@ struct AgentPresenceOSCTests { #expect(value.title == "codex") } + @Test func notificationShowsTitleOnlyToastWhenBodyAbsent() { + // A turn-complete notify with no body still fires, showing just the title. + let resolved = WorktreeTerminalState.notification( + id: "claude", metadata: Self.notifyMeta(), expectedToken: "tok", surfaceExists: true) + guard case .success(let value) = resolved else { + Issue.record("expected success, got \(resolved)") + return + } + #expect(value.title == "claude") + #expect(value.body.isEmpty) + } + @Test func notificationDropsPayloadThatSanitizesEmpty() { // Body of only control / whitespace and no usable title sanitizes to empty, // so the toast is suppressed rather than shown blank. - let metadata = "kind=notify;token=tok;data=\(Data(#"{"message":"\n"}"#.utf8).base64EncodedString())" let result = WorktreeTerminalState.notification( - id: " ", metadata: metadata, expectedToken: "tok", surfaceExists: true) + id: " ", metadata: Self.notifyMeta(body: "\n"), expectedToken: "tok", surfaceExists: true) if case .failure(.empty) = result {} else { Issue.record("expected empty, got \(result)") } } @@ -323,11 +387,9 @@ struct AgentPresenceOSCTests { // unicode escape (raw 0x1B is illegal in a JSON string); the C0 strip // must drop both the opening ESC and the trailing ST ESC before the // toast sees them. - let json = - #"{"hook_event_name":"Stop","message":"before\u001b]3008;start=evil;event=busy;token=X\u001b\\after"}"# - let metadata = "kind=notify;token=tok;data=\(Data(json.utf8).base64EncodedString())" + let body = "before\u{1B}]3008;start=evil;event=busy;token=X\u{1B}\\after" let resolved = WorktreeTerminalState.notification( - id: "claude", metadata: metadata, expectedToken: "tok", surfaceExists: true) + id: "claude", metadata: Self.notifyMeta(body: body), expectedToken: "tok", surfaceExists: true) guard case .success(let value) = resolved else { Issue.record("expected success, got \(resolved)") return @@ -348,20 +410,14 @@ struct AgentPresenceOSCTests { // MARK: - large payload (metadata cap headroom). @Test func largeNotifyPayloadNearMetadataCapRoundTripsEndToEnd() { - // Locks the design margin against a future Ghostty cap reduction or a Claude - // payload growth that would silently drop notifications: a ~1.5KB Codex - // `last_assistant_message` must survive parseNotify + notification(...) with - // the title / body resolving correctly. Sized so the base64-expanded - // metadata stays just under the 2047-byte OSC cap. + // A near-cap body must survive parseNotify + notification(...) end to end and + // stay under the 2047-byte OSC cap so a real terminal does not truncate. let bodyText = String(repeating: "y", count: 1400) - let json = #"{"hook_event_name":"Stop","title":"Big","last_assistant_message":"\#(bodyText)"}"# - let metadata = AgentPresenceOSC.notifyMetadata( - token: "tok", data: Data(json.utf8).base64EncodedString()) - // Stay under Ghostty's 2047-byte metadata cap so a real terminal would not truncate. + let metadata = Self.notifyMeta(title: "Big", body: bodyText) #expect(metadata.utf8.count < 2047) let signal = AgentPresenceOSC.parseNotify(id: "codex", metadata: metadata) - #expect(signal?.payload == Data(json.utf8)) + #expect(signal?.body == bodyText) let resolved = WorktreeTerminalState.notification( id: "codex", metadata: metadata, expectedToken: "tok", surfaceExists: true) From 23ae4ac6372ae34fd8da3732e09bedfad8e6d367 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 10 Jun 2026 21:58:08 +0200 Subject: [PATCH 13/56] Drop the OSC 3008 capability token (#394) 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. --- .../AgentHookSettingsCommand.swift | 18 +- .../BusinessLogic/AgentPresenceOSC.swift | 134 ++++++-------- .../BusinessLogic/PiExtensionContent.swift | 34 ++-- .../Models/WorktreeTerminalState.swift | 130 +++++-------- supacodeTests/AgentBusyStateTests.swift | 46 ----- supacodeTests/AgentHookCommandTests.swift | 131 +++++++------ supacodeTests/AgentPresenceOSCTests.swift | 173 ++++++------------ supacodeTests/GhosttySurfaceBridgeTests.swift | 6 +- 8 files changed, 251 insertions(+), 421 deletions(-) diff --git a/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsCommand.swift b/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsCommand.swift index aebe88e9f..e49c44289 100644 --- a/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsCommand.swift +++ b/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsCommand.swift @@ -43,11 +43,11 @@ nonisolated enum AgentHookSettingsCommand { + #" && [ -n "${SUPACODE_TAB_ID:-}" ]"# + #" && [ -n "${SUPACODE_SURFACE_ID:-}" ]"# - /// Composes the OSC 3008 hook command: one token guard, then (once that passes) - /// the tty resolve plus a presence emit per event and/or a notify emit, all in a + /// Composes the OSC 3008 hook command: one guard, then (once that passes) the + /// tty resolve plus a presence emit per event and/or a notify emit, all in a /// single brace group whose output is suppressed. Guarding first keeps the - /// command truly inert outside Supacode (no `ps` runs when the token is unset). - /// The precondition rejects a no-op invocation that would emit nothing. + /// command truly inert outside Supacode (no `ps` runs when the surface id is + /// unset). The precondition rejects a no-op invocation that would emit nothing. static func compositeCommand( events: [HookEvent], forwardStdinAsNotification: Bool, @@ -63,12 +63,10 @@ nonisolated enum AgentHookSettingsCommand { return "\(oscGuardExpr) && { \(steps.joined(separator: "; ")); } >/dev/null 2>&1 || true \(ownershipMarker)" } - /// Guard for the OSC command: the per-surface OSC token set (the - /// no-op-outside-Supacode gate) and a surface id present. Fires both locally and - /// over SSH; the pid suffix inside the presence emit is what's gated on the - /// socket path, not the emission itself. + /// Guard for the OSC command: a surface id present (the no-op-outside-Supacode + /// gate). Fires both locally and over SSH; the pid suffix inside the presence + /// emit is what's gated on the socket path, not the emission itself. private static var oscGuardExpr: String { - #"[ -n "${\#(AgentPresenceOSC.tokenEnvVar):-}" ]"# - + #" && [ -n "${SUPACODE_SURFACE_ID:-}" ]"# + #"[ -n "${\#(AgentPresenceOSC.surfaceEnvVar):-}" ]"# } } diff --git a/SupacodeSettingsShared/BusinessLogic/AgentPresenceOSC.swift b/SupacodeSettingsShared/BusinessLogic/AgentPresenceOSC.swift index ee10ac3e6..e22df9ef3 100644 --- a/SupacodeSettingsShared/BusinessLogic/AgentPresenceOSC.swift +++ b/SupacodeSettingsShared/BusinessLogic/AgentPresenceOSC.swift @@ -5,33 +5,35 @@ import Foundation /// state over SSH where the local Unix socket can't be reached. The sequence is /// inert in any terminal that doesn't handle OSC 3008 (no toast, no side effect). /// -/// Emit shape: `OSC 3008 ; = ; event= ; token=[ ; pid=] ST`. +/// Emit shape: `OSC 3008 ; = ; event=[ ; pid=] ST`. /// libghostty splits that into `id = ` (the context id, up to the first -/// `;`) and `metadata = "event=;token=[;pid=]"`, which is what -/// `parse` receives. `parse` derives the event solely from the `event=` field and -/// ignores the start/end action byte. +/// `;`) and `metadata = "event=[;pid=]"`, which is what `parse` +/// receives. `parse` derives the event solely from the `event=` field and ignores +/// the start/end action byte. /// - attribution is by the receiving surface, so no surface id is carried; /// - `event` is the `HookEvent` rawValue; -/// - `token` echoes the per-surface `SUPACODE_OSC_TOKEN` capability nonce, which -/// the app verifies against the receiving surface before trusting the signal; /// - `pid` is the agent's LOCAL process id, present only when the hook ran on the /// same host (gated on `SUPACODE_SOCKET_PATH`); it feeds the app's liveness /// sweep so a crashed local agent is reaped. Omitted over SSH. /// /// The same transport also carries the rich notification leg -/// (`kind=notify;token=;title=;body=`); the emitter -/// extracts the display title/body so the wire stays small and the app carries no -/// agent-specific JSON shape. Presence and notify are disjoint metadata shapes. +/// (`kind=notify;title=;body=`); the emitter extracts the display +/// title/body so the wire stays small and the app carries no agent-specific JSON +/// shape. Presence and notify are disjoint metadata shapes. +/// +/// Signals are unauthenticated: anything that can write to the terminal can emit +/// one, and the worst case is a spurious badge or notification (text is +/// control-char-sanitized and length-capped app-side). Emission is gated on +/// `SUPACODE_SURFACE_ID` so it no-ops outside a Supacode surface. /// /// Single source of truth for both the emit side (the agent hook) and the parse /// side (the app), so the field names can't drift. public nonisolated enum AgentPresenceOSC { - /// Env var carrying the per-surface secret capability nonce. Present only on - /// Supacode surfaces, so its presence doubles as the no-op-outside-Supacode gate. - public static let tokenEnvVar = "SUPACODE_OSC_TOKEN" + /// Env var present only on Supacode surfaces, so its presence is the + /// no-op-outside-Supacode emit gate. + public static let surfaceEnvVar = "SUPACODE_SURFACE_ID" static let eventField = "event" - static let tokenField = "token" static let pidField = "pid" static let kindField = "kind" static let titleField = "title" @@ -47,26 +49,22 @@ public nonisolated enum AgentPresenceOSC { static let notifyBodyByteBudget = 1000 static let notifyTitleByteBudget = 160 - /// A parsed, NOT-yet-trusted presence signal. The caller must verify `token` - /// against the receiving surface's nonce before acting on it. + /// A parsed presence signal. public struct Signal: Equatable, Sendable { /// Context id, i.e. the agent rawValue. public let agent: String /// A known HookEvent rawValue. Parse rejects unknown values; stored as /// String so wire concerns don't leak into the enum. public let eventRawValue: String - public let token: String - /// The agent's process id, trusted via the per-surface token, not verified - /// local: parse/trust can't distinguish a genuinely-local emit from a forged - /// `pid=` on the wire. The emit gates it on `SUPACODE_SOCKET_PATH` so a - /// legitimate local hook carries it and a remote one omits it, but a forged - /// positive pid at worst pins a live-looking badge until surface close. + /// The agent's LOCAL process id. The emit gates it on `SUPACODE_SOCKET_PATH` + /// so a local hook carries it and a remote one omits it; a forged positive + /// pid at worst pins a live-looking badge until surface close. public let pid: pid_t? } /// Parse the OSC 3008 context id + raw key=value metadata (as surfaced by /// libghostty) into a `Signal`. Returns nil for anything that isn't a - /// well-formed presence signal with a known event. Does NOT verify the token. + /// well-formed presence signal with a known event. public static func parse(id: String, metadata: String) -> Signal? { guard !id.isEmpty else { return nil } guard let fields = parseFields(metadata) else { return nil } @@ -74,11 +72,9 @@ public nonisolated enum AgentPresenceOSC { let rawEvent = fields[Substring(eventField)], HookEvent(rawValue: String(rawEvent)) != nil else { return nil } - guard let token = fields[Substring(tokenField)], !token.isEmpty else { return nil } return Signal( agent: id, eventRawValue: String(rawEvent), - token: String(token), pid: parsePid(fields[Substring(pidField)]), ) } @@ -103,17 +99,16 @@ public nonisolated enum AgentPresenceOSC { /// the value keeps everything after the FIRST `=` (`firstIndex(of:)`), so base64 /// `=` padding survives intact. /// - /// Duplicate trust-bearing keys (`token`, `event`, `kind`) are rejected: a - /// repeated key would otherwise pin perceived state to the last occurrence, - /// which an attacker who can splice into the wire could exploit to flip - /// `event=` or to pair a valid `token=` with an injected `kind=notify`. All - /// other duplicate keys keep the historical last-write-wins behavior. + /// Duplicate `event` / `kind` keys are rejected: a repeated key would otherwise + /// pin perceived state to the last occurrence, which a splice into the wire + /// could exploit to flip `event=` or inject `kind=notify`. All other duplicate + /// keys keep the historical last-write-wins behavior. public static func parseFields(_ metadata: String) -> [Substring: Substring]? { var fields: [Substring: Substring] = [:] for pair in metadata.split(separator: ";", omittingEmptySubsequences: true) { guard let equalsIndex = pair.firstIndex(of: "=") else { continue } let key = pair[.. = [ - Substring(tokenField), Substring(eventField), Substring(kindField), + private static let dedupedFields: Set = [ + Substring(eventField), Substring(kindField), ] - /// A parsed, NOT-yet-trusted notification signal with already-decoded display - /// text. The caller must verify `token` against the surface nonce before acting. + /// A parsed notification signal with already-decoded display text. public struct NotifySignal: Equatable, Sendable { public let agent: String - public let token: String /// Both nil-on-empty; the caller falls back to the agent name for a missing /// title and shows a title-only toast for a missing body. public let title: String? public let body: String? + /// Raw base64 byte count of the body field on the wire, before decode. A + /// non-zero count alongside a nil `body` means a truncation the shed loop + /// couldn't recover, so the caller can log the silent-failure case. + public let wireBodyByteCount: Int } - /// Parse `kind=notify;token=;title=;body=`. Requires the - /// notify kind and a non-empty token; title/body are optional. Does NOT verify - /// the token. + /// Parse `kind=notify;title=;body=`. Requires the notify kind; + /// title/body are optional. public static func parseNotify(id: String, metadata: String) -> NotifySignal? { guard !id.isEmpty else { return nil } guard let fields = parseFields(metadata) else { return nil } guard fields[Substring(kindField)] == Substring(notifyKind) else { return nil } - guard let token = fields[Substring(tokenField)], !token.isEmpty else { return nil } return NotifySignal( agent: id, - token: String(token), title: decodedNotifyField(fields[Substring(titleField)]), body: decodedNotifyField(fields[Substring(bodyField)]), + wireBodyByteCount: fields[Substring(bodyField)]?.utf8.count ?? 0, ) } @@ -166,10 +161,10 @@ public nonisolated enum AgentPresenceOSC { static func decodeNotifyValue(_ base64: String) -> String? { guard var data = Data(base64Encoded: base64) else { return nil } let quote = Data([0x22]) - // 12 = longest dangling escape (a `\uXXXX\uXXXX` surrogate pair), so a cut - // mid-pair still sheds back to valid text instead of dropping the whole body. + let decoder = JSONDecoder() + // 12 = full `\uXXXX\uXXXX` surrogate-pair length; covers any mid-pair cut (worst dangling tail is 11 bytes). for _ in 0...min(12, data.count) { - if let text = try? JSONDecoder().decode(String.self, from: quote + data + quote) { + if let text = try? decoder.decode(String.self, from: quote + data + quote) { return text } if data.isEmpty { break } @@ -190,8 +185,8 @@ public nonisolated enum AgentPresenceOSC { /// is appended verbatim (e.g. `;pid=123`) so the emit can splice in a /// shell-built, conditionally-empty suffix. See `notifyMetadata` for the /// notify counterpart. - static func metadata(event: HookEvent, token: String, pidSuffix: String = "") -> String { - "\(eventField)=\(event.rawValue);\(tokenField)=\(token)\(pidSuffix)" + static func metadata(event: HookEvent, pidSuffix: String = "") -> String { + "\(eventField)=\(event.rawValue)\(pidSuffix)" } /// Shell that resolves `$__tty` to a writable terminal device for the OSC emits. @@ -204,30 +199,27 @@ public nonisolated enum AgentPresenceOSC { #"__tty=$(ps -o tty= -p "$PPID" 2>/dev/null | tr -d '[:space:]'); "# + #"case "$__tty" in *[0-9]*) __tty="/dev/${__tty#/dev/}";; *) __tty="/dev/tty";; esac"# - /// Shell `printf` that emits the OSC 3008 presence sequence for `event`, - /// echoing `$SUPACODE_OSC_TOKEN` for the token placeholder. Written to the - /// `$__tty` device resolved by `ttyResolveSnippet` so it reaches the terminal - /// even though the hook has no controlling terminal and captured stdout. The - /// caller guards emission on the token env var and runs `ttyResolveSnippet` - /// first. + /// Shell `printf` that emits the OSC 3008 presence sequence for `event`. Written + /// to the `$__tty` device resolved by `ttyResolveSnippet` so it reaches the + /// terminal even though the hook has no controlling terminal and captured + /// stdout. The caller guards emission on `SUPACODE_SURFACE_ID` and runs + /// `ttyResolveSnippet` first. /// /// The pid suffix is gated on `SUPACODE_SOCKET_PATH` (set only on the local - /// host) so a legitimate local hook carries `$PPID` and a remote one omits it. - /// This is not verified on receipt: the per-surface token is the only real - /// gate, and a forged positive pid at worst pins a live-looking badge until - /// surface close. The suffix is built in shell and filled into a trailing - /// `%s`, empty when remote. + /// host) so a local hook carries `$PPID` and a remote one omits it; a forged + /// positive pid at worst pins a live-looking badge until surface close. The + /// suffix is built in shell and filled into a trailing `%s`, empty when remote. static func emitShell(event: HookEvent, agent: SkillAgent) -> String { - // token=%s then a trailing %s for the shell-built, conditionally-empty pid suffix. - let meta = metadata(event: event, token: "%s", pidSuffix: "%s") + // Trailing %s for the shell-built, conditionally-empty pid suffix. + let meta = metadata(event: event, pidSuffix: "%s") let payload = #"\033]3008;\#(action(for: event))=\#(agent.rawValue);\#(meta)\033\\"# return #"__sp=""; [ -n "${SUPACODE_SOCKET_PATH:-}" ] && __sp=";\#(pidField)=$PPID"; "# - + #"printf '\#(payload)' "$\#(tokenEnvVar)" "$__sp" > "$__tty""# + + #"printf '\#(payload)' "$__sp" > "$__tty""# } /// The `key=value` metadata a notify signal carries; `title` / `body` are base64. - static func notifyMetadata(token: String, title: String, body: String) -> String { - "\(kindField)=\(notifyKind);\(tokenField)=\(token);\(titleField)=\(title);\(bodyField)=\(body)" + static func notifyMetadata(title: String, body: String) -> String { + "\(kindField)=\(notifyKind);\(titleField)=\(title);\(bodyField)=\(body)" } /// Portable awk that extracts one JSON string value from the agent's hook JSON @@ -238,6 +230,10 @@ public nonisolated enum AgentPresenceOSC { /// No `RS`/`\x` tricks and no single quote, so it is portable and shell-safe. /// The caller runs it under `LC_ALL=C` so `length`/`substr` are byte-based /// (gawk in a UTF-8 locale would otherwise count characters and overshoot 2048). + /// Best-effort by design: a body nested inside an object (e.g. the key appears + /// in an inner object before the top-level one) is not extracted correctly. The + /// agents we target emit flat payloads; the structured Pi extension path is the + /// canonical one when a nested shape is needed. static let notifyExtractAwk = #"function ws(c){return c==" "||c=="\t"||c=="\n"||c=="\r"}"# + #"function fv(s,key, p,i,n,c,o,e){p="\""key"\"";i=index(s,p);if(i==0)return "";"# @@ -253,25 +249,13 @@ public nonisolated enum AgentPresenceOSC { /// and emits the OSC 3008 notify. Sending only the display fields keeps the wire /// under libghostty's 2048-byte OSC ceiling. Locked to STANDARD base64. static func emitNotifyShell(agent: SkillAgent) -> String { - let payload = #"\033]3008;start=\#(agent.rawValue);\#(notifyMetadata(token: "%s", title: "%s", body: "%s"))\033\\"# + let payload = #"\033]3008;start=\#(agent.rawValue);\#(notifyMetadata(title: "%s", body: "%s"))\033\\"# let bodyKeys = notifyBodyKeys.joined(separator: ",") return #"__in=$(cat); "# + #"__t=$(printf '%s' "$__in" | LC_ALL=C awk -v keys="\#(titleField)" "# + #"-v budget=\#(notifyTitleByteBudget) '\#(notifyExtractAwk)' | base64 | tr -d '\n'); "# + #"__b=$(printf '%s' "$__in" | LC_ALL=C awk -v keys="\#(bodyKeys)" "# + #"-v budget=\#(notifyBodyByteBudget) '\#(notifyExtractAwk)' | base64 | tr -d '\n'); "# - + #"printf '\#(payload)' "$\#(tokenEnvVar)" "$__t" "$__b" > "$__tty""# - } - - /// Equal-length constant-time compare. A length mismatch returns immediately; - /// safe because the expected token is server-generated and fixed-length - /// (32 hex chars), not attacker-controlled. - public static func tokensMatch(_ lhs: String, _ rhs: String) -> Bool { - let lhsBytes = Array(lhs.utf8) - let rhsBytes = Array(rhs.utf8) - guard lhsBytes.count == rhsBytes.count else { return false } - var diff: UInt8 = 0 - for index in lhsBytes.indices { diff |= lhsBytes[index] ^ rhsBytes[index] } - return diff == 0 + + #"printf '\#(payload)' "$__t" "$__b" > "$__tty""# } } diff --git a/SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift b/SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift index e15a01324..b68ddf578 100644 --- a/SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift +++ b/SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift @@ -19,8 +19,8 @@ nonisolated enum PiExtensionContent { * local socket needed), matching the Claude / Codex / Kiro hook integrations. * * Required env var (injected automatically by Supacode on every surface): - * SUPACODE_OSC_TOKEN per-surface capability nonce; gates emission and is - * verified app-side. Absent = not a Supacode surface. + * SUPACODE_SURFACE_ID present only on a Supacode surface; absence is the + * no-op gate. Signals are unauthenticated. * Optional: * SUPACODE_SOCKET_PATH present only on the local host; gates the local pid * so the app's liveness sweep can reap a crashed agent. @@ -45,9 +45,9 @@ nonisolated enum PiExtensionContent { let lastWarnedAt = 0; const WARN_INTERVAL_MS = 60_000; - function readToken(): string | null { - const token = process.env["SUPACODE_OSC_TOKEN"]; - return token && token.length > 0 ? token : null; + function isSupacodeSurface(): boolean { + const id = process.env["SUPACODE_SURFACE_ID"]; + return !!id && id.length > 0; } /** @@ -106,9 +106,9 @@ nonisolated enum PiExtensionContent { } } - function emitPresence(token: string, event: string): void { + function emitPresence(event: string): void { const action = event === "session_end" ? "end" : "start"; - const meta = `event=${event};token=${token}${localPidSuffix()}`; + const meta = `event=${event}${localPidSuffix()}`; writeToTerminal(`\\x1b]3008;${action}=${AGENT};${meta}\\x1b\\\\`); } @@ -122,9 +122,9 @@ nonisolated enum PiExtensionContent { return capped.toString("base64"); } - function emitNotification(token: string, content: NotifyContent): void { + function emitNotification(content: NotifyContent): void { const meta = - `kind=notify;token=${token}` + + `kind=notify` + `;title=${notifyField(content.title ?? "", \(AgentPresenceOSC.notifyTitleByteBudget))}` + `;body=${notifyField(content.body ?? "", \(AgentPresenceOSC.notifyBodyByteBudget))}`; writeToTerminal(`\\x1b]3008;start=${AGENT};${meta}\\x1b\\\\`); @@ -152,29 +152,27 @@ nonisolated enum PiExtensionContent { } export default function (pi: ExtensionAPI) { - const token = readToken(); - // Not running under Supacode, or not a Supacode surface: stay inert. - if (!token) return; + if (!isSupacodeSurface()) return; // Extension load = agent process running. Pi has no equivalent of // Claude's SessionStart hook, so we fire it ourselves. - emitPresence(token, "session_start"); + emitPresence("session_start"); pi.on("agent_start", (_event, _ctx) => { - emitPresence(token, "busy"); + emitPresence("busy"); }); pi.on("agent_end", (_event, ctx) => { // Atomic state-set: `idle` overwrites whatever was running on the // Supacode side (turn-level Stop equivalent). - emitPresence(token, "idle"); - emitNotification(token, { body: lastAssistantText(ctx) }); + emitPresence("idle"); + emitNotification({ body: lastAssistantText(ctx) }); }); pi.on("session_shutdown", (_event, _ctx) => { - emitPresence(token, "session_end"); - emitPresence(token, "idle"); + emitPresence("session_end"); + emitPresence("idle"); }); } """ diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 14a867108..360b048ed 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -5,14 +5,9 @@ import Foundation import GhosttyKit import IdentifiedCollections import Observation -import Security import Sharing import SupacodeSettingsShared -#if !DEBUG - import Sentry -#endif - private let blockingScriptLogger = SupaLogger("BlockingScript") private let layoutLogger = SupaLogger("Layout") private let terminalStateLogger = SupaLogger("Terminal") @@ -93,9 +88,6 @@ final class WorktreeTerminalState { /// doesn't invalidate every leaf; the per-instance `hasUnseenNotification` is /// the observed signal. @ObservationIgnored private(set) var surfaceStates: [UUID: WorktreeSurfaceState] = [:] - /// Per-surface secret nonce echoed in OSC 3008 presence signals, compared - /// against the incoming token before the signal is trusted. - @ObservationIgnored private var oscTokensBySurfaceID: [UUID: String] = [:] var notificationsEnabled = true @ObservationIgnored @Dependency(\.date.now) private var now @ObservationIgnored @Dependency(\.zmxClient) private var zmxClient @@ -134,7 +126,6 @@ final class WorktreeTerminalState { #if DEBUG var debugCustomNotificationTimestampCount: Int { lastCustomNotificationAt.count } var debugPendingOSCCount: Int { pendingAgentOSCNotifications.count } - func debugOSCToken(forSurfaceID surfaceID: UUID) -> String? { oscTokensBySurfaceID[surfaceID] } #endif var hasUnseenNotification: Bool { notifications.contains { !$0.isRead } @@ -1348,11 +1339,6 @@ final class WorktreeTerminalState { let currentPath = ProcessInfo.processInfo.environment["PATH"] ?? "" env["PATH"] = currentPath.isEmpty ? cliBinDir : "\(cliBinDir):\(currentPath)" } - // Per-surface OSC presence capability nonce. Present only on Supacode - // surfaces, so the hook treats its absence as the no-op-outside-Supacode gate. - let oscToken = Self.makeOSCToken() - oscTokensBySurfaceID[surfaceID] = oscToken - env[AgentPresenceOSC.tokenEnvVar] = oscToken return env } @@ -1508,133 +1494,118 @@ final class WorktreeTerminalState { switch Self.presenceEvent( id: id, metadata: metadata, - expectedToken: oscTokensBySurfaceID[surfaceID], surfaceID: surfaceID, surfaceExists: surfaces[surfaceID] != nil ) { case .success(let event): onAgentHookEvent?(event) - case .failure(.tokenMismatch(let agent, let event)): - // A wrong token is a potential spoof over the wider SSH capability; warn. - terminalStateLogger.warning( - "Rejected OSC presence with mismatched token for surface \(surfaceID), agent=\(agent), event=\(event).") case .failure(.parseFailed): // Malformed metadata on a live surface is probe-shaped; warn (mirrors notify). terminalStateLogger.warning("Dropped malformed OSC presence signal for surface \(surfaceID).") - case .failure(.unknownSurface), .failure(.noToken): + case .failure(.unknownSurface): terminalStateLogger.debug("Dropped OSC presence signal for surface \(surfaceID).") } } /// Typed reasons a presence signal was dropped, so the single call site can pick a - /// log severity per cause (warn for spoof-shaped failures, debug otherwise). + /// log severity per cause (warn for malformed, debug otherwise). enum PresenceDrop: Error, Equatable { case unknownSurface - case noToken case parseFailed - case tokenMismatch(agent: String, event: String) } - /// Pure trust decision for an OSC presence signal: returns an `AgentHookEvent` - /// attributed to the RECEIVING surface only when the surface is known and the - /// echoed token matches its nonce; otherwise a typed `PresenceDrop` so the caller - /// can log per cause. The wire never carries a surface id (so a payload can't - /// spoof another worktree). The pid is trusted via the per-surface token, not - /// verified local: a forged positive pid at worst pins a live-looking badge until - /// surface close. The parser still rejects a non-positive pid before it could - /// reach the sweep. + /// Pure decision for an OSC presence signal: returns an `AgentHookEvent` + /// attributed to the RECEIVING surface when the surface is known and the metadata + /// is well-formed; otherwise a typed `PresenceDrop` so the caller can log per + /// cause. The wire never carries a surface id (so a payload can't spoof another + /// worktree). The parser rejects a non-positive pid before it could reach the + /// liveness sweep; a forged positive pid at worst pins a live-looking badge. nonisolated static func presenceEvent( id: String, metadata: String, - expectedToken: String?, surfaceID: UUID, surfaceExists: Bool ) -> Result { guard surfaceExists else { return .failure(.unknownSurface) } - guard let expectedToken else { return .failure(.noToken) } guard let signal = AgentPresenceOSC.parse(id: id, metadata: metadata) else { return .failure(.parseFailed) } - guard AgentPresenceOSC.tokensMatch(signal.token, expectedToken) else { - return .failure(.tokenMismatch(agent: signal.agent, event: signal.eventRawValue)) - } return .success( AgentHookEvent( agent: signal.agent, event: signal.eventRawValue, surfaceID: surfaceID, pid: signal.pid)) } - /// Verify an OSC 3008 notify signal against the receiving surface's nonce, then - /// sanitize and display it. Gated by the rich-notifications setting. + /// Parse an OSC 3008 notify signal for the receiving surface, then sanitize and + /// display it. Gated by the rich-notifications setting. private func handleNotifySignal(surfaceID: UUID, id: String, metadata: String) { switch Self.notification( id: id, metadata: metadata, - expectedToken: oscTokensBySurfaceID[surfaceID], surfaceExists: surfaces[surfaceID] != nil ) { case .success(let resolved): - // Gate AFTER trust check so the setting can't be probed via drop-rate signals. + // Gate AFTER parse so the setting can't be probed via drop-rate signals. @Shared(.settingsFile) var settingsFile guard settingsFile.global.richAgentNotificationsEnabled else { - terminalStateLogger.debug("Dropped trusted OSC notify; rich notifications disabled.") + terminalStateLogger.debug("Dropped OSC notify; rich notifications disabled.") return } - // A body present on the wire but decoded empty means a ghostty truncation or - // an escape-cut the shed loop couldn't recover: keep it out of silent-failure - // territory by logging, even though we still show the title-only toast. - if resolved.body.isEmpty, let wireBody = AgentPresenceOSC.parseFields(metadata)?[Substring("body")], - !wireBody.isEmpty - { - terminalStateLogger.debug( - "OSC notify body present on wire (\(wireBody.utf8.count) b64 bytes) but decoded empty: surface \(surfaceID)." + // A body present on the wire but decoded empty means a truncation, an + // escape-cut the shed loop couldn't recover, or a non-base64 (probe / forged) + // field: keep it out of silent-failure territory by logging, even though we + // still show the title-only toast. + if resolved.body.isEmpty, resolved.wireBodyByteCount > 0 { + let wireBytes = resolved.wireBodyByteCount + terminalStateLogger.warning( + "OSC notify body present on wire (\(wireBytes) b64 bytes) but decoded empty, dropped: surface \(surfaceID)." ) } appendHookNotification(title: resolved.title, body: resolved.body, surfaceID: surfaceID) - case .failure(.tokenMismatch(let agent)): - // A wrong token is a potential spoof over the wider SSH capability; warn. - terminalStateLogger.warning( - "Rejected OSC notify with mismatched token for surface \(surfaceID), agent=\(agent).") case .failure(.parseFailed): - // parseNotify only fails now on missing token / empty id (not a truncated body, + // parseNotify only fails on a non-notify / empty id (not a truncated body, // which decodes to an empty field, logged in the success arm above). terminalStateLogger.warning( "Dropped malformed OSC notify (metadata bytes: \(metadata.utf8.count)) for surface \(surfaceID).") - case .failure(.unknownSurface), .failure(.missingToken), .failure(.empty): + case .failure(.unknownSurface), .failure(.empty): terminalStateLogger.debug("Dropped OSC notify signal for surface \(surfaceID).") } } /// Typed reasons a notify signal was dropped, so the single call site can pick a - /// log severity per cause (warn for spoof-shaped failures, debug otherwise). + /// log severity per cause (warn for malformed, debug otherwise). enum NotifyDrop: Error { case unknownSurface - case missingToken - case tokenMismatch(agent: String) case parseFailed case empty } - /// Pure trust + parse decision for an OSC notify signal. Title/body are bounded - /// and stripped of control characters since the OSC leg is a wider capability - /// than a local socket. Title falls back to the agent name; body may be empty. + /// A parsed + sanitized notify ready for display, plus the raw wire body byte + /// count so the call site can log a truncated-to-empty body. + struct ResolvedNotification: Equatable { + let title: String + let body: String + let wireBodyByteCount: Int + } + + /// Pure parse decision for an OSC notify signal. Title/body are bounded and + /// stripped of control characters since anything on the terminal can emit one. + /// Title falls back to the agent name; body may be empty. nonisolated static func notification( id: String, metadata: String, - expectedToken: String?, surfaceExists: Bool - ) -> Result<(title: String, body: String), NotifyDrop> { + ) -> Result { guard surfaceExists else { return .failure(.unknownSurface) } - guard let expectedToken else { return .failure(.missingToken) } guard let notify = AgentPresenceOSC.parseNotify(id: id, metadata: metadata) else { return .failure(.parseFailed) } - guard AgentPresenceOSC.tokensMatch(notify.token, expectedToken) else { - return .failure(.tokenMismatch(agent: notify.agent)) - } + // Second-line defense behind the emit-side caps (notifyTitleByteBudget / + // notifyBodyByteBudget): these are scalar counts, not bytes, and the wire is + // already bounded, so they only bite on a hand-crafted oversized payload. let title = sanitizeNotificationText(notify.title ?? notify.agent, max: 200) let body = sanitizeNotificationText(notify.body ?? "", max: 1000) guard !(title.isEmpty && body.isEmpty) else { return .failure(.empty) } - return .success((title, body)) + return .success(ResolvedNotification(title: title, body: body, wireBodyByteCount: notify.wireBodyByteCount)) } /// Bound length and neutralize control characters in attacker-influenceable @@ -1657,23 +1628,6 @@ final class WorktreeTerminalState { return String(scalars).trimmingCharacters(in: .whitespaces) } - /// 128-bit (32 hex char) nonce. Hex avoids the `;` / `=` / control bytes that - /// would corrupt the OSC 3008 metadata framing. - static func makeOSCToken() -> String { - var bytes = [UInt8](repeating: 0, count: 16) - if SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess { - return bytes.map { String(format: "%02x", $0) }.joined() - } - // RNG failure downgrades a security capability; surface to Sentry and fall back - // to arc4random_buf (always available, no failure path) rather than UUID entropy. - terminalStateLogger.error("SecRandomCopyBytes failed; OSC token falling back to arc4random_buf.") - #if !DEBUG - SentrySDK.logger.error("SecRandomCopyBytes failed; OSC token degraded to arc4random_buf.") - #endif - arc4random_buf(&bytes, bytes.count) - return bytes.map { String(format: "%02x", $0) }.joined() - } - struct ResolvedLaunch { var command: String? var initialInput: String? @@ -1874,15 +1828,13 @@ final class WorktreeTerminalState { /// killed here; callers route the kill through `killZmxSessions(forSurfaceIDs:)` /// so a single multi-pane close emits one `count=N` analytics event + one /// `withTaskGroup` instead of N events and N detached Tasks. - /// Also cancels any held agent OSC 9 and forgets the per-surface OSC token - /// and last-custom-notification instant so a future surface ID can't be - /// authenticated with stale state. + /// Also cancels any held agent OSC 9 and forgets the last-custom-notification + /// instant so a future surface ID can't reuse stale dedupe state. private func cleanupSurfaceState(for surfaceID: UUID) { pendingAgentOSCNotifications.removeValue(forKey: surfaceID)?.cancel() lastCustomNotificationAt.removeValue(forKey: surfaceID) surfaces.removeValue(forKey: surfaceID) surfaceStates.removeValue(forKey: surfaceID) - oscTokensBySurfaceID.removeValue(forKey: surfaceID) onSurfacesClosed?([surfaceID]) } diff --git a/supacodeTests/AgentBusyStateTests.swift b/supacodeTests/AgentBusyStateTests.swift index a620b8968..bbb0f9ff5 100644 --- a/supacodeTests/AgentBusyStateTests.swift +++ b/supacodeTests/AgentBusyStateTests.swift @@ -32,52 +32,6 @@ struct AgentBusyStateTests { #expect(!fixture.isBusy) } - @Test func presenceWithMismatchedTokenIsDroppedFromLiveBridge() { - let fixture = makeStateWithSurface() - var forwardedEvents = 0 - fixture.state.onAgentHookEvent = { _ in forwardedEvents += 1 } - - // Drive the live `onContextSignal` callpath with a presence payload whose - // token doesn't match the surface's nonce: the manager-side hook must never see it. - fixture.surface.bridge.onContextSignal?(0, "claude", "event=busy;token=WRONG") - - #expect(forwardedEvents == 0) - #expect(!fixture.isBusy) - } - - @Test func presenceWithOtherSurfaceTokenIsDroppedFromLiveBridge() { - // Two live surfaces share the worktree state. Surface A's real token must not - // validate when echoed on surface B's bridge: tokens are per-surface, so a - // payload that authenticates for A is a spoof for B. - let (manager, _) = WorktreeTerminalManager.withPresenceHarness() - let worktree = makeWorktree() - let state = manager.state(for: worktree) { false } - let tabAId = state.createTab()! - let tabBId = state.createTab()! - let surfaceA = state.splitTree(for: tabAId).root!.leftmostLeaf() - let surfaceB = state.splitTree(for: tabBId).root!.leftmostLeaf() - guard let tokenA = state.debugOSCToken(forSurfaceID: surfaceA.id) else { - Issue.record("Expected an OSC token for surface A") - return - } - - var forwardedForA = 0 - var forwardedForB = 0 - let previous = state.onAgentHookEvent - state.onAgentHookEvent = { event in - if event.surfaceID == surfaceA.id { forwardedForA += 1 } - if event.surfaceID == surfaceB.id { forwardedForB += 1 } - previous?(event) - } - - // Deliver a presence signal carrying A's token on B's bridge: the receiving - // surface is B, so the expected token is B's nonce and A's token must mismatch. - surfaceB.bridge.onContextSignal?(0, "claude", "event=busy;token=\(tokenA)") - - #expect(forwardedForA == 0) - #expect(forwardedForB == 0) - } - @Test func activityEventForUnknownSurfaceIsNoOp() { let fixture = makeStateWithSurface() diff --git a/supacodeTests/AgentHookCommandTests.swift b/supacodeTests/AgentHookCommandTests.swift index 7cd8f5b93..e80bd8926 100644 --- a/supacodeTests/AgentHookCommandTests.swift +++ b/supacodeTests/AgentHookCommandTests.swift @@ -60,14 +60,15 @@ struct AgentHookCommandTests { } } - @Test func compositeGuardsOnTokenAndSurfaceOnly() { - // OSC is the only transport now: the guard is the per-surface capability - // token plus the surface id. The worktree / tab ids the socket envelope - // carried are no longer referenced anywhere in the command. + @Test func compositeGuardsOnSurfaceOnly() { + // OSC is the only transport now, and signals are unauthenticated: the guard + // is just the surface id (the no-op-outside-Supacode gate). The token and the + // worktree / tab ids the socket envelope carried are gone. let command = AgentHookSettingsCommand.compositeCommand( events: [.busy], forwardStdinAsNotification: false, agent: .claude) - #expect(command.contains("SUPACODE_OSC_TOKEN")) #expect(command.contains("SUPACODE_SURFACE_ID")) + #expect(!command.contains("SUPACODE_OSC_TOKEN")) + #expect(!command.contains("token=")) #expect(!command.contains("SUPACODE_WORKTREE_ID")) #expect(!command.contains("SUPACODE_TAB_ID")) } @@ -87,8 +88,8 @@ struct AgentHookCommandTests { @Test func notifyDoesNotReferenceWorktreeOrTabIDs() { // The notify leg used to prefix a `worktree tab surface agent` header for - // the socket text proto. The OSC notify carries only the per-surface token - // and base64 title/body, so those ids must be gone. + // the socket text proto. The OSC notify carries only base64 title/body, so + // those ids must be gone; only the surface-id gate remains. let command = AgentHookSettingsCommand.compositeCommand( events: [], forwardStdinAsNotification: true, agent: .codex) #expect(!command.contains("SUPACODE_WORKTREE_ID")) @@ -291,26 +292,25 @@ struct AgentHookCommandTests { events: [.busy], forwardStdinAsNotification: false, agent: .claude ) let expected = - #"[ -n "${SUPACODE_OSC_TOKEN:-}" ] && [ -n "${SUPACODE_SURFACE_ID:-}" ] && { "# + #"[ -n "${SUPACODE_SURFACE_ID:-}" ] && { "# + #"__tty=$(ps -o tty= -p "$PPID" 2>/dev/null | tr -d '[:space:]'); "# + #"case "$__tty" in *[0-9]*) __tty="/dev/${__tty#/dev/}";; *) __tty="/dev/tty";; esac; "# + #"__sp=""; [ -n "${SUPACODE_SOCKET_PATH:-}" ] && __sp=";pid=$PPID"; "# - + #"printf '\033]3008;start=claude;event=busy;token=%s%s\033\\' "# - + #""$SUPACODE_OSC_TOKEN" "$__sp" > "$__tty"; "# + + #"printf '\033]3008;start=claude;event=busy%s\033\\' "$__sp" > "$__tty"; "# + #"} >/dev/null 2>&1 || true # supacode-managed-hook"# #expect(composite == expected) } // MARK: - OSC presence emission. - @Test func compositeEmitsOSCPresenceGuardedByToken() { + @Test func compositeEmitsOSCPresenceGuardedBySurface() { let command = AgentHookSettingsCommand.compositeCommand( events: [.busy], forwardStdinAsNotification: false, agent: .claude) - // OSC is the sole transport, gated only by the per-surface token (no-op - // outside Supacode) and the surface id. It fires local and remote alike. - #expect(command.contains("]3008;start=claude;event=busy;")) - #expect(command.contains(#"[ -n "${SUPACODE_OSC_TOKEN:-}" ]"#)) - #expect(command.contains("token=%s")) + // OSC is the sole transport, gated only by the surface id (no-op outside + // Supacode). It fires local and remote alike, and carries no token. + #expect(command.contains("]3008;start=claude;event=busy")) + #expect(command.contains(#"[ -n "${SUPACODE_SURFACE_ID:-}" ]"#)) + #expect(!command.contains("token=")) #expect(command.contains(#"> "$__tty""#)) #expect(command.contains("ps -o tty=")) #expect(!command.contains(#"[ -z "${SUPACODE_SOCKET_PATH:-}" ]"#)) @@ -320,21 +320,21 @@ struct AgentHookCommandTests { for agent in [SkillAgent.claude, .codex] { let command = AgentHookSettingsCommand.compositeCommand( events: [.sessionStart], forwardStdinAsNotification: false, agent: agent) - #expect(command.contains("]3008;start=\(agent.rawValue);event=session_start;")) + #expect(command.contains("]3008;start=\(agent.rawValue);event=session_start")) } } @Test func sessionEndUsesOSCEndAction() { let command = AgentHookSettingsCommand.compositeCommand( events: [.sessionEnd, .idle], forwardStdinAsNotification: false, agent: .claude) - #expect(command.contains("]3008;end=claude;event=session_end;")) + #expect(command.contains("]3008;end=claude;event=session_end")) } @Test func awaitingInputComposesOSCPresence() { // awaiting_input is the badge-critical "needs you" state; assert it rides OSC too. let command = AgentHookSettingsCommand.compositeCommand( events: [.awaitingInput], forwardStdinAsNotification: false, agent: .claude) - #expect(command.contains("]3008;start=claude;event=awaiting_input;")) + #expect(command.contains("]3008;start=claude;event=awaiting_input")) } @Test func notifyOnlyComposesNotifyOSCButNoPresenceOSC() { @@ -348,7 +348,7 @@ struct AgentHookCommandTests { @Test func notifyComposesOSCNotify() { let command = AgentHookSettingsCommand.compositeCommand( events: [.idle], forwardStdinAsNotification: true, agent: .claude) - #expect(command.contains("]3008;start=claude;kind=notify;token=%s;title=%s;body=%s")) + #expect(command.contains("]3008;start=claude;kind=notify;title=%s;body=%s")) #expect(command.contains("base64 | tr -d")) } @@ -364,10 +364,7 @@ struct AgentHookCommandTests { // The pid suffix is the local/remote discriminator: present when // SUPACODE_SOCKET_PATH is set (local host), absent over SSH. A regression // that always or never emitted it would silently break the liveness sweep. - let base: [String: String] = [ - "SUPACODE_OSC_TOKEN": "testtoken", - "SUPACODE_SURFACE_ID": UUID().uuidString, - ] + let base: [String: String] = ["SUPACODE_SURFACE_ID": UUID().uuidString] let command = AgentHookSettingsCommand.compositeCommand( events: [.busy], forwardStdinAsNotification: false, agent: .claude) @@ -376,14 +373,12 @@ struct AgentHookCommandTests { command, env: base.merging(["SUPACODE_SOCKET_PATH": "/tmp/sock-\(UUID().uuidString)"]) { $1 }) let localSignal = try #require(Self.parsePresence(fromTTY: local)) #expect(localSignal.eventRawValue == "busy") - #expect(localSignal.token == "testtoken") #expect((localSignal.pid ?? 0) > 0) // Remote (socket absent): the presence OSC lands but carries no pid. let remote = try runHookCommandCapturingTTY(command, env: base) let remoteSignal = try #require(Self.parsePresence(fromTTY: remote)) #expect(remoteSignal.eventRawValue == "busy") - #expect(remoteSignal.token == "testtoken") #expect(remoteSignal.pid == nil) } @@ -391,15 +386,11 @@ struct AgentHookCommandTests { // End-to-end: the real shell hook runs the awk extractor over Claude's stdin // JSON and the resulting OSC parses back with the body intact. let json = #"{"hook_event_name":"Stop","message":"hi there"}"# - let base: [String: String] = [ - "SUPACODE_OSC_TOKEN": "tok", - "SUPACODE_SURFACE_ID": UUID().uuidString, - ] + let base: [String: String] = ["SUPACODE_SURFACE_ID": UUID().uuidString] let command = AgentHookSettingsCommand.compositeCommand( events: [], forwardStdinAsNotification: true, agent: .claude) let tty = try runHookCommandCapturingTTY(command, env: base, stdin: json) let signal = try #require(Self.parseNotify(fromTTY: tty)) - #expect(signal.token == "tok") #expect(signal.body == "hi there") } @@ -409,14 +400,11 @@ struct AgentHookCommandTests { // the precedence list (here `last_assistant_message`, with `message` empty). let json = #"{"hook_event_name":"Stop","title":"Done","message":"","last_assistant_message":"line \"one\"\nDONE ✓"}"# - let base: [String: String] = [ - "SUPACODE_OSC_TOKEN": "tok", - "SUPACODE_SURFACE_ID": UUID().uuidString, - ] + let base: [String: String] = ["SUPACODE_SURFACE_ID": UUID().uuidString] let command = AgentHookSettingsCommand.compositeCommand( events: [.idle], forwardStdinAsNotification: true, agent: .claude) let tty = try runHookCommandCapturingTTY(command, env: base, stdin: json) - #expect(tty.contains("]3008;start=claude;event=idle;")) + #expect(tty.contains("]3008;start=claude;event=idle")) let signal = try #require(Self.parseNotify(fromTTY: tty)) #expect(signal.title == "Done") #expect(signal.body == "line \"one\"\nDONE ✓") @@ -433,10 +421,7 @@ struct AgentHookCommandTests { (#"{"assistant_response":"kiro body"}"#, "kiro body"), ]) func notifyAwkResolvesBodyByPrecedence(json: String, expectedBody: String) throws { - let base: [String: String] = [ - "SUPACODE_OSC_TOKEN": "tok", - "SUPACODE_SURFACE_ID": UUID().uuidString, - ] + let base: [String: String] = ["SUPACODE_SURFACE_ID": UUID().uuidString] let command = AgentHookSettingsCommand.compositeCommand( events: [], forwardStdinAsNotification: true, agent: .claude) let tty = try runHookCommandCapturingTTY(command, env: base, stdin: json) @@ -451,10 +436,7 @@ struct AgentHookCommandTests { // `length(v)>budget` branch and the LC_ALL=C byte cap end to end. let bodyText = String(repeating: "a", count: 4000) let json = #"{"hook_event_name":"Stop","message":"\#(bodyText)"}"# - let base: [String: String] = [ - "SUPACODE_OSC_TOKEN": "tok", - "SUPACODE_SURFACE_ID": UUID().uuidString, - ] + let base: [String: String] = ["SUPACODE_SURFACE_ID": UUID().uuidString] let command = AgentHookSettingsCommand.compositeCommand( events: [], forwardStdinAsNotification: true, agent: .claude) let tty = try runHookCommandCapturingTTY(command, env: base, stdin: json) @@ -468,13 +450,47 @@ struct AgentHookCommandTests { #expect(signal.body?.allSatisfy { $0 == "a" } == true) } + @Test func notifyMultibyteBodyCapDecodesToCleanPrefix() throws { + // A multibyte (non-ASCII) body driven past the byte budget is the realistic + // silent-drop risk for non-English agent output: LC_ALL=C makes the awk cap + // byte-based, so it can sever a 3-byte codepoint mid-sequence. The shed loop + // in decodeNotifyValue must recover a clean prefix, not corrupt to U+FFFD or + // drop the whole body. "日" is 3 bytes, so 2000 of them blow past the 1000-byte + // budget and the cap lands mid-codepoint. + let bodyText = String(repeating: "日", count: 2000) + let json = #"{"hook_event_name":"Stop","message":"\#(bodyText)"}"# + let base: [String: String] = ["SUPACODE_SURFACE_ID": UUID().uuidString] + let command = AgentHookSettingsCommand.compositeCommand( + events: [], forwardStdinAsNotification: true, agent: .claude) + let tty = try runHookCommandCapturingTTY(command, env: base, stdin: json) + let signal = try #require(Self.parseNotify(fromTTY: tty)) + #expect(signal.body?.isEmpty == false) + // Every surviving character is a whole "日": no partial codepoint, no U+FFFD. + #expect(signal.body?.allSatisfy { $0 == "日" } == true) + } + + @Test func notifyAwkIgnoresKeyTextEscapedInsideAnEarlierValue() throws { + // The awk matches the first `"message":` occurrence. An earlier value that + // mentions the key can only do so with escaped quotes (valid JSON), so the + // `"message"` token (quote-key-quote) never matches inside it: the real + // top-level field still wins. Pins that JSON escaping protects flat extraction. + let json = #"{"title":"see \"message\": here","message":"real body"}"# + let base: [String: String] = ["SUPACODE_SURFACE_ID": UUID().uuidString] + let command = AgentHookSettingsCommand.compositeCommand( + events: [], forwardStdinAsNotification: true, agent: .claude) + let tty = try runHookCommandCapturingTTY(command, env: base, stdin: json) + let signal = try #require(Self.parseNotify(fromTTY: tty)) + #expect(signal.title == #"see "message": here"#) + #expect(signal.body == "real body") + } + @Test func emitsNothingOutsideSupacode() throws { - // No OSC token = not a Supacode surface: the guard short-circuits and the - // command writes nothing to the tty (the inert-outside-Supacode contract). + // No SUPACODE_SURFACE_ID = not a Supacode surface: the guard short-circuits + // and the command writes nothing to the tty (the inert-outside-Supacode + // contract). let command = AgentHookSettingsCommand.compositeCommand( events: [.busy], forwardStdinAsNotification: true, agent: .claude) - let tty = try runHookCommandCapturingTTY( - command, env: ["SUPACODE_SURFACE_ID": UUID().uuidString], stdin: "{}") + let tty = try runHookCommandCapturingTTY(command, env: [:], stdin: "{}") #expect(tty.isEmpty) } @@ -488,7 +504,6 @@ struct AgentHookCommandTests { AgentHookSettingsCommand.compositeCommand( events: [.sessionStart], forwardStdinAsNotification: false, agent: .claude), env: [ - "SUPACODE_OSC_TOKEN": "rttoken", "SUPACODE_SURFACE_ID": surfaceID.uuidString, "SUPACODE_SOCKET_PATH": "/tmp/supacode-rt-\(UUID().uuidString)", ] @@ -496,7 +511,6 @@ struct AgentHookCommandTests { let signal = try #require(Self.parsePresence(fromTTY: captured)) #expect(signal.agent == "claude") #expect(signal.eventRawValue == "session_start") - #expect(signal.token == "rttoken") // PPID inside the shell is whatever spawned it (Process), not the test's // pid, so just check it decoded as positive. #expect((signal.pid ?? 0) > 0) @@ -504,7 +518,7 @@ struct AgentHookCommandTests { /// Reconstructs libghostty's OSC 3008 split from a captured tty stream: the /// first `verb=id` field becomes the context id, the rest is the metadata - /// `parse` consumes. Returns the parsed, not-yet-trusted signal. + /// `parse` consumes. Returns the parsed signal. private static func parsePresence(fromTTY tty: String) -> AgentPresenceOSC.Signal? { guard let marker = tty.range(of: "]3008;") else { return nil } let afterMarker = tty[marker.upperBound...] @@ -537,18 +551,17 @@ struct AgentHookCommandTests { return AgentPresenceOSC.parseNotify(id: id, metadata: metadata) } - // Shared head: token guard, then (inside one brace group) resolve $__tty from - // the parent agent's controlling terminal since the hook has none of its own. + // Shared head: surface-id guard, then (inside one brace group) resolve $__tty + // from the parent agent's controlling terminal since the hook has none of its own. private static let guardAndTTY = - #"[ -n "${SUPACODE_OSC_TOKEN:-}" ] && [ -n "${SUPACODE_SURFACE_ID:-}" ] && { "# + #"[ -n "${SUPACODE_SURFACE_ID:-}" ] && { "# + #"__tty=$(ps -o tty= -p "$PPID" 2>/dev/null | tr -d '[:space:]'); "# + #"case "$__tty" in *[0-9]*) __tty="/dev/${__tty#/dev/}";; *) __tty="/dev/tty";; esac; "# private static let suppressTail = #"} >/dev/null 2>&1 || true # supacode-managed-hook"# private static func presence(_ action: String, _ agent: String, _ event: String) -> String { #"__sp=""; [ -n "${SUPACODE_SOCKET_PATH:-}" ] && __sp=";pid=$PPID"; "# - + #"printf '\033]3008;\#(action)=\#(agent);event=\#(event);token=%s%s\033\\' "# - + #""$SUPACODE_OSC_TOKEN" "$__sp" > "$__tty"; "# + + #"printf '\033]3008;\#(action)=\#(agent);event=\#(event)%s\033\\' "$__sp" > "$__tty"; "# } private static func notify(_ agent: String) -> String { @@ -559,8 +572,7 @@ struct AgentHookCommandTests { + #"-v budget=\#(AgentPresenceOSC.notifyTitleByteBudget) '\#(awk)' | base64 | tr -d '\n'); "# + #"__b=$(printf '%s' "$__in" | LC_ALL=C awk -v keys="\#(bodyKeys)" "# + #"-v budget=\#(AgentPresenceOSC.notifyBodyByteBudget) '\#(awk)' | base64 | tr -d '\n'); "# - + #"printf '\033]3008;start=\#(agent);kind=notify;token=%s;title=%s;body=%s\033\\' "# - + #""$SUPACODE_OSC_TOKEN" "$__t" "$__b" > "$__tty"; "# + + #"printf '\033]3008;start=\#(agent);kind=notify;title=%s;body=%s\033\\' "$__t" "$__b" > "$__tty"; "# } static let snapshotClaudeBusy = @@ -599,9 +611,8 @@ struct AgentHookCommandTests { process.arguments = ["-c", patched] var environment = ProcessInfo.processInfo.environment // The host may already export Supacode-surface vars (tests can run inside a - // Supacode surface); clear all three so every absent-variable assertion is genuine. + // Supacode surface); clear them so every absent-variable assertion is genuine. environment.removeValue(forKey: "SUPACODE_SOCKET_PATH") - environment.removeValue(forKey: "SUPACODE_OSC_TOKEN") environment.removeValue(forKey: "SUPACODE_SURFACE_ID") for (key, value) in env { environment[key] = value } process.environment = environment diff --git a/supacodeTests/AgentPresenceOSCTests.swift b/supacodeTests/AgentPresenceOSCTests.swift index 64ef93e81..d5bc3186b 100644 --- a/supacodeTests/AgentPresenceOSCTests.swift +++ b/supacodeTests/AgentPresenceOSCTests.swift @@ -8,42 +8,33 @@ struct AgentPresenceOSCTests { // MARK: - parse. @Test func parsesValidSignal() { - let signal = AgentPresenceOSC.parse(id: "claude", metadata: "event=busy;token=abc123") + let signal = AgentPresenceOSC.parse(id: "claude", metadata: "event=busy") #expect(signal?.agent == "claude") #expect(signal?.eventRawValue == "busy") - #expect(signal?.token == "abc123") } @Test func rejectsEmptyId() { - #expect(AgentPresenceOSC.parse(id: "", metadata: "event=busy;token=abc") == nil) + #expect(AgentPresenceOSC.parse(id: "", metadata: "event=busy") == nil) } @Test func rejectsMissingEvent() { - #expect(AgentPresenceOSC.parse(id: "claude", metadata: "token=abc") == nil) + #expect(AgentPresenceOSC.parse(id: "claude", metadata: "pid=5") == nil) } @Test func rejectsUnknownEvent() { - #expect(AgentPresenceOSC.parse(id: "claude", metadata: "event=not_a_real_event;token=abc") == nil) - } - - @Test func rejectsMissingToken() { - #expect(AgentPresenceOSC.parse(id: "claude", metadata: "event=busy") == nil) - } - - @Test func rejectsEmptyToken() { - #expect(AgentPresenceOSC.parse(id: "claude", metadata: "event=busy;token=") == nil) + #expect(AgentPresenceOSC.parse(id: "claude", metadata: "event=not_a_real_event") == nil) } @Test func ignoresUnknownFieldsAndOrdering() { + // A leftover `token=` from an older emitter is just an unknown field now: ignored. let signal = AgentPresenceOSC.parse(id: "codex", metadata: "extra=1;token=zzz;event=session_start") #expect(signal?.eventRawValue == "session_start") - #expect(signal?.token == "zzz") #expect(signal?.agent == "codex") } @Test func skipsBareSegmentWithoutEquals() { // A segment with no '=' (a stray sentinel byte) is skipped, not fatal. - let signal = AgentPresenceOSC.parse(id: "claude", metadata: "garbage;event=idle;token=t") + let signal = AgentPresenceOSC.parse(id: "claude", metadata: "garbage;event=idle") #expect(signal?.eventRawValue == "idle") } @@ -51,22 +42,21 @@ struct AgentPresenceOSCTests { @Test func emitMetadataRoundTripsThroughParse() { for event in [HookEvent.sessionStart, .sessionEnd, .busy, .awaitingInput, .idle] { - let metadata = AgentPresenceOSC.metadata(event: event, token: "tok123") + let metadata = AgentPresenceOSC.metadata(event: event) let signal = AgentPresenceOSC.parse(id: "claude", metadata: metadata) #expect(signal?.eventRawValue == event.rawValue) - #expect(signal?.token == "tok123") } } // MARK: - pid field (local-host liveness). @Test func parsesPositivePidField() { - let signal = AgentPresenceOSC.parse(id: "claude", metadata: "event=busy;token=tok;pid=4321") + let signal = AgentPresenceOSC.parse(id: "claude", metadata: "event=busy;pid=4321") #expect(signal?.pid == 4321) } @Test func absentPidParsesAsNil() { - let signal = AgentPresenceOSC.parse(id: "claude", metadata: "event=busy;token=tok") + let signal = AgentPresenceOSC.parse(id: "claude", metadata: "event=busy") #expect(signal?.eventRawValue == "busy") #expect(signal?.pid == nil) } @@ -75,7 +65,7 @@ struct AgentPresenceOSCTests { // 0 / negatives would let `kill(_:0)` match the caller's process group and // pin a permanent badge; a non-numeric pid is dropped, not fatal. for raw in ["0", "-7", "abc", ""] { - let signal = AgentPresenceOSC.parse(id: "claude", metadata: "event=busy;token=tok;pid=\(raw)") + let signal = AgentPresenceOSC.parse(id: "claude", metadata: "event=busy;pid=\(raw)") #expect(signal?.eventRawValue == "busy") #expect(signal?.pid == nil) } @@ -84,20 +74,20 @@ struct AgentPresenceOSCTests { @Test func rejectsPidThatOverflowsPidT() { // Defense in depth against a future change from `pid_t(raw)` to `Int(raw)`: // a value beyond pid_t's range must drop, not wrap. - let signal = AgentPresenceOSC.parse(id: "claude", metadata: "event=busy;token=t;pid=99999999999999") + let signal = AgentPresenceOSC.parse(id: "claude", metadata: "event=busy;pid=99999999999999") #expect(signal?.pid == nil) } @Test func metadataPidSuffixRoundTripsThroughParse() { - let metadata = AgentPresenceOSC.metadata(event: .busy, token: "tok", pidSuffix: ";pid=99") + let metadata = AgentPresenceOSC.metadata(event: .busy, pidSuffix: ";pid=99") let signal = AgentPresenceOSC.parse(id: "claude", metadata: metadata) #expect(signal?.pid == 99) } @Test func presenceEventThreadsLocalPid() { - let metadata = AgentPresenceOSC.metadata(event: .busy, token: "tok", pidSuffix: ";pid=4242") + let metadata = AgentPresenceOSC.metadata(event: .busy, pidSuffix: ";pid=4242") let result = WorktreeTerminalState.presenceEvent( - id: "claude", metadata: metadata, expectedToken: "tok", surfaceID: UUID(), surfaceExists: true) + id: "claude", metadata: metadata, surfaceID: UUID(), surfaceExists: true) #expect((try? result.get())?.pid == 4242) } @@ -108,45 +98,13 @@ struct AgentPresenceOSCTests { } } - // MARK: - tokensMatch (anti-spoof compare). - - @Test func tokensMatchEqual() { - #expect(AgentPresenceOSC.tokensMatch("abc123", "abc123")) - } - - @Test func tokensMatchEmpty() { - #expect(AgentPresenceOSC.tokensMatch("", "")) - } - - @Test func tokensMatchRejectsOneByteDifference() { - #expect(!AgentPresenceOSC.tokensMatch("abc123", "abc124")) - } - - @Test func tokensMatchRejectsDifferentLengths() { - #expect(!AgentPresenceOSC.tokensMatch("abc", "abc1")) - } - - // MARK: - makeOSCToken (fixed-length hex invariant). - - @MainActor - @Test func makeOSCTokenAlwaysReturns32LowercaseHexChars() { - // Guards `tokensMatch`'s fixed-length contract against regressions on either - // the SecRandomCopyBytes path or the arc4random_buf fallback. - let allowed = Set("0123456789abcdef") - for _ in 0..<100 { - let token = WorktreeTerminalState.makeOSCToken() - #expect(token.count == 32) - #expect(token.allSatisfy { allowed.contains($0) }) - } - } - - // MARK: - presenceEvent (trust boundary + attribution). + // MARK: - presenceEvent (attribution to receiving surface). - @Test func presenceEventTrustsMatchingTokenAndAttributesToReceivingSurface() { + @Test func presenceEventAttributesToReceivingSurface() { let surfaceID = UUID() - let metadata = AgentPresenceOSC.metadata(event: .busy, token: "tok") + let metadata = AgentPresenceOSC.metadata(event: .busy) let result = WorktreeTerminalState.presenceEvent( - id: "claude", metadata: metadata, expectedToken: "tok", surfaceID: surfaceID, surfaceExists: true) + id: "claude", metadata: metadata, surfaceID: surfaceID, surfaceExists: true) let event = try? result.get() #expect(event?.surfaceID == surfaceID) #expect(event?.agent == "claude") @@ -154,23 +112,17 @@ struct AgentPresenceOSCTests { #expect(event?.pid == nil) } - @Test func presenceEventDropsMismatchedToken() { - let metadata = AgentPresenceOSC.metadata(event: .busy, token: "wrong") + @Test func presenceEventDropsUnknownSurface() { + let metadata = AgentPresenceOSC.metadata(event: .busy) let result = WorktreeTerminalState.presenceEvent( - id: "claude", metadata: metadata, expectedToken: "right", surfaceID: UUID(), surfaceExists: true) - guard case .failure(.tokenMismatch(let agent, let event)) = result else { - Issue.record("expected tokenMismatch, got \(result)") - return - } - #expect(agent == "claude") - #expect(event == "busy") + id: "claude", metadata: metadata, surfaceID: UUID(), surfaceExists: false) + #expect(result == .failure(.unknownSurface)) } - @Test func presenceEventDropsUnknownSurface() { - let metadata = AgentPresenceOSC.metadata(event: .busy, token: "tok") + @Test func presenceEventDropsMalformedMetadata() { let result = WorktreeTerminalState.presenceEvent( - id: "claude", metadata: metadata, expectedToken: nil, surfaceID: UUID(), surfaceExists: false) - #expect(result == .failure(.unknownSurface)) + id: "claude", metadata: "event=nope", surfaceID: UUID(), surfaceExists: true) + #expect(result == .failure(.parseFailed)) } // MARK: - AgentHookEvent synthesis. @@ -195,15 +147,13 @@ struct AgentPresenceOSCTests { return Data(json.dropFirst().dropLast()).base64EncodedString() } - private static func notifyMeta(token: String = "tok", title: String? = nil, body: String? = nil) -> String { - AgentPresenceOSC.notifyMetadata( - token: token, title: title.map(field) ?? "", body: body.map(field) ?? "") + private static func notifyMeta(title: String? = nil, body: String? = nil) -> String { + AgentPresenceOSC.notifyMetadata(title: title.map(field) ?? "", body: body.map(field) ?? "") } @Test func parsesValidNotify() { let signal = AgentPresenceOSC.parseNotify(id: "claude", metadata: Self.notifyMeta(body: "hi")) #expect(signal?.agent == "claude") - #expect(signal?.token == "tok") #expect(signal?.title == nil) #expect(signal?.body == "hi") } @@ -222,11 +172,7 @@ struct AgentPresenceOSCTests { } @Test func rejectsNotifyWithoutKind() { - #expect(AgentPresenceOSC.parseNotify(id: "claude", metadata: "token=tok;body=\(Self.field("x"))") == nil) - } - - @Test func rejectsNotifyWithoutToken() { - #expect(AgentPresenceOSC.parseNotify(id: "claude", metadata: "kind=notify;body=\(Self.field("x"))") == nil) + #expect(AgentPresenceOSC.parseNotify(id: "claude", metadata: "body=\(Self.field("x"))") == nil) } @Test func notifyWithoutBodyParsesAsTitleOnly() { @@ -242,7 +188,7 @@ struct AgentPresenceOSCTests { } @Test func invalidBase64FieldDecodesToNil() { - let signal = AgentPresenceOSC.parseNotify(id: "claude", metadata: "kind=notify;token=tok;body=!!notb64") + let signal = AgentPresenceOSC.parseNotify(id: "claude", metadata: "kind=notify;body=!!notb64") #expect(signal?.body == nil) } @@ -252,7 +198,7 @@ struct AgentPresenceOSCTests { // path relies on this). let escaped = #"say \"# // ends with a lone backslash (a cut `\"`) let signal = AgentPresenceOSC.parseNotify( - id: "claude", metadata: "kind=notify;token=tok;body=\(Data(escaped.utf8).base64EncodedString())") + id: "claude", metadata: "kind=notify;body=\(Data(escaped.utf8).base64EncodedString())") #expect(signal?.body == "say ") } @@ -261,7 +207,7 @@ struct AgentPresenceOSCTests { // must shed back to the recoverable prefix, not drop the whole body to empty. let escaped = #"done \uD83D\uDE0"# let signal = AgentPresenceOSC.parseNotify( - id: "claude", metadata: "kind=notify;token=tok;body=\(Data(escaped.utf8).base64EncodedString())") + id: "claude", metadata: "kind=notify;body=\(Data(escaped.utf8).base64EncodedString())") // The recoverable prefix survives (not dropped to empty); exact trailing // depends on Foundation's lone-surrogate handling, so assert the prefix. #expect(signal?.body?.hasPrefix("done") == true) @@ -273,7 +219,7 @@ struct AgentPresenceOSCTests { let valid = Data("hello world body".utf8).base64EncodedString() let cut = String(valid.dropLast()) // break base64 alignment let signal = AgentPresenceOSC.parseNotify( - id: "claude", metadata: "kind=notify;token=tok;title=\(Self.field("Done"));body=\(cut)") + id: "claude", metadata: "kind=notify;title=\(Self.field("Done"));body=\(cut)") #expect(signal?.title == "Done") #expect(signal?.body == nil) } @@ -283,7 +229,6 @@ struct AgentPresenceOSCTests { let signal = AgentPresenceOSC.parseNotify(id: "codex", metadata: metadata) #expect(signal?.title == "T") #expect(signal?.body == "round trip") - #expect(signal?.token == "tok") } @Test func presenceParseRejectsNotifyMetadata() { @@ -291,11 +236,11 @@ struct AgentPresenceOSCTests { #expect(AgentPresenceOSC.parse(id: "claude", metadata: Self.notifyMeta(body: "x")) == nil) } - // MARK: - notification (trust + sanitize). + // MARK: - notification (parse + sanitize). - @Test func notificationTrustsMatchingTokenAndExtractsBody() { + @Test func notificationExtractsBody() { let resolved = WorktreeTerminalState.notification( - id: "claude", metadata: Self.notifyMeta(body: "all done"), expectedToken: "tok", surfaceExists: true) + id: "claude", metadata: Self.notifyMeta(body: "all done"), surfaceExists: true) guard case .success(let value) = resolved else { Issue.record("expected success, got \(resolved)") return @@ -303,30 +248,19 @@ struct AgentPresenceOSCTests { #expect(value.body == "all done") } - @Test func notificationDropsMismatchedToken() { - let result = WorktreeTerminalState.notification( - id: "claude", metadata: Self.notifyMeta(token: "wrong", body: "x"), - expectedToken: "right", surfaceExists: true) - guard case .failure(.tokenMismatch(let agent)) = result else { - Issue.record("expected tokenMismatch, got \(result)") - return - } - #expect(agent == "claude") - } - @Test func notificationDropsUnknownSurface() { let result = WorktreeTerminalState.notification( - id: "claude", metadata: Self.notifyMeta(body: "x"), expectedToken: nil, surfaceExists: false) + id: "claude", metadata: Self.notifyMeta(body: "x"), surfaceExists: false) if case .failure(.unknownSurface) = result {} else { Issue.record("expected unknownSurface, got \(result)") } } - @Test func notificationDropsClosedSurfaceWithNilExpectedTokenWithoutWarning() { - // A signal targeting a closed surface (no expected token, surface gone) is - // benign, not a spoof: the call site routes `.unknownSurface` to `.debug`, - // never `.warning`. Asserting the exact failure case locks that mapping in - // since `tokenMismatch` / `parseFailed` are the only warn-level branches. + @Test func notificationDropsClosedSurfaceWithoutWarning() { + // A signal targeting a closed surface is benign, not malformed: the call site + // routes `.unknownSurface` to `.debug`, never `.warning`. Asserting the exact + // failure case locks that mapping in since `parseFailed` is the only + // warn-level branch. let result = WorktreeTerminalState.notification( - id: "claude", metadata: Self.notifyMeta(body: "x"), expectedToken: nil, surfaceExists: false) + id: "claude", metadata: Self.notifyMeta(body: "x"), surfaceExists: false) guard case .failure(let drop) = result else { Issue.record("expected failure, got \(result)") return @@ -335,13 +269,12 @@ struct AgentPresenceOSCTests { } else { Issue.record("expected unknownSurface, got \(drop)") } - if case .tokenMismatch = drop { Issue.record("unknown surface must not log as a spoof warning") } if case .parseFailed = drop { Issue.record("unknown surface must not log as a malformed warning") } } @Test func notificationFallsBackToAgentTitleWhenAbsent() { let resolved = WorktreeTerminalState.notification( - id: "codex", metadata: Self.notifyMeta(body: "body only"), expectedToken: "tok", surfaceExists: true) + id: "codex", metadata: Self.notifyMeta(body: "body only"), surfaceExists: true) guard case .success(let value) = resolved else { Issue.record("expected success, got \(resolved)") return @@ -352,7 +285,7 @@ struct AgentPresenceOSCTests { @Test func notificationShowsTitleOnlyToastWhenBodyAbsent() { // A turn-complete notify with no body still fires, showing just the title. let resolved = WorktreeTerminalState.notification( - id: "claude", metadata: Self.notifyMeta(), expectedToken: "tok", surfaceExists: true) + id: "claude", metadata: Self.notifyMeta(), surfaceExists: true) guard case .success(let value) = resolved else { Issue.record("expected success, got \(resolved)") return @@ -365,7 +298,7 @@ struct AgentPresenceOSCTests { // Body of only control / whitespace and no usable title sanitizes to empty, // so the toast is suppressed rather than shown blank. let result = WorktreeTerminalState.notification( - id: " ", metadata: Self.notifyMeta(body: "\n"), expectedToken: "tok", surfaceExists: true) + id: " ", metadata: Self.notifyMeta(body: "\n"), surfaceExists: true) if case .failure(.empty) = result {} else { Issue.record("expected empty, got \(result)") } } @@ -387,9 +320,9 @@ struct AgentPresenceOSCTests { // unicode escape (raw 0x1B is illegal in a JSON string); the C0 strip // must drop both the opening ESC and the trailing ST ESC before the // toast sees them. - let body = "before\u{1B}]3008;start=evil;event=busy;token=X\u{1B}\\after" + let body = "before\u{1B}]3008;start=evil;event=busy\u{1B}\\after" let resolved = WorktreeTerminalState.notification( - id: "claude", metadata: Self.notifyMeta(body: body), expectedToken: "tok", surfaceExists: true) + id: "claude", metadata: Self.notifyMeta(body: body), surfaceExists: true) guard case .success(let value) = resolved else { Issue.record("expected success, got \(resolved)") return @@ -399,28 +332,28 @@ struct AgentPresenceOSCTests { // Printable framing bytes survive (they are not C0); the load-bearing // assertion is that the ESC is gone, so a downstream renderer cannot // re-trigger an escape parser. - #expect(value.body == #"before]3008;start=evil;event=busy;token=X\after"#) + #expect(value.body == #"before]3008;start=evil;event=busy\after"#) // The standalone sanitize entry point pins the same contract directly. - let dirty = "before\u{1B}]3008;start=evil;event=busy;token=X\u{1B}\\after" + let dirty = "before\u{1B}]3008;start=evil;event=busy\u{1B}\\after" #expect( WorktreeTerminalState.sanitizeNotificationText(dirty, max: 1000) - == #"before]3008;start=evil;event=busy;token=X\after"#) + == #"before]3008;start=evil;event=busy\after"#) } // MARK: - large payload (metadata cap headroom). @Test func largeNotifyPayloadNearMetadataCapRoundTripsEndToEnd() { // A near-cap body must survive parseNotify + notification(...) end to end and - // stay under the 2047-byte OSC cap so a real terminal does not truncate. + // stay under the 2048-byte OSC cap so a real terminal does not truncate. let bodyText = String(repeating: "y", count: 1400) let metadata = Self.notifyMeta(title: "Big", body: bodyText) - #expect(metadata.utf8.count < 2047) + #expect(metadata.utf8.count < 2048) let signal = AgentPresenceOSC.parseNotify(id: "codex", metadata: metadata) #expect(signal?.body == bodyText) let resolved = WorktreeTerminalState.notification( - id: "codex", metadata: metadata, expectedToken: "tok", surfaceExists: true) + id: "codex", metadata: metadata, surfaceExists: true) guard case .success(let value) = resolved else { Issue.record("expected success, got \(resolved)") return diff --git a/supacodeTests/GhosttySurfaceBridgeTests.swift b/supacodeTests/GhosttySurfaceBridgeTests.swift index b4ded231c..6de3f95b8 100644 --- a/supacodeTests/GhosttySurfaceBridgeTests.swift +++ b/supacodeTests/GhosttySurfaceBridgeTests.swift @@ -126,7 +126,7 @@ struct GhosttySurfaceBridgeTests { let target = ghostty_target_s() "claude".withCString { idPtr in - "event=busy;token=abc".withCString { metaPtr in + "event=busy".withCString { metaPtr in action.action.context_signal = ghostty_action_context_signal_s( action: 0, id: idPtr, @@ -138,7 +138,7 @@ struct GhosttySurfaceBridgeTests { #expect(receivedAction == 0) #expect(receivedID == "claude") - #expect(receivedMetadata == "event=busy;token=abc") + #expect(receivedMetadata == "event=busy") } @Test func contextSignalDropsNullIDOrMetadata() { @@ -151,7 +151,7 @@ struct GhosttySurfaceBridgeTests { let target = ghostty_target_s() // Null id with valid metadata. - "event=busy;token=abc".withCString { metaPtr in + "event=busy".withCString { metaPtr in action.action.context_signal = ghostty_action_context_signal_s( action: 0, id: nil, From 2df2b75a761fb095f6ea2e951f229eb0420dedc8 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 10 Jun 2026 22:06:53 +0200 Subject: [PATCH 14/56] Restructure CI flows and cache all ThirdParty build outputs (#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. --- .github/actions/setup-macos/action.yml | 24 +- .github/workflows/{test.yml => ci.yml} | 8 +- .github/workflows/inspect-dependencies.yml | 29 -- .../workflows/{release-tip.yml => main.yml} | 177 ++++++++++- .github/workflows/release.yml | 286 ------------------ .github/workflows/warm-cache.yml | 26 -- Makefile | 26 +- 7 files changed, 194 insertions(+), 382 deletions(-) rename .github/workflows/{test.yml => ci.yml} (82%) delete mode 100644 .github/workflows/inspect-dependencies.yml rename .github/workflows/{release-tip.yml => main.yml} (65%) delete mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/warm-cache.yml diff --git a/.github/actions/setup-macos/action.yml b/.github/actions/setup-macos/action.yml index b8fd67438..a5e53e98c 100644 --- a/.github/actions/setup-macos/action.yml +++ b/.github/actions/setup-macos/action.yml @@ -1,5 +1,5 @@ name: Setup macOS build deps -description: Install + cache mise tools and Ghostty build outputs +description: Install + cache mise tools and ThirdParty build outputs (Ghostty, zmx) runs: using: composite @@ -36,21 +36,35 @@ runs: run: | XCODE_VERSION="$(xcodebuild -version | tr '\n' '-' | sed 's/[^A-Za-z0-9._-]/_/g; s/-$//')" printf '%s\n' "XCODE_VERSION=$XCODE_VERSION" >> "$GITHUB_ENV" - - name: Ghostty cache key + - name: ThirdParty cache keys shell: bash run: | set -euo pipefail - GHOSTTY_SHA="$(git -C ThirdParty/ghostty rev-parse HEAD)" - printf '%s\n' "GHOSTTY_SHA=$GHOSTTY_SHA" >> "$GITHUB_ENV" + printf '%s\n' "GHOSTTY_SHA=$(git -C ThirdParty/ghostty rev-parse HEAD)" >> "$GITHUB_ENV" + printf '%s\n' "ZMX_SHA=$(git -C ThirdParty/zmx rev-parse HEAD)" >> "$GITHUB_ENV" + # Key on only the inputs that change the built artifact (submodule SHA, build + # script, zig version, patches) so unrelated action/project edits don't evict it. - name: Ghostty cache id: ghostty_cache uses: actions/cache@v5 with: path: .build/ghostty - key: ${{ runner.os }}-${{ runner.arch }}-xcode-${{ env.XCODE_VERSION }}-ghostty-v2-${{ env.GHOSTTY_SHA }}-${{ hashFiles('.github/actions/setup-macos/action.yml', 'Project.swift', 'scripts/build-ghostty.sh', 'mise.toml', '.gitmodules') }} + key: ${{ runner.os }}-${{ runner.arch }}-xcode-${{ env.XCODE_VERSION }}-ghostty-v3-${{ env.GHOSTTY_SHA }}-${{ hashFiles('scripts/build-ghostty.sh', 'mise.toml', '.gitmodules', 'patches/*.patch') }} - name: Build ghostty if: steps.ghostty_cache.outputs.cache-hit != 'true' shell: bash run: | set -euo pipefail make build-ghostty-xcframework + - name: zmx cache + id: zmx_cache + uses: actions/cache@v5 + with: + path: .build/zmx + key: ${{ runner.os }}-${{ runner.arch }}-xcode-${{ env.XCODE_VERSION }}-zmx-v1-${{ env.ZMX_SHA }}-${{ hashFiles('scripts/build-zmx.sh', 'mise.toml', '.gitmodules') }} + - name: Build zmx + if: steps.zmx_cache.outputs.cache-hit != 'true' + shell: bash + run: | + set -euo pipefail + make build-zmx diff --git a/.github/workflows/test.yml b/.github/workflows/ci.yml similarity index 82% rename from .github/workflows/test.yml rename to .github/workflows/ci.yml index f15d62dee..91cea384e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,6 @@ -name: test +name: ci on: - push: - branches: - - main pull_request: branches: - main @@ -27,5 +24,8 @@ jobs: submodules: recursive - uses: ./.github/actions/setup-macos - run: make lint + - run: make inspect-dependencies - run: make build-app + env: + XCODEBUILD_FLAGS: -showBuildTimingSummary - run: make test diff --git a/.github/workflows/inspect-dependencies.yml b/.github/workflows/inspect-dependencies.yml deleted file mode 100644 index 1310a39ef..000000000 --- a/.github/workflows/inspect-dependencies.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: inspect-dependencies - -on: - push: - branches: - - main - pull_request: - branches: - - main - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - inspect-dependencies: - runs-on: macos-26 - permissions: - contents: read - id-token: write - env: - MISE_HTTP_TIMEOUT: 120 - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@v6 - with: - submodules: recursive - - uses: ./.github/actions/setup-macos - - run: make inspect-dependencies diff --git a/.github/workflows/release-tip.yml b/.github/workflows/main.yml similarity index 65% rename from .github/workflows/release-tip.yml rename to .github/workflows/main.yml index 2b81f4d79..9a44fe235 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,4 @@ -name: Release Tip +name: main on: push: @@ -6,13 +6,55 @@ on: workflow_dispatch: {} +# Never cancel in flight: this pipeline also cuts stable releases. Newer pushes queue. concurrency: group: ${{ github.workflow }} - cancel-in-progress: true + cancel-in-progress: false jobs: + # Warm the binary cache upstream so test and build consume it on the same commit. + warm: + runs-on: macos-26 + strategy: + matrix: + configuration: [Debug, Release] + permissions: + contents: read + id-token: write + env: + MISE_HTTP_TIMEOUT: 120 + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: ./.github/actions/setup-macos + - run: make warm-cache + env: + TUIST_CACHE_CONFIGURATION: ${{ matrix.configuration }} + + test: + runs-on: macos-26 + needs: [warm] + permissions: + contents: read + id-token: write + env: + MISE_HTTP_TIMEOUT: 120 + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: ./.github/actions/setup-macos + - run: make lint + - run: make inspect-dependencies + - run: make build-app + env: + XCODEBUILD_FLAGS: -showBuildTimingSummary + - run: make test + check: - if: github.event_name == 'workflow_dispatch' || github.ref_name == 'main' runs-on: ubuntu-latest outputs: should_skip: ${{ steps.check.outputs.should_skip }} @@ -33,7 +75,7 @@ jobs: build: runs-on: macos-26 - needs: [check] + needs: [check, warm] if: needs.check.outputs.should_skip != 'true' permissions: contents: read @@ -55,7 +97,7 @@ jobs: with: submodules: recursive - uses: ./.github/actions/setup-macos - - name: Prepare tip overrides + - name: Prepare build overrides env: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} @@ -65,13 +107,8 @@ jobs: : "${SENTRY_DSN:?secret SENTRY_DSN is not set}" : "${POSTHOG_API_KEY:?secret POSTHOG_API_KEY is not set}" : "${POSTHOG_HOST:?secret POSTHOG_HOST is not set}" - BASE=$(awk -F' = ' '/^CURRENT_PROJECT_VERSION = [0-9]+$/{print $2; exit}' Configurations/Project.xcconfig) - OFFSET=${{ github.run_number }} - if [ "$OFFSET" -gt 999 ]; then - echo "::error::Tip run_number ($OFFSET) exceeds 999. Bump CURRENT_PROJECT_VERSION before the next tip release." - exit 1 - fi - BUILD_NUMBER=$((BASE * 1000 + OFFSET)) + # Epoch build number: monotonic, computed once here so the single binary reused by tip and stable carries one version. + BUILD_NUMBER=$(date +%s) mkdir -p build cat > build/ReleaseOverrides.xcconfig </dev/null || true)" = "${{ github.sha }}" ]; then + # Idempotency: skip if this release was already published (re-run safety). + ASSET_COUNT=$(gh release view "$TAG" -R "${{ github.repository }}" --json assets --jq '.assets | length' 2>/dev/null || echo "0") + if [ "$ASSET_COUNT" -eq 0 ]; then + RELEASE_TAG="$TAG" + else + echo "$TAG already has $ASSET_COUNT assets, skipping stable publish" + fi + fi + echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT" + tag: runs-on: ubuntu-latest needs: [check, build] @@ -265,10 +335,89 @@ jobs: SENTRY_PROJECT: supacode run: sentry-cli debug-files upload --include-sources dsyms + # Stable release: reuses the SAME notarized binaries build produced (no second + # archive or notarization) and publishes them with auto-generated notes. + publish-stable: + runs-on: macos-26 + needs: [build, detect] + if: ${{ needs.detect.outputs.release_tag != '' }} + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + RELEASE_REPO: ${{ github.repository }} + TAG: ${{ needs.detect.outputs.release_tag }} + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 + with: + name: build-artifacts + path: build + - name: Drop the tip appcast/deltas from the shared artifact + run: rm -f build/appcast.xml build/*.delta + - name: Generate release notes + run: | + set -euo pipefail + PREV=$(gh release list --exclude-drafts --exclude-pre-releases -R "$RELEASE_REPO" --json tagName --jq '.[0].tagName' 2>/dev/null || echo "") + if [ -n "$PREV" ] && [ "$PREV" != "$TAG" ]; then + gh api "repos/$RELEASE_REPO/releases/generate-notes" -f tag_name="$TAG" -f previous_tag_name="$PREV" --jq '.body' > build/release-notes.md + else + gh api "repos/$RELEASE_REPO/releases/generate-notes" -f tag_name="$TAG" --jq '.body' > build/release-notes.md + fi + - name: Generate appcast with history and deltas + run: | + set -euo pipefail + MAX_DELTAS=10 + NOTES_FILE=build/release-notes.md + STAGING=$(mktemp -d) + ARCHIVE=build/supacode.app.zip + ARCHIVE_BASE=$(basename "$ARCHIVE") + ARCHIVE_BASE="${ARCHIVE_BASE%.zip}" + cp "$ARCHIVE" "$STAGING/" + cp "$NOTES_FILE" "$STAGING/$ARCHIVE_BASE.md" + + curl -fsSL "https://supacode.sh/download/latest/appcast.xml" -o "$STAGING/appcast.xml" || true + + SEEN_VERSIONS="" + for i in $(seq 1 $MAX_DELTAS); do + PREV_TAG=$(gh release list --limit $i --exclude-drafts --exclude-pre-releases -R "$RELEASE_REPO" --json tagName --jq ".[$((i-1))].tagName" 2>/dev/null || true) + if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$TAG" ]; then + gh release download "$PREV_TAG" -p "supacode.app.zip" -O "$STAGING/supacode-$PREV_TAG.app.zip" -R "$RELEASE_REPO" 2>/dev/null || true + if [ -f "$STAGING/supacode-$PREV_TAG.app.zip" ]; then + BUNDLE_VERSION=$(unzip -p "$STAGING/supacode-$PREV_TAG.app.zip" "supacode.app/Contents/Info.plist" 2>/dev/null | /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" /dev/stdin 2>/dev/null || echo "") + if [ -n "$BUNDLE_VERSION" ] && echo "$SEEN_VERSIONS" | grep -q "^$BUNDLE_VERSION$"; then + rm -f "$STAGING/supacode-$PREV_TAG.app.zip" + else + [ -n "$BUNDLE_VERSION" ] && SEEN_VERSIONS="$SEEN_VERSIONS$BUNDLE_VERSION"$'\n' + gh release view "$PREV_TAG" -R "$RELEASE_REPO" --json body --jq '.body' > "$STAGING/supacode-$PREV_TAG.app.md" 2>/dev/null || true + fi + fi + fi + done + + printf "%s" "$SPARKLE_PRIVATE_KEY" | tr -d '\r\n\t ' | ./bins/generate_appcast --download-url-prefix "https://supacode.sh/download/$TAG/" --full-release-notes-url "https://github.com/supabitapp/supacode/releases" --embed-release-notes --maximum-versions $MAX_DELTAS --maximum-deltas $MAX_DELTAS --delta-compression lzma --ed-key-file - "$STAGING" + cp "$STAGING/appcast.xml" build/appcast.xml + find "$STAGING" -name "*.delta" -exec cp {} build/ \; 2>/dev/null || true + - name: Create release and upload assets + run: | + set -euo pipefail + gh release create "$TAG" --title "$TAG" --notes-file build/release-notes.md --target "${{ github.sha }}" -R "$RELEASE_REPO" 2>/dev/null || \ + gh release edit "$TAG" --target "${{ github.sha }}" -R "$RELEASE_REPO" + DELTA_FILES=$(find build -name "*.delta" -type f 2>/dev/null | tr '\n' ' ' || true) + gh release upload "$TAG" build/supacode.app.zip build/supacode.dmg build/appcast.xml $DELTA_FILES --clobber -R "$RELEASE_REPO" + - name: Verify download URLs + run: | + set -euo pipefail + curl -fsSL "https://supacode.sh/download/$TAG/supacode.app.zip" -o /dev/null + curl -fsSL "https://supacode.sh/download/$TAG/supacode.dmg" -o /dev/null + + # Ordered after publish-stable so the tip item merges into any freshly cut release. Tolerates a skip + # (non-release commits) but blocks on its failure, so the tip merge never runs against a half-published release. publish: runs-on: ubuntu-latest - needs: [check, build, tag] - if: needs.check.outputs.should_skip != 'true' + needs: [check, build, tag, publish-stable] + if: ${{ !cancelled() && needs.check.outputs.should_skip != 'true' && needs.build.result == 'success' && needs.tag.result == 'success' && (needs.publish-stable.result == 'success' || needs.publish-stable.result == 'skipped') }} permissions: contents: write steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index a81f6692f..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,286 +0,0 @@ -name: Release - -on: - release: - types: [published] - -concurrency: - group: release - cancel-in-progress: false - -jobs: - check: - runs-on: ubuntu-latest - outputs: - should_skip: ${{ steps.check.outputs.should_skip }} - steps: - - name: Check if release already has artifacts - id: check - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.event.release.tag_name }} - run: | - ASSETS=$(gh release view "$TAG" -R "${{ github.repository }}" --json assets --jq '.assets | length') - if [ "$ASSETS" -gt 0 ]; then - echo "Release $TAG already has $ASSETS assets, skipping" - echo "should_skip=true" >> "$GITHUB_OUTPUT" - else - echo "should_skip=false" >> "$GITHUB_OUTPUT" - fi - - build: - runs-on: macos-26 - needs: [check] - if: needs.check.outputs.should_skip != 'true' - permissions: - contents: read - id-token: write - env: - MISE_HTTP_TIMEOUT: 120 - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }} - DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }} - DEVELOPER_ID_IDENTITY: ${{ secrets.DEVELOPER_ID_IDENTITY }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_NOTARIZATION_ISSUER: ${{ secrets.APPLE_NOTARIZATION_ISSUER }} - APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} - APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} - SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} - steps: - - uses: actions/checkout@v6 - with: - submodules: recursive - - uses: ./.github/actions/setup-macos - - run: echo "TAG=${{ github.event.release.tag_name }}" >> "$GITHUB_ENV" - - name: Prepare release overrides - env: - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} - POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }} - run: | - set -euo pipefail - : "${SENTRY_DSN:?secret SENTRY_DSN is not set}" - : "${POSTHOG_API_KEY:?secret POSTHOG_API_KEY is not set}" - : "${POSTHOG_HOST:?secret POSTHOG_HOST is not set}" - BASE=$(awk -F' = ' '/^CURRENT_PROJECT_VERSION = [0-9]+$/{print $2; exit}' Configurations/Project.xcconfig) - BUILD_NUMBER=$((BASE * 1000)) - mkdir -p build - cat > build/ReleaseOverrides.xcconfig <> "$GITHUB_ENV" - - name: Setup keychain - run: | - echo "$DEVELOPER_ID_CERT_P12" | base64 --decode > build-cert.p12 - security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - security set-keychain-settings -t 3600 -u build.keychain - security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - security import build-cert.p12 -k build.keychain -P "$DEVELOPER_ID_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/xcodebuild > /dev/null - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain > /dev/null - security list-keychains -d user -s build.keychain $(security list-keychains -d user | tr -d '"') - security default-keychain -s build.keychain - DEVELOPER_ID_IDENTITY_SHA=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application" | head -1 | awk '{print $2}') - if [ -z "$DEVELOPER_ID_IDENTITY_SHA" ]; then - echo "::error::Developer ID Application identity not found in keychain" - exit 1 - fi - echo "DEVELOPER_ID_IDENTITY_SHA=$DEVELOPER_ID_IDENTITY_SHA" >> "$GITHUB_ENV" - - name: Archive workspace - run: make archive - - name: Upload dSYMs - uses: actions/upload-artifact@v6 - with: - name: dsyms - path: build/supacode.xcarchive/dSYMs - - run: | - cat > build/ExportOptions.plist < - - - - method - developer-id - signingStyle - manual - signingCertificate - $DEVELOPER_ID_IDENTITY - teamID - $APPLE_TEAM_ID - - - EOF - make export-archive - - name: Re-sign frameworks - run: | - set -ex - APP_PATH="$(find build/export -name "supacode.app" -maxdepth 3 -print -quit)" - SPARKLE="$APP_PATH/Contents/Frameworks/Sparkle.framework/Versions/B" - bash ./.github/scripts/resign_exported_app.sh build/export - - codesign -d --entitlements - "$APP_PATH/Contents/MacOS/supacode" 2>&1 | tee /tmp/supacode-entitlements.txt - grep -q "com.apple.security.device.audio-input" /tmp/supacode-entitlements.txt - - codesign -dv --verbose=4 "$APP_PATH" 2>&1 | grep -E "Authority=Developer ID Application|Timestamp=" - codesign -dv --verbose=4 "$APP_PATH/Contents/MacOS/supacode" 2>&1 | grep -E "Authority=Developer ID Application|Timestamp=" - codesign -dv --verbose=4 "$SPARKLE/Updater.app/Contents/MacOS/Updater" 2>&1 | grep -E "Authority=Developer ID Application|Timestamp=" - codesign -dv --verbose=4 "$SPARKLE/XPCServices/Installer.xpc/Contents/MacOS/Installer" 2>&1 | grep -E "Authority=Developer ID Application|Timestamp=" - codesign -dv --verbose=4 "$SPARKLE/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" 2>&1 | grep -E "Authority=Developer ID Application|Timestamp=" - echo "Signature verified successfully" - - name: Store notarization credentials - run: | - echo "$APPLE_NOTARIZATION_KEY" > notarization_key.p8 - xcrun notarytool store-credentials "notarytool-profile" \ - --key notarization_key.p8 \ - --key-id "$APPLE_NOTARIZATION_KEY_ID" \ - --issuer "$APPLE_NOTARIZATION_ISSUER" - rm notarization_key.p8 - - name: Build DMG - run: | - APP_PATH="$(find build/export -name "supacode.app" -maxdepth 3 -print -quit)" - - mise exec -- create-dmg "$APP_PATH" build/ \ - --overwrite \ - --dmg-title="Supacode" \ - --identity="$DEVELOPER_ID_IDENTITY_SHA" - - DMG_OUTPUT=$(find build -name "*.dmg" -maxdepth 1 | head -1) - if [ "$DMG_OUTPUT" != "build/supacode.dmg" ]; then - mv "$DMG_OUTPUT" build/supacode.dmg - fi - - name: Notarize and staple - run: | - set -euo pipefail - APP_PATH="$(find build/export -name "supacode.app" -maxdepth 3 -print -quit)" - NOTARY_RESULT_PATH=build/notarytool-submit.json - NOTARY_LOG_PATH=build/notarytool-log.json - NOTARY_STATUS="" - NOTARY_ID="" - for attempt in 1 2 3; do - echo "Notarization attempt $attempt..." - submit_exit=0 - if ! xcrun notarytool submit build/supacode.dmg \ - --keychain-profile "notarytool-profile" \ - --wait \ - --output-format json > "$NOTARY_RESULT_PATH"; then - submit_exit=$? - fi - NOTARY_STATUS="$(jq -r '.status // empty' "$NOTARY_RESULT_PATH" 2>/dev/null || true)" - NOTARY_ID="$(jq -r '.id // empty' "$NOTARY_RESULT_PATH" 2>/dev/null || true)" - cat "$NOTARY_RESULT_PATH" || true - if [ "$submit_exit" -eq 0 ] && [ "$NOTARY_STATUS" = "Accepted" ]; then - break - fi - if [ -n "$NOTARY_ID" ]; then - xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" > "$NOTARY_LOG_PATH" || true - cat "$NOTARY_LOG_PATH" || true - fi - echo "Attempt $attempt failed, retrying in 30s..." - sleep 30 - done - if [ "$NOTARY_STATUS" != "Accepted" ]; then - echo "::error::Notarization failed with status '$NOTARY_STATUS'" - exit 1 - fi - xcrun stapler staple build/supacode.dmg - xcrun stapler staple "$APP_PATH" - ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" build/supacode.app.zip - VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PATH/Contents/Info.plist") - echo "VERSION=$VERSION" >> "$GITHUB_ENV" - - name: Fetch release notes - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release view "$TAG" --json body --jq '.body' > build/release-notes.md - - name: Generate appcast with history and deltas - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - MAX_DELTAS=10 - NOTES_FILE=build/release-notes.md - STAGING=$(mktemp -d) - ARCHIVE=build/supacode.app.zip - ARCHIVE_BASE=$(basename "$ARCHIVE") - ARCHIVE_BASE="${ARCHIVE_BASE%.zip}" - cp "$ARCHIVE" "$STAGING/" - cp "$NOTES_FILE" "$STAGING/$ARCHIVE_BASE.md" - - curl -fsSL "https://supacode.sh/download/latest/appcast.xml" -o "$STAGING/appcast.xml" || true - - RELEASE_REPO="supabitapp/supacode" - SEEN_VERSIONS="" - for i in $(seq 1 $MAX_DELTAS); do - PREV_TAG=$(gh release list --limit $i --exclude-drafts --exclude-pre-releases -R "$RELEASE_REPO" --json tagName --jq ".[$((i-1))].tagName" 2>/dev/null || true) - if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$TAG" ]; then - echo "Downloading $PREV_TAG for delta generation..." - gh release download "$PREV_TAG" -p "supacode.app.zip" -O "$STAGING/supacode-$PREV_TAG.app.zip" -R "$RELEASE_REPO" 2>/dev/null || true - if [ -f "$STAGING/supacode-$PREV_TAG.app.zip" ]; then - BUNDLE_VERSION=$(unzip -p "$STAGING/supacode-$PREV_TAG.app.zip" "supacode.app/Contents/Info.plist" 2>/dev/null | /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" /dev/stdin 2>/dev/null || echo "") - if [ -n "$BUNDLE_VERSION" ] && echo "$SEEN_VERSIONS" | grep -q "^$BUNDLE_VERSION$"; then - echo "Skipping $PREV_TAG (duplicate bundle version $BUNDLE_VERSION)" - rm -f "$STAGING/supacode-$PREV_TAG.app.zip" - else - [ -n "$BUNDLE_VERSION" ] && SEEN_VERSIONS="$SEEN_VERSIONS$BUNDLE_VERSION"$'\n' - gh release view "$PREV_TAG" -R "$RELEASE_REPO" --json body --jq '.body' > "$STAGING/supacode-$PREV_TAG.app.md" 2>/dev/null || true - fi - fi - fi - done - - printf "%s" "$SPARKLE_PRIVATE_KEY" | tr -d '\r\n\t ' | ./bins/generate_appcast --download-url-prefix "https://supacode.sh/download/$TAG/" --full-release-notes-url "https://github.com/supabitapp/supacode/releases" --embed-release-notes --maximum-versions $MAX_DELTAS --maximum-deltas $MAX_DELTAS --delta-compression lzma --ed-key-file - "$STAGING" - cp "$STAGING/appcast.xml" build/appcast.xml - find "$STAGING" -name "*.delta" -exec cp {} build/ \; 2>/dev/null || true - - uses: actions/upload-artifact@v6 - with: - name: build-artifacts - path: | - build/supacode.app.zip - build/supacode.dmg - build/appcast.xml - build/*.delta - - sentry-dsym: - runs-on: ubuntu-latest - needs: [check, build] - if: needs.check.outputs.should_skip != 'true' - steps: - - name: Install sentry-cli - run: curl -sL https://sentry.io/get-cli/ | sh - - uses: actions/download-artifact@v7 - with: - name: dsyms - path: dsyms - - name: Upload dSYMs to Sentry - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: supabit - SENTRY_PROJECT: supacode - run: sentry-cli debug-files upload --include-sources dsyms - - publish: - runs-on: ubuntu-latest - needs: [check, build] - if: needs.check.outputs.should_skip != 'true' - permissions: - contents: write - env: - RELEASE_REPO: supabitapp/supacode - steps: - - run: echo "TAG=${{ github.event.release.tag_name }}" >> "$GITHUB_ENV" - - uses: actions/download-artifact@v7 - with: - name: build-artifacts - path: build - - run: | - DELTA_FILES=$(find build -name "*.delta" -type f 2>/dev/null | tr '\n' ' ' || true) - gh release upload "$TAG" build/supacode.app.zip build/supacode.dmg build/appcast.xml $DELTA_FILES --clobber -R "$RELEASE_REPO" - env: - GH_TOKEN: ${{ github.token }} - - run: | - set -euo pipefail - curl -fsSL "https://supacode.sh/download/$TAG/supacode.app.zip" -o /dev/null - curl -fsSL "https://supacode.sh/download/$TAG/supacode.dmg" -o /dev/null diff --git a/.github/workflows/warm-cache.yml b/.github/workflows/warm-cache.yml deleted file mode 100644 index 374c37def..000000000 --- a/.github/workflows/warm-cache.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: warm-cache - -on: - push: - branches: - - main - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - warm-cache-debug: - runs-on: macos-26 - permissions: - contents: read - id-token: write - env: - MISE_HTTP_TIMEOUT: 120 - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@v6 - with: - submodules: recursive - - uses: ./.github/actions/setup-macos - - run: make warm-cache diff --git a/Makefile b/Makefile index 78c6c58be..3ab68ca04 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ TUIST_GENERATION_STAMP_DIR := $(CURRENT_MAKEFILE_DIR)/.build/.tuist-generated-st TUIST_INSTALL_STAMP := $(TUIST_GENERATION_STAMP_DIR)/.installed TUIST_DEVELOPMENT_GENERATION_STAMP := $(TUIST_GENERATION_STAMP_DIR)/development TUIST_SOURCE_GENERATION_STAMP := $(TUIST_GENERATION_STAMP_DIR)/none -TUIST_SOURCE_RELEASE_GENERATION_STAMP := $(TUIST_GENERATION_STAMP_DIR)/none-release +TUIST_RELEASE_GENERATION_STAMP := $(TUIST_GENERATION_STAMP_DIR)/development-release TUIST_GENERATION_INPUTS := Project.swift Workspace.swift Tuist.swift Tuist/Package.swift $(wildcard Tuist/Package.resolved) $(PROJECT_CONFIG_PATH) mise.toml scripts/build-ghostty.sh scripts/build-zmx.sh TUIST_GENERATE_CACHE_PROFILE ?= development TUIST_CACHE_CONFIGURATION ?= Debug @@ -58,7 +58,8 @@ $(TUIST_GENERATION_STAMP_DIR)/%: $(TUIST_GENERATION_INPUTS) $(TUIST_INSTALL_STAM mise exec -- tuist generate --no-open --cache-profile "$*" touch "$@" -$(TUIST_SOURCE_RELEASE_GENERATION_STAMP): $(TUIST_GENERATION_INPUTS) $(TUIST_INSTALL_STAMP) +# Consumes the warmed Release binary cache, so archive compiles only the app shell. +$(TUIST_RELEASE_GENERATION_STAMP): $(TUIST_GENERATION_INPUTS) $(TUIST_INSTALL_STAMP) mkdir -p "$(TUIST_GENERATION_STAMP_DIR)" find "$(TUIST_GENERATION_STAMP_DIR)" -mindepth 1 -maxdepth 1 ! -name '.installed' -delete rm -rf supacode.xcodeproj supacode.xcworkspace @@ -66,7 +67,7 @@ $(TUIST_SOURCE_RELEASE_GENERATION_STAMP): $(TUIST_GENERATION_INPUTS) $(TUIST_INS [ -e "$$path" ] || continue; \ rm -rf "$$path"; \ done - mise exec -- tuist generate --no-open --cache-profile none --configuration Release + mise exec -- tuist generate --no-open --cache-profile development --configuration Release touch "$@" build-ghostty-xcframework: # Build ghostty framework @@ -82,7 +83,7 @@ warm-cache: $(TUIST_INSTALL_STAMP) # Warm the full Tuist cacheable graph mise exec -- tuist cache warm --configuration $(TUIST_CACHE_CONFIGURATION) build-app: $(TUIST_DEVELOPMENT_GENERATION_STAMP) # Build the macOS app (Debug) - bash -o pipefail -c 'xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Debug build -skipMacroValidation 2>&1 | mise exec -- xcbeautify --disable-logging' + bash -o pipefail -c 'xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Debug build -skipMacroValidation $(XCODEBUILD_FLAGS) 2>&1 | mise exec -- xcbeautify --disable-logging' run-app: build-app # Build then launch (Debug) with log streaming @settings="$$(xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Debug -showBuildSettings -json 2>/dev/null)"; \ @@ -106,7 +107,7 @@ install-dev-build: build-app # install dev build to /Applications ditto "$$src" "$$dst"; \ echo "installed $$dst" -archive: $(TUIST_SOURCE_RELEASE_GENERATION_STAMP) # Archive Release build for distribution +archive: $(TUIST_RELEASE_GENERATION_STAMP) # Archive Release build for distribution mkdir -p build bash -o pipefail -c 'xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Release -destination "generic/platform=macOS" -archivePath build/supacode.xcarchive archive CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="$$APPLE_TEAM_ID" CODE_SIGN_IDENTITY="$$DEVELOPER_ID_IDENTITY_SHA" OTHER_CODE_SIGN_FLAGS="--timestamp" -skipMacroValidation $(XCODEBUILD_FLAGS) 2>&1 | mise exec -- xcbeautify --quiet --disable-logging' @@ -172,17 +173,6 @@ bump-version: # Bump app version (usage: make bump-version [VERSION=x.x.x] [BUIL git tag -s "v$$version" -m "v$$version"; \ echo "version bumped to $$version (build $$build), tagged v$$version" -bump-and-release: bump-version # Bump version and push tags to trigger release +# main.yml detects the tag at HEAD and cuts the release with auto-generated notes. +bump-and-release: bump-version # Bump version and push tags to trigger the release git push --follow-tags - @tag="$$(git describe --tags --abbrev=0)"; \ - repo="$$(gh repo view --json nameWithOwner -q .nameWithOwner)"; \ - prev="$$(gh release view --json tagName -q .tagName 2>/dev/null || echo '')"; \ - tmp="$$(mktemp)"; \ - if [ -n "$$prev" ]; then \ - gh api "repos/$$repo/releases/generate-notes" -f tag_name="$$tag" -f previous_tag_name="$$prev" --jq '.body' > "$$tmp"; \ - else \ - gh api "repos/$$repo/releases/generate-notes" -f tag_name="$$tag" --jq '.body' > "$$tmp"; \ - fi; \ - $${EDITOR:-vim} "$$tmp"; \ - gh release create "$$tag" --notes-file "$$tmp"; \ - rm -f "$$tmp" From a8cab19e0c3deb41a032f8e4baea9c57b334be53 Mon Sep 17 00:00:00 2001 From: Jason Brashear <115739170+webdevtodayjason@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:37:59 -0500 Subject: [PATCH 15/56] Fix "Dismiss All" notifications not clearing the popover (#385) (#409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Models/WorktreeTerminalState.swift | 29 +++++++++---------- .../WorktreeTerminalManagerTests.swift | 23 +++++++++++++++ 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 360b048ed..27b582244 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -906,17 +906,15 @@ final class WorktreeTerminalState { } func markAllNotificationsRead() { - let previousHasUnseen = hasUnseenNotification for index in notifications.indices { notifications[index].isRead = true } clearAllSurfaceUnseenFlags() emitAllTabProjections() - emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) + emitNotificationStateChanged() } func markNotificationsRead(forSurfaceID surfaceID: UUID) { - let previousHasUnseen = hasUnseenNotification for index in notifications.indices where notifications[index].surfaceID == surfaceID { notifications[index].isRead = true } @@ -924,12 +922,11 @@ final class WorktreeTerminalState { if let tabId = tabID(containing: surfaceID) { emitTabProjection(for: tabId) } - emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) + emitNotificationStateChanged() } /// Marks a single notification as read, leaving others untouched. func markNotificationRead(id: WorktreeTerminalNotification.ID) { - let previousHasUnseen = hasUnseenNotification guard let index = notifications.firstIndex(where: { $0.id == id }) else { return } guard !notifications[index].isRead else { return } let surfaceID = notifications[index].surfaceID @@ -938,11 +935,10 @@ final class WorktreeTerminalState { if let tabId = tabID(containing: surfaceID) { emitTabProjection(for: tabId) } - emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) + emitNotificationStateChanged() } func dismissNotification(_ notificationID: WorktreeTerminalNotification.ID) { - let previousHasUnseen = hasUnseenNotification let affectedSurface = notifications.first(where: { $0.id == notificationID })?.surfaceID notifications.removeAll { $0.id == notificationID } if let affectedSurface { @@ -951,15 +947,14 @@ final class WorktreeTerminalState { emitTabProjection(for: tabId) } } - emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) + emitNotificationStateChanged() } func dismissAllNotifications() { - let previousHasUnseen = hasUnseenNotification notifications.removeAll() clearAllSurfaceUnseenFlags() emitAllTabProjections() - emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) + emitNotificationStateChanged() } /// Recomputes the surface's unseen flag through the canonical predicate so a @@ -1803,7 +1798,6 @@ final class WorktreeTerminalState { let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) guard !(trimmedTitle.isEmpty && trimmedBody.isEmpty) else { return } if notificationsEnabled { - let previousHasUnseen = hasUnseenNotification let isRead = isSelected() && isFocusedSurface(surfaceID) notifications.insert( WorktreeTerminalNotification( @@ -1819,7 +1813,7 @@ final class WorktreeTerminalState { if let tabId = tabID(containing: surfaceID) { emitTabProjection(for: tabId) } - emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) + emitNotificationStateChanged() } onNotificationReceived?(surfaceID, trimmedTitle, trimmedBody) } @@ -1958,10 +1952,13 @@ final class WorktreeTerminalState { onFocusChanged?(surfaceID) } - private func emitNotificationIndicatorIfNeeded(previousHasUnseen: Bool) { - if previousHasUnseen != hasUnseenNotification { - onNotificationIndicatorChanged?() - } + /// `currentProjection()` already includes the full list and per-item `isRead`, + /// so the sidebar/popover must re-sync on every mutation, not just when + /// `hasUnseenNotification` flips. Gating here broke dismiss / mark-read of + /// already-read notifications (#385). Downstream emits self-dedupe, so keep + /// this ungated. + private func emitNotificationStateChanged() { + onNotificationIndicatorChanged?() } private func syncFocusIfNeeded() { diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 3f56b062b..114e09e86 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -518,6 +518,29 @@ struct WorktreeTerminalManagerTests { #expect(manager.hasUnseenNotifications(for: worktree.id) == false) } + /// Regression for #385: dismissing already-read notifications doesn't flip + /// `hasUnseenNotification` (it's already false), but the row projection must + /// still re-emit so the toolbar popover (which reads the mirrored + /// `notifications` array) clears. Previously gated on the flag flip, so the + /// popover kept showing dismissed notifications. + @Test func dismissAllReadNotificationsStillEmitsProjection() { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + state.setNotificationsForTesting([ + makeNotification(isRead: true), + makeNotification(isRead: true), + ]) + #expect(state.hasUnseenNotification == false) + + var emitCount = 0 + state.onNotificationIndicatorChanged = { emitCount += 1 } + state.dismissAllNotifications() + + #expect(state.notifications.isEmpty) + #expect(emitCount == 1) + } + // MARK: - Per-surface unseen flag @Test func setNotificationsForTestingHydratesPerSurfaceFlag() { From 88e5039831c174f865f7b1494448e0eca359d611 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Thu, 18 Jun 2026 23:57:24 +0200 Subject: [PATCH 16/56] Add remote SSH repositories and worktrees (#407) * 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. --- .gitignore | 3 + .../Models/SettingsRepositorySummary.swift | 24 +- .../Reducer/RepositorySettingsFeature.swift | 17 +- .../Reducer/SettingsFeature.swift | 9 +- SupacodeSettingsShared/App/AppShortcuts.swift | 12 +- .../BusinessLogic/RepositorySettingsKey.swift | 70 +- .../Clients/Shell/ShellClient+SSH.swift | 61 + .../Clients/Shell/ShellClient.swift | 15 + .../Models/GlobalSettings.swift | 8 + .../Models/RemoteHost.swift | 61 + .../Models/RemoteRepositoryConfig.swift | 48 + .../Support/SSHCommand.swift | 162 + docs/remote-ssh-setup.md | 261 + supacode/App/SidebarBottomCardView.swift | 27 +- supacode/App/WindowTitle.swift | 10 +- supacode/App/supacodeApp.swift | 19 +- .../Clients/Deeplink/DeeplinkClient.swift | 8 +- supacode/Clients/Git/GitClient.swift | 177 +- .../Repositories/GitClientDependency.swift | 130 +- supacode/Clients/Zmx/ZmxClient.swift | 50 + supacode/Commands/WorktreeCommands.swift | 10 +- supacode/Domain/PendingWorktree.swift | 4 +- supacode/Domain/Repository.swift | 101 +- supacode/Domain/RepositoryIdentity.swift | 197 + supacode/Domain/SidebarBranchNesting.swift | 4 +- supacode/Domain/Worktree.swift | 83 +- .../Features/App/Reducer/AppFeature.swift | 64 +- .../CommandPalette/CommandPaletteItem.swift | 12 +- .../Reducer/CommandPaletteFeature.swift | 50 +- .../Views/CommandPaletteOverlayView.swift | 10 +- .../SidebarPersistenceMigrator.swift | 32 +- .../BusinessLogic/SidebarStructure.swift | 75 +- .../WorktreeInfoWatcherManager.swift | 88 +- .../Models/ToolbarNotificationGroup.swift | 12 +- .../Reducer/RemoteConnectionFormFeature.swift | 135 + .../Reducer/RenameBranchFeature.swift | 4 + .../Reducer/RepositoriesFeature+Remote.swift | 604 +++ .../Reducer/RepositoriesFeature+Removal.swift | 6 +- .../Reducer/RepositoriesFeature+Sidebar.swift | 19 +- .../Reducer/RepositoriesFeature.swift | 4680 +++++++++-------- .../Reducer/SidebarItemFeature.swift | 12 +- .../Repositories/Views/EmptyStateView.swift | 5 + .../Views/RemoteConnectionFormView.swift | 69 + .../RemoteRepositoriesBetaCardView.swift | 82 + .../Views/RepoSectionHeaderView.swift | 20 +- .../Repositories/Views/SidebarItemView.swift | 43 +- .../Repositories/Views/SidebarItemsView.swift | 40 +- .../Repositories/Views/SidebarListView.swift | 71 +- .../Repositories/Views/SidebarView.swift | 21 +- .../Views/WorktreeDetailTitleView.swift | 65 +- .../Views/WorktreeDetailView.swift | 27 +- .../Settings/Views/SettingsView.swift | 99 +- .../BusinessLogic/BlockingScriptRunner.swift | 51 + .../WorktreeTerminalManager.swift | 16 +- .../Models/WorktreeTerminalState.swift | 100 +- supacodeTests/AgentPresenceFeatureTests.swift | 41 + .../AppFeatureArchivedSelectionTests.swift | 6 +- .../AppFeatureCommandPaletteTests.swift | 5 +- supacodeTests/AppFeatureDeeplinkTests.swift | 10 +- .../AppFeatureDefaultEditorTests.swift | 37 +- .../AppFeatureJumpToLatestUnreadTests.swift | 4 +- .../AppFeatureOpenWorktreeTests.swift | 4 +- .../AppFeatureSettingsChangedTests.swift | 2 +- .../AppFeatureSettingsSelectionTests.swift | 44 +- supacodeTests/BrandedIDTestSupport.swift | 23 + .../CommandPaletteFeatureTests.swift | 11 +- supacodeTests/GhosttySurfaceBridgeTests.swift | 1 + supacodeTests/GitClientRemoteSSHTests.swift | 290 + .../GitClientSupacodeLockTests.swift | 6 +- .../GitClientWorktreeDiscoveryTests.swift | 6 +- .../RemoteRepositorySidebarTests.swift | 1262 +++++ supacodeTests/RemoteSSHCommandTests.swift | 241 + supacodeTests/RenameBranchFeatureTests.swift | 1 + ...epositoriesFeatureCustomizationTests.swift | 10 +- ...RepositoriesFeatureRenameBranchTests.swift | 116 +- .../RepositoriesFeatureSidebarTests.swift | 126 +- supacodeTests/RepositoriesFeatureTests.swift | 164 +- .../RepositoriesSidebarTestHelpers.swift | 4 +- supacodeTests/RepositoryIdentityTests.swift | 128 + .../RepositorySettingsKeyTests.swift | 97 + supacodeTests/ResolvedRowDisplayTests.swift | 26 +- .../SelectedWorktreeSliceCacheTests.swift | 4 +- supacodeTests/SidebarBottomCardTests.swift | 23 + .../SidebarHighlightOrderingTests.swift | 2 +- supacodeTests/SidebarItemFeatureTests.swift | 2 +- .../SidebarPersistenceMigratorTests.swift | 56 +- supacodeTests/SidebarStateTests.swift | 4 +- supacodeTests/SidebarStructureTests.swift | 80 +- .../ToolbarNotificationGroupingTests.swift | 43 +- supacodeTests/WindowTitleTests.swift | 38 +- .../WorktreeCreationPromptParentTests.swift | 42 +- .../WorktreeCustomizationParentTests.swift | 18 +- supacodeTests/WorktreeEnvironmentTests.swift | 36 + .../WorktreeInfoWatcherManagerTests.swift | 113 +- ...erminalManagerLayoutPersistenceTests.swift | 56 +- .../WorktreeTerminalManagerReaperTests.swift | 2 +- .../WorktreeTerminalManagerTests.swift | 51 +- 97 files changed, 8342 insertions(+), 2976 deletions(-) create mode 100644 SupacodeSettingsShared/Clients/Shell/ShellClient+SSH.swift create mode 100644 SupacodeSettingsShared/Models/RemoteHost.swift create mode 100644 SupacodeSettingsShared/Models/RemoteRepositoryConfig.swift create mode 100644 SupacodeSettingsShared/Support/SSHCommand.swift create mode 100644 docs/remote-ssh-setup.md create mode 100644 supacode/Domain/RepositoryIdentity.swift create mode 100644 supacode/Features/Repositories/Reducer/RemoteConnectionFormFeature.swift create mode 100644 supacode/Features/Repositories/Reducer/RepositoriesFeature+Remote.swift create mode 100644 supacode/Features/Repositories/Views/RemoteConnectionFormView.swift create mode 100644 supacode/Features/Repositories/Views/RemoteRepositoriesBetaCardView.swift create mode 100644 supacodeTests/BrandedIDTestSupport.swift create mode 100644 supacodeTests/GitClientRemoteSSHTests.swift create mode 100644 supacodeTests/RemoteRepositorySidebarTests.swift create mode 100644 supacodeTests/RemoteSSHCommandTests.swift create mode 100644 supacodeTests/RepositoryIdentityTests.swift diff --git a/.gitignore b/.gitignore index 4a5148706..61a5a0cb7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ ## User settings xcuserdata/ +## Local build output (xcodebuild -derivedDataPath build). +build/ + ## Obj-C/Swift specific *.hmap diff --git a/SupacodeSettingsFeature/Models/SettingsRepositorySummary.swift b/SupacodeSettingsFeature/Models/SettingsRepositorySummary.swift index f488c8fe8..aa3e06952 100644 --- a/SupacodeSettingsFeature/Models/SettingsRepositorySummary.swift +++ b/SupacodeSettingsFeature/Models/SettingsRepositorySummary.swift @@ -1,17 +1,33 @@ import Foundation +import SupacodeSettingsShared public struct SettingsRepositorySummary: Equatable, Hashable, Sendable { public var id: String public var name: String public var isGitRepository: Bool + /// The SSH host the repository lives on, `nil` for local. Carried (rather than + /// a bare bool) so the per-repo settings key can brand remote repos by host. + public var host: RemoteHost? + /// The repository's real root URL, used to key its per-repo settings. For a + /// local repo this equals `URL(fileURLWithPath: id)`; for a remote repo `id` + /// is a `remote:` key (not a path), so the bare remote path must be passed in + /// explicitly so the settings key matches the worktree's `repositoryRootURL`. + public var rootURL: URL - public var rootURL: URL { - URL(fileURLWithPath: id).standardizedFileURL - } + /// Lives on an SSH host. Partitions the settings sidebar into Local / Remote. + public var isRemote: Bool { host != nil } - public init(id: String, name: String, isGitRepository: Bool = true) { + public init( + id: String, + name: String, + isGitRepository: Bool = true, + host: RemoteHost? = nil, + rootURL: URL? = nil + ) { self.id = id self.name = name self.isGitRepository = isGitRepository + self.host = host + self.rootURL = (rootURL ?? URL(fileURLWithPath: id)).standardizedFileURL } } diff --git a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift index 2ffcfa2e7..c4341c480 100644 --- a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift @@ -8,6 +8,7 @@ public struct RepositorySettingsFeature { @ObservableState public struct State: Equatable { public var rootURL: URL + public var host: RemoteHost? public var isGitRepository: Bool public var settings: RepositorySettings public var globalDefaultWorktreeBaseDirectoryPath: String? @@ -32,6 +33,7 @@ public struct RepositorySettingsFeature { public init( rootURL: URL, + host: RemoteHost? = nil, isGitRepository: Bool = true, settings: RepositorySettings, globalDefaultWorktreeBaseDirectoryPath: String? = nil, @@ -44,6 +46,7 @@ public struct RepositorySettingsFeature { isBranchDataLoaded: Bool = false ) { self.rootURL = rootURL + self.host = host self.isGitRepository = isGitRepository self.settings = settings self.globalDefaultWorktreeBaseDirectoryPath = globalDefaultWorktreeBaseDirectoryPath @@ -82,7 +85,7 @@ public struct RepositorySettingsFeature { @CasePathable public enum Delegate: Equatable { - case settingsChanged(URL) + case settingsChanged(URL, host: RemoteHost?) } @Dependency(RepositorySettingsGitClient.self) private var gitClient @@ -96,7 +99,7 @@ public struct RepositorySettingsFeature { case .task: let rootURL = state.rootURL let isGitRepository = state.isGitRepository - @Shared(.repositorySettings(rootURL)) var repositorySettings + @Shared(.repositorySettings(rootURL, host: state.host)) var repositorySettings @Shared(.settingsFile) var settingsFile let settings = repositorySettings let global = settingsFile.global @@ -175,9 +178,10 @@ public struct RepositorySettingsFeature { state.isBareRepository = isBareRepository guard updatedSettings != settings else { return .none } let rootURL = state.rootURL - @Shared(.repositorySettings(rootURL)) var repositorySettings + let host = state.host + @Shared(.repositorySettings(rootURL, host: host)) var repositorySettings $repositorySettings.withLock { $0 = updatedSettings } - return .send(.delegate(.settingsChanged(rootURL))) + return .send(.delegate(.settingsChanged(rootURL, host: host))) case .branchDataLoaded(let branches, let defaultBaseRef): state.defaultWorktreeBaseRef = defaultBaseRef @@ -244,13 +248,14 @@ public struct RepositorySettingsFeature { /// Persists the current settings and notifies the delegate. private func persistAndNotify(state: inout State) -> Effect { let rootURL = state.rootURL + let host = state.host var normalizedSettings = state.settings normalizedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( normalizedSettings.worktreeBaseDirectoryPath, repositoryRootURL: rootURL ) - @Shared(.repositorySettings(rootURL)) var repositorySettings + @Shared(.repositorySettings(rootURL, host: host)) var repositorySettings $repositorySettings.withLock { $0 = normalizedSettings } - return .send(.delegate(.settingsChanged(rootURL))) + return .send(.delegate(.settingsChanged(rootURL, host: host))) } } diff --git a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift index 2dcebacd0..ef180d23e 100644 --- a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift @@ -603,10 +603,15 @@ public struct SettingsFeature { state.repositorySettings = nil return } - if state.repositorySettings?.rootURL != summary.rootURL { - @Shared(.repositorySettings(summary.rootURL)) var repositorySettings + // Compare on host too: two remote hosts at the same path share a `rootURL` + // but are distinct repositories, so a path-only check would keep stale state. + if state.repositorySettings?.rootURL != summary.rootURL + || state.repositorySettings?.host != summary.host + { + @Shared(.repositorySettings(summary.rootURL, host: summary.host)) var repositorySettings state.repositorySettings = RepositorySettingsFeature.State( rootURL: summary.rootURL, + host: summary.host, isGitRepository: summary.isGitRepository, settings: repositorySettings ) diff --git a/SupacodeSettingsShared/App/AppShortcuts.swift b/SupacodeSettingsShared/App/AppShortcuts.swift index 0ed4ce5c3..57d9b7a30 100644 --- a/SupacodeSettingsShared/App/AppShortcuts.swift +++ b/SupacodeSettingsShared/App/AppShortcuts.swift @@ -12,7 +12,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep case selectNextWorktree, selectPreviousWorktree case worktreeHistoryBack, worktreeHistoryForward case selectWorktree(Int) - case openWorktree, revealInFinder, openRepository, openPullRequest, copyPath + case openWorktree, revealInFinder, openRepository, addRemoteRepository, openPullRequest, copyPath case runScript, stopRunScript case jumpToLatestUnread @@ -55,6 +55,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep case .openWorktree: "openWorktree" case .revealInFinder: "revealInFinder" case .openRepository: "openRepository" + case .addRemoteRepository: "addRemoteRepository" case .openPullRequest: "openPullRequest" case .copyPath: "copyPath" case .runScript: "runScript" @@ -84,6 +85,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep "openFinder": .openWorktree, "revealInFinder": .revealInFinder, "openRepository": .openRepository, + "addRemoteRepository": .addRemoteRepository, "openPullRequest": .openPullRequest, "copyPath": .copyPath, "runScript": .runScript, @@ -125,6 +127,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep case .openWorktree: "Open Worktree" case .revealInFinder: "Reveal in Finder" case .openRepository: "Open Repository or Folder" + case .addRemoteRepository: "Add Remote Repository or Folder" case .openPullRequest: "Open Pull Request" case .copyPath: "Copy Path" case .runScript: "Run Script" @@ -340,6 +343,9 @@ public enum AppShortcuts { public static let openWorktree = AppShortcut(id: .openWorktree, key: "o", modifiers: .command) public static let revealInFinder = AppShortcut(id: .revealInFinder, key: "r", modifiers: [.command, .option]) public static let openRepository = AppShortcut(id: .openRepository, key: "o", modifiers: [.command, .shift]) + public static let addRemoteRepository = AppShortcut( + id: .addRemoteRepository, key: "k", modifiers: [.command, .shift] + ) public static let openPullRequest = AppShortcut(id: .openPullRequest, key: "g", modifiers: [.command, .control]) public static let copyPath = AppShortcut(id: .copyPath, key: "c", modifiers: [.command, .shift]) public static let runScript = AppShortcut(id: .runScript, key: "r", modifiers: .command) @@ -393,8 +399,8 @@ public enum AppShortcuts { AppShortcutGroup( category: .actions, shortcuts: [ - openWorktree, revealInFinder, openRepository, openPullRequest, copyPath, runScript, stopRunScript, - jumpToLatestUnread, + openWorktree, revealInFinder, openRepository, addRemoteRepository, openPullRequest, + copyPath, runScript, stopRunScript, jumpToLatestUnread, ] ), ] diff --git a/SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift b/SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift index 6fadb94e2..5d0412301 100644 --- a/SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift +++ b/SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift @@ -13,10 +13,19 @@ public nonisolated struct RepositorySettingsKeyID: Hashable, Sendable { public nonisolated struct RepositorySettingsKey: SharedKey { public let repositoryID: String public let rootURL: URL + public let host: RemoteHost? - public init(rootURL: URL) { + public init(rootURL: URL, host: RemoteHost? = nil) { self.rootURL = rootURL.standardizedFileURL - repositoryID = self.rootURL.path(percentEncoded: false) + self.host = host + if let host { + // Brand remote keys with the host (matching `RepositoryLocation.id`) so + // two hosts at the same path can't share settings, and so a local repo + // at that path keeps its own bare-path key. + repositoryID = "remote://" + host.authority + self.rootURL.path(percentEncoded: false) + } else { + repositoryID = self.rootURL.path(percentEncoded: false) + } } public var id: RepositorySettingsKeyID { @@ -27,18 +36,22 @@ public nonisolated struct RepositorySettingsKey: SharedKey { context: LoadContext, continuation: LoadContinuation ) { - @Dependency(\.repositoryLocalSettingsStorage) var repositoryLocalSettingsStorage - let repositorySettingsURL = SupacodePaths.repositorySettingsURL(for: rootURL) - if let localData = try? repositoryLocalSettingsStorage.load(repositorySettingsURL) { - let decoder = JSONDecoder() - if let settings = try? decoder.decode(RepositorySettings.self, from: localData) { - continuation.resume(returning: settings) - return + // Remote repos never own a local `supacode.json`; the synthetic `rootURL` + // points at the remote path, which must not be read off the local disk. + if host == nil { + @Dependency(\.repositoryLocalSettingsStorage) var repositoryLocalSettingsStorage + let repositorySettingsURL = SupacodePaths.repositorySettingsURL(for: rootURL) + if let localData = try? repositoryLocalSettingsStorage.load(repositorySettingsURL) { + let decoder = JSONDecoder() + if let settings = try? decoder.decode(RepositorySettings.self, from: localData) { + continuation.resume(returning: settings) + return + } + let path = repositorySettingsURL.path(percentEncoded: false) + SupaLogger("Settings").warning( + "Unable to decode repository settings at \(path); falling back to global settings." + ) } - let path = repositorySettingsURL.path(percentEncoded: false) - SupaLogger("Settings").warning( - "Unable to decode repository settings at \(path); falling back to global settings." - ) } @Shared(.settingsFile) var settingsFile: SettingsFile @@ -65,19 +78,22 @@ public nonisolated struct RepositorySettingsKey: SharedKey { context _: SaveContext, continuation: SaveContinuation ) { - @Dependency(\.repositoryLocalSettingsStorage) var repositoryLocalSettingsStorage - let repositorySettingsURL = SupacodePaths.repositorySettingsURL(for: rootURL) - if (try? repositoryLocalSettingsStorage.load(repositorySettingsURL)) != nil { - do { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(value) - try repositoryLocalSettingsStorage.save(data, repositorySettingsURL) - continuation.resume() - } catch { - continuation.resume(throwing: error) + // Mirror `load`: only a local repo may persist to an on-disk `supacode.json`. + if host == nil { + @Dependency(\.repositoryLocalSettingsStorage) var repositoryLocalSettingsStorage + let repositorySettingsURL = SupacodePaths.repositorySettingsURL(for: rootURL) + if (try? repositoryLocalSettingsStorage.load(repositorySettingsURL)) != nil { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(value) + try repositoryLocalSettingsStorage.save(data, repositorySettingsURL) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + return } - return } @Shared(.settingsFile) var settingsFile: SettingsFile @@ -88,7 +104,7 @@ public nonisolated struct RepositorySettingsKey: SharedKey { } } nonisolated extension SharedReaderKey where Self == RepositorySettingsKey.Default { - public static func repositorySettings(_ rootURL: URL) -> Self { - Self[RepositorySettingsKey(rootURL: rootURL), default: .default] + public static func repositorySettings(_ rootURL: URL, host: RemoteHost? = nil) -> Self { + Self[RepositorySettingsKey(rootURL: rootURL, host: host), default: .default] } } diff --git a/SupacodeSettingsShared/Clients/Shell/ShellClient+SSH.swift b/SupacodeSettingsShared/Clients/Shell/ShellClient+SSH.swift new file mode 100644 index 000000000..4ce31352e --- /dev/null +++ b/SupacodeSettingsShared/Clients/Shell/ShellClient+SSH.swift @@ -0,0 +1,61 @@ +import Foundation + +extension ShellClient { + /// Host-aware transport: a `ShellClient` that runs every command on `host` + /// over SSH instead of locally, by rewriting each call into an `ssh + /// ` invocation (the working directory becomes a remote `cd`) + /// and delegating to `base` (defaults to `.live`; tests inject a recorder). + /// This is the single chokepoint that makes the rest of the stack remote. ssh + /// already runs the remote command through the user's login shell, so the + /// `runLogin*` entries must not re-wrap it and route to the plain `base.run*`. + /// `extraOptions` injects per-call `ssh -o` flags (e.g. the non-interactive + /// background-probe profile) on top of the shared multiplexing options. + public static func ssh( + host: RemoteHost, + base: ShellClient = .live, + extraOptions: [String] = [] + ) -> ShellClient { + ShellClient( + run: { executableURL, arguments, currentDirectoryURL in + let invocation = SSHCommand.invocation( + host: host, + executable: executableURL.path(percentEncoded: false), + arguments: arguments, + workingDirectory: currentDirectoryURL, + extraOptions: extraOptions + ) + return try await base.run(invocation.executableURL, invocation.arguments, nil) + }, + runLoginImpl: { executableURL, arguments, currentDirectoryURL, _ in + let invocation = SSHCommand.invocation( + host: host, + executable: executableURL.path(percentEncoded: false), + arguments: arguments, + workingDirectory: currentDirectoryURL, + extraOptions: extraOptions + ) + return try await base.run(invocation.executableURL, invocation.arguments, nil) + }, + runStream: { executableURL, arguments, currentDirectoryURL in + let invocation = SSHCommand.invocation( + host: host, + executable: executableURL.path(percentEncoded: false), + arguments: arguments, + workingDirectory: currentDirectoryURL, + extraOptions: extraOptions + ) + return base.runStream(invocation.executableURL, invocation.arguments, nil) + }, + runLoginStreamImpl: { executableURL, arguments, currentDirectoryURL, _ in + let invocation = SSHCommand.invocation( + host: host, + executable: executableURL.path(percentEncoded: false), + arguments: arguments, + workingDirectory: currentDirectoryURL, + extraOptions: extraOptions + ) + return base.runStream(invocation.executableURL, invocation.arguments, nil) + } + ) + } +} diff --git a/SupacodeSettingsShared/Clients/Shell/ShellClient.swift b/SupacodeSettingsShared/Clients/Shell/ShellClient.swift index 8a3cbcda6..afdfcc87c 100644 --- a/SupacodeSettingsShared/Clients/Shell/ShellClient.swift +++ b/SupacodeSettingsShared/Clients/Shell/ShellClient.swift @@ -184,6 +184,21 @@ nonisolated private func runProcessStream( let command = ([executableURL.path(percentEncoded: false)] + arguments).joined(separator: " ") do { try process.run() + // Terminate the child only when the consuming task is cancelled (e.g. a + // remote load probe timing out); on normal completion the process already + // exited, so signalling its pid would risk a reused pid. Without this the + // `waitUntilExit()` below blocks its thread forever on a stalled ssh + // connection and no timeout can fire. SIGTERM first, then SIGKILL after a + // short grace so an ssh that ignores SIGTERM can't keep the task hung. + let pid = process.processIdentifier + continuation.onTermination = { @Sendable termination in + guard case .cancelled = termination, pid > 0 else { return } + kill(pid, SIGTERM) + Task.detached { + try? await Task.sleep(for: .seconds(2)) + if kill(pid, 0) == 0 { kill(pid, SIGKILL) } + } + } let stdoutTask = Task.detached { for await line in lineStream(from: outputHandle) { await outputAccumulator.append(line, source: .stdout) diff --git a/SupacodeSettingsShared/Models/GlobalSettings.swift b/SupacodeSettingsShared/Models/GlobalSettings.swift index f2ad322e2..42d4258cc 100644 --- a/SupacodeSettingsShared/Models/GlobalSettings.swift +++ b/SupacodeSettingsShared/Models/GlobalSettings.swift @@ -54,6 +54,9 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { public var shortcutOverrides: [AppShortcutID: AppShortcutOverride] /// Scripts shared across every repository. Always `.custom` kind. public var globalScripts: [ScriptDefinition] + /// User-configured remote repositories reachable over SSH. Materialized at + /// load into folder-kind repositories whose terminals run on the remote host. + public var remoteRepositories: [RemoteRepositoryConfig] public var richAgentNotificationsEnabled: Bool public var agentPresenceBadgesEnabled: Bool /// When true, an agent integration that reports `.outdated` at launch / @@ -94,6 +97,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { autoDeleteArchivedWorktreesAfterDays: nil, shortcutOverrides: [:], globalScripts: [], + remoteRepositories: [], richAgentNotificationsEnabled: true, agentPresenceBadgesEnabled: true, autoUpdateAgentIntegrationsEnabled: true, @@ -128,6 +132,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? = nil, shortcutOverrides: [AppShortcutID: AppShortcutOverride] = [:], globalScripts: [ScriptDefinition] = [], + remoteRepositories: [RemoteRepositoryConfig] = [], richAgentNotificationsEnabled: Bool = true, agentPresenceBadgesEnabled: Bool = true, autoUpdateAgentIntegrationsEnabled: Bool = true, @@ -160,6 +165,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.autoDeleteArchivedWorktreesAfterDays = autoDeleteArchivedWorktreesAfterDays self.shortcutOverrides = shortcutOverrides self.globalScripts = globalScripts + self.remoteRepositories = remoteRepositories self.richAgentNotificationsEnabled = richAgentNotificationsEnabled self.agentPresenceBadgesEnabled = agentPresenceBadgesEnabled self.autoUpdateAgentIntegrationsEnabled = autoUpdateAgentIntegrationsEnabled @@ -285,6 +291,8 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { if script.name.isEmpty { script.name = ScriptKind.custom.defaultName } return script } + // Lossy: a malformed entry is dropped, a missing key collapses to `[]`. + remoteRepositories = container.decodeLossyArrayIfPresent(forKey: .remoteRepositories) ?? [] richAgentNotificationsEnabled = try container.decodeIfPresent(Bool.self, forKey: .richAgentNotificationsEnabled) ?? Self.default.richAgentNotificationsEnabled diff --git a/SupacodeSettingsShared/Models/RemoteHost.swift b/SupacodeSettingsShared/Models/RemoteHost.swift new file mode 100644 index 000000000..bafcda169 --- /dev/null +++ b/SupacodeSettingsShared/Models/RemoteHost.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Describes an SSH destination a worktree can live on. A `nil` `RemoteHost` +/// everywhere means "local": the unchanged Process-on-this-machine path. +/// +/// `alias` is whatever `ssh` itself accepts as a host: a `~/.ssh/config` alias +/// or a bare hostname. `username` / `port` are optional overrides for callers +/// that don't want to encode them in ssh config. `worktreeBasePath` is the +/// remote directory new worktrees are created under (expanded by the remote +/// shell, so a leading `~` is fine); `nil` lets the caller fall back to a +/// remote default. +public nonisolated struct RemoteHost: Codable, Hashable, Sendable { + public var alias: String + public var username: String? + public var port: Int? + public var worktreeBasePath: String? + + public init( + alias: String, + username: String? = nil, + port: Int? = nil, + worktreeBasePath: String? = nil + ) { + self.alias = alias + self.username = username + self.port = port + self.worktreeBasePath = worktreeBasePath + } + + /// The `user@host` (or bare `host`) token passed to `ssh`. + public var sshDestination: String { + if let username, !username.isEmpty { + return "\(username)@\(alias)" + } + return alias + } + + /// Friendly `[user@]host[:port]` for display: username only when the user set + /// it, port only when non-default (not 22). The id-bearing `authority` always + /// includes the port; this is the human-facing variant. + public var displayAuthority: String { + guard let port, port != 22 else { return sshDestination } + return "\(sshDestination):\(port)" + } + + /// `[user@]host[:port]` token used to brand remote ids and settings keys. + /// Always folds in the port (unlike `displayAuthority`), so two hosts that + /// differ only by port get distinct ids. Always shell/url safe. + public var authority: String { + guard let port else { return sshDestination } + return "\(sshDestination):\(port)" + } + + /// Extra `ssh` option arguments derived from the host (currently just the + /// port). Always shell-safe tokens, so callers can splice them into a + /// command line without quoting. + public var sshOptionArguments: [String] { + guard let port else { return [] } + return ["-p", String(port)] + } +} diff --git a/SupacodeSettingsShared/Models/RemoteRepositoryConfig.swift b/SupacodeSettingsShared/Models/RemoteRepositoryConfig.swift new file mode 100644 index 000000000..07bc5afdc --- /dev/null +++ b/SupacodeSettingsShared/Models/RemoteRepositoryConfig.swift @@ -0,0 +1,48 @@ +import Foundation + +/// A user-configured remote repository/folder reachable over SSH. Persisted in +/// `GlobalSettings.remoteRepositories` (mirrors `globalScripts`); each entry is +/// materialized at load time into a folder-kind `Repository` whose synthetic +/// worktree carries `host`, so its terminal launches via +/// `ssh -tt zmx attach …` (see Phase A). +/// +/// `remotePath` is an absolute path on the remote host, resolved (and any +/// leading `~` expanded) over ssh at creation time. `displayName` is the +/// sidebar title. +public nonisolated struct RemoteRepositoryConfig: Codable, Equatable, Sendable, Identifiable { + public var id: UUID + public var host: RemoteHost + public var remotePath: String + public var displayName: String + + public init( + id: UUID = UUID(), + host: RemoteHost, + remotePath: String, + displayName: String + ) { + self.id = id + self.host = host + self.remotePath = remotePath + self.displayName = displayName + } + + /// Trailing-slash-trimmed remote path. Kept stable so the derived + /// repository / worktree id doesn't churn on a cosmetic edit. + public var normalizedRemotePath: String { + var trimmed = Substring(remotePath.trimmingCharacters(in: .whitespaces)) + while trimmed.count > 1, trimmed.hasSuffix("/") { + trimmed = trimmed.dropLast() + } + return String(trimmed) + } + + /// Sidebar title, falling back to the remote path's last component when the + /// user left the name blank. + public var resolvedDisplayName: String { + let trimmed = displayName.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + let leaf = normalizedRemotePath.split(separator: "/").last.map(String.init) + return leaf?.isEmpty == false ? leaf! : host.alias + } +} diff --git a/SupacodeSettingsShared/Support/SSHCommand.swift b/SupacodeSettingsShared/Support/SSHCommand.swift new file mode 100644 index 000000000..a8a07e793 --- /dev/null +++ b/SupacodeSettingsShared/Support/SSHCommand.swift @@ -0,0 +1,162 @@ +import Foundation + +/// Pure, stateless builders for the `ssh` command lines Supacode issues against +/// a `RemoteHost`. Two consumers, two shapes: +/// +/// - `invocation(...)` returns an argv for `Process` / `ShellClient`: ssh +/// receives the remote command as a single argument, so only the *remote* +/// shell re-parses it (one quoting level, applied in `remoteCommand`). +/// - `commandLine(...)` returns a single string for a parent `/bin/sh -c` +/// (Ghostty's surface command), so the remote command must additionally be +/// quoted for the *local* shell (two quoting levels). +/// +/// Every invocation shares `controlOptions` so N git calls plus the terminal +/// reuse one multiplexed SSH connection: one auth / FIDO touch, and no +/// per-call TCP+handshake round trip that would otherwise make a many-worktree +/// sidebar crawl. +public nonisolated enum SSHCommand { + public static let sshExecutablePath = "/usr/bin/ssh" + + /// `%C` is ssh's hash of (local host, remote host, port, user): stable per + /// connection and short, keeping the control socket well under the + /// `sockaddr_un.sun_path` limit. ssh expands both `~` and `%C` itself. + public static let defaultControlPath = "~/.ssh/supacode-%C" + + /// SSH connection-multiplexing options. `auto` opens a master if none exists + /// and reuses it otherwise; `ControlPersist` keeps it warm briefly after the + /// last client so a burst of git calls shares one connection. + public static func controlOptions(controlPath: String = defaultControlPath) -> [String] { + [ + "-o", "ControlMaster=auto", + "-o", "ControlPath=\(controlPath)", + "-o", "ControlPersist=10m", + ] + } + + /// Options for a non-interactive background probe (e.g. resolving a remote + /// repository at launch). `BatchMode` so it fails fast instead of blocking on + /// a password / host-key prompt; `ConnectTimeout` bounds the TCP+handshake; + /// `ServerAlive*` aborts a connection that stalls mid-command (~10s). A live + /// ControlMaster (an open terminal) bypasses auth, so the common case is fast. + public static let backgroundProbeOptions: [String] = [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=10", + "-o", "ServerAliveInterval=5", + "-o", "ServerAliveCountMax=2", + ] + + /// POSIX single-quote a token so a parent shell passes it through literally. + public static func shellQuote(_ value: String) -> String { + "'" + value.replacing("'", with: "'\\''") + "'" + } + + /// The command string the *remote* shell runs for a local + /// `(executable, arguments, workingDirectory)` invocation. A working + /// directory becomes `cd -- && exec ...` so the remote process starts + /// in the worktree and replaces the shell (signals / exit status map + /// straight through). + public static func remoteCommand( + executable: String, + arguments: [String], + workingDirectory: URL? + ) -> String { + let invocation = ([executable] + arguments).map(shellQuote).joined(separator: " ") + guard let workingDirectory else { + return invocation + } + let directory = shellQuote(workingDirectory.path(percentEncoded: false)) + return "cd -- \(directory) && exec \(invocation)" + } + + /// Wrap a remote command so it runs under a **login** shell. ssh's default + /// `$SHELL -c ` is non-interactive *and* non-login, so on macOS it only + /// inherits `~/.zshenv`'s bare PATH (`/usr/bin:/bin:/usr/sbin:/sbin`), so + /// Homebrew's `/opt/homebrew/bin` (where remote `zmx` / `git` / the `wt` shim + /// live) is NOT on it and the remote command fails with `command not found`. + /// A login shell reads `/etc/zprofile` (path_helper) + `~/.zprofile` + /// (`brew shellenv`), restoring the full PATH. `$SHELL` is expanded by ssh's + /// own outer shell; `exec` replaces it so signals / exit status pass through. + public static func loginShellWrapped(_ remoteScript: String) -> String { + "exec \"$SHELL\" -l -c " + shellQuote(remoteScript) + } + + /// Login-shell-wrapped remote command that also forwards positional arguments + /// (`$0`, `$1`, …) to the `-c` script, so an arbitrary payload (e.g. a user + /// script) rides as `$1` instead of being concatenated into the script text. + /// `exec "$SHELL" -l -c '