native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add coding agent hook system for Claude Code and Codex (#221)

Introduce Unix domain socket server for receiving agent hook messages,
settings file installers for managing hook configuration, and UI for
toggling hook installation per agent. Includes busy state tracking on
terminal surfaces, notification forwarding, and deduplication logic.

authored by

Stefano Bertagno and committed by
GitHub
61356be1 301cf398

+3019 -81
+1 -1
Makefile
··· 70 70 bash -o pipefail -c 'xcodebuild -exportArchive -archivePath build/supacode.xcarchive -exportPath build/export -exportOptionsPlist build/ExportOptions.plist 2>&1 | mise exec -- xcsift -qw --format toon' 71 71 72 72 test: build-ghostty-xcframework 73 - xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -clonedSourcePackagesDirPath "$(SPM_CACHE_DIR)" 2>&1 73 + xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -parallel-testing-enabled NO -clonedSourcePackagesDirPath "$(SPM_CACHE_DIR)" 2>&1 74 74 75 75 format: # Format code with swift-format (local only) 76 76 swift-format -p --in-place --recursive --configuration ./.swift-format.json supacode supacodeTests
+16
supacode/Assets.xcassets/claude-code-mark.imageset/Contents.json
··· 1 + { 2 + "images" : [ 3 + { 4 + "filename" : "claude-code-mark.svg", 5 + "idiom" : "universal" 6 + } 7 + ], 8 + "info" : { 9 + "author" : "xcode", 10 + "version" : 1 11 + }, 12 + "properties" : { 13 + "preserves-vector-representation" : true, 14 + "template-rendering-intent" : "original" 15 + } 16 + }
+1
supacode/Assets.xcassets/claude-code-mark.imageset/claude-code-mark.svg
··· 1 + <svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
+16
supacode/Assets.xcassets/codex-mark.imageset/Contents.json
··· 1 + { 2 + "images" : [ 3 + { 4 + "filename" : "codex-mark.svg", 5 + "idiom" : "universal" 6 + } 7 + ], 8 + "info" : { 9 + "author" : "xcode", 10 + "version" : 1 11 + }, 12 + "properties" : { 13 + "preserves-vector-representation" : true, 14 + "template-rendering-intent" : "original" 15 + } 16 + }
+10
supacode/Assets.xcassets/codex-mark.imageset/codex-mark.svg
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!-- Generated by Pixelmator Pro 4.0 --> 3 + <svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 4 + <linearGradient id="linearGradient1" x1="11.999784" y1="-0.000156" x2="11.999784" y2="23.999904" gradientUnits="userSpaceOnUse"> 5 + <stop offset="1e-05" stop-color="#b1a7ff" stop-opacity="1"/> 6 + <stop offset="0.5" stop-color="#7a9dff" stop-opacity="1"/> 7 + <stop offset="1" stop-color="#3941ff" stop-opacity="1"/> 8 + </linearGradient> 9 + <path id="Path" fill="url(#linearGradient1)" stroke="none" d="M 8.085108 0.458511 C 9.048526 0.062128 10.097364 -0.081083 11.131782 0.042511 C 12.465118 0.195843 13.653121 0.762512 14.695791 1.742514 C 14.709124 1.755848 14.72779 1.765181 14.745124 1.770514 C 14.763955 1.775146 14.783628 1.775146 14.802458 1.770514 C 16.157993 1.420458 17.592882 1.550003 18.863802 2.137184 L 18.926468 2.166515 L 19.081137 2.242516 C 20.411499 2.916796 21.449179 4.054541 21.998476 5.441191 C 22.277143 6.121193 22.41581 6.829193 22.418476 7.567862 C 22.438519 8.117216 22.37833 8.666553 22.239811 9.198534 C 22.225927 9.253122 22.241032 9.311017 22.279808 9.351868 C 23.071814 10.161203 23.597147 11.125205 23.857147 12.245208 C 24.242479 14.145213 23.847813 15.85855 22.674479 17.383888 L 22.493145 17.605221 C 21.716038 18.494761 20.696058 19.137981 19.55847 19.455891 C 19.508398 19.470568 19.468302 19.50819 19.450468 19.557224 C 19.195801 20.291895 18.939802 20.92123 18.4638 21.549232 C 17.263798 23.131903 15.501126 24.010571 13.515788 23.999905 C 11.933117 23.991903 10.530447 23.413235 9.306443 22.263899 C 9.26894 22.228809 9.215469 22.216589 9.166444 22.231899 C 8.64911 22.398567 8.126441 22.422565 7.561106 22.415901 C 6.660661 22.40867 5.773724 22.196171 4.967766 21.794567 C 4.123537 21.376217 3.388511 20.766741 2.821095 20.014561 C 2.618428 19.745228 2.417094 19.491892 2.269093 19.191891 C 2.067047 18.780224 1.901954 18.351427 1.775758 17.910557 C 1.50936 16.907387 1.502936 15.852887 1.757092 14.846548 C 1.76545 14.822602 1.768187 14.797053 1.765092 14.771881 C 1.760527 14.747143 1.747862 14.724627 1.729092 14.707881 C 1.113182 14.084464 0.642432 13.332812 0.350422 12.506542 C 0.156222 11.997587 0.043286 11.461254 0.015754 10.917204 C -0.032685 10.200644 0.030741 9.480911 0.203755 8.783866 C 0.653089 7.301195 1.513091 6.137194 2.781094 5.29319 C 3.063761 5.10519 3.331762 4.958523 3.582429 4.853189 C 3.869097 4.734524 4.155765 4.634521 4.443765 4.550522 C 4.485569 4.537554 4.518105 4.504519 4.530432 4.462523 C 4.749117 3.677242 5.125179 2.944624 5.635768 2.309183 C 6.279415 1.491859 7.123051 0.854425 8.085108 0.458511 Z M 12.727785 14.545215 C 12.277901 14.570453 11.926023 14.942623 11.926023 15.393216 C 11.926023 15.843809 12.277901 16.215981 12.727785 16.241219 L 17.575796 16.241219 C 17.890108 16.25885 18.188389 16.101189 18.350857 15.831549 C 18.513323 15.561909 18.513323 15.224524 18.350857 14.954884 C 18.188389 14.685242 17.890108 14.527581 17.575796 14.545215 L 12.727785 14.545215 Z M 7.282439 8.306531 C 7.042571 7.915852 6.535948 7.78603 6.137755 8.013209 C 5.739562 8.240388 5.593514 8.742574 5.807768 9.147867 L 7.503773 12.113208 L 5.815769 14.961214 C 5.576813 15.364383 5.709934 15.884929 6.113102 16.123884 C 6.516272 16.362841 7.036816 16.229719 7.275772 15.82655 L 9.214444 12.553208 C 9.370052 12.290686 9.372598 11.964796 9.221111 11.699873 L 7.282439 8.306531 Z"/> 10 + </svg>
+44
supacode/Clients/CodingAgents/ClaudeSettingsClient.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + 4 + struct ClaudeSettingsClient: Sendable { 5 + var checkInstalled: @Sendable (Bool) async -> Bool 6 + var installProgress: @Sendable () async throws -> Void 7 + var installNotifications: @Sendable () async throws -> Void 8 + var uninstallProgress: @Sendable () async throws -> Void 9 + var uninstallNotifications: @Sendable () async throws -> Void 10 + } 11 + 12 + extension ClaudeSettingsClient: DependencyKey { 13 + static let liveValue = Self( 14 + checkInstalled: { progress in 15 + ClaudeSettingsInstaller().isInstalled(progress: progress) 16 + }, 17 + installProgress: { 18 + try ClaudeSettingsInstaller().installProgressHooks() 19 + }, 20 + installNotifications: { 21 + try ClaudeSettingsInstaller().installNotificationHooks() 22 + }, 23 + uninstallProgress: { 24 + try ClaudeSettingsInstaller().uninstallProgressHooks() 25 + }, 26 + uninstallNotifications: { 27 + try ClaudeSettingsInstaller().uninstallNotificationHooks() 28 + } 29 + ) 30 + static let testValue = Self( 31 + checkInstalled: { _ in false }, 32 + installProgress: {}, 33 + installNotifications: {}, 34 + uninstallProgress: {}, 35 + uninstallNotifications: {} 36 + ) 37 + } 38 + 39 + extension DependencyValues { 40 + var claudeSettingsClient: ClaudeSettingsClient { 41 + get { self[ClaudeSettingsClient.self] } 42 + set { self[ClaudeSettingsClient.self] = newValue } 43 + } 44 + }
+44
supacode/Clients/CodingAgents/CodexSettingsClient.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + 4 + struct CodexSettingsClient: Sendable { 5 + var checkInstalled: @Sendable (Bool) async -> Bool 6 + var installProgress: @Sendable () async throws -> Void 7 + var installNotifications: @Sendable () async throws -> Void 8 + var uninstallProgress: @Sendable () async throws -> Void 9 + var uninstallNotifications: @Sendable () async throws -> Void 10 + } 11 + 12 + extension CodexSettingsClient: DependencyKey { 13 + static let liveValue = Self( 14 + checkInstalled: { progress in 15 + CodexSettingsInstaller().isInstalled(progress: progress) 16 + }, 17 + installProgress: { 18 + try await CodexSettingsInstaller().installProgressHooks() 19 + }, 20 + installNotifications: { 21 + try await CodexSettingsInstaller().installNotificationHooks() 22 + }, 23 + uninstallProgress: { 24 + try CodexSettingsInstaller().uninstallProgressHooks() 25 + }, 26 + uninstallNotifications: { 27 + try CodexSettingsInstaller().uninstallNotificationHooks() 28 + } 29 + ) 30 + static let testValue = Self( 31 + checkInstalled: { _ in false }, 32 + installProgress: {}, 33 + installNotifications: {}, 34 + uninstallProgress: {}, 35 + uninstallNotifications: {} 36 + ) 37 + } 38 + 39 + extension DependencyValues { 40 + var codexSettingsClient: CodexSettingsClient { 41 + get { self[CodexSettingsClient.self] } 42 + set { self[CodexSettingsClient.self] = newValue } 43 + } 44 + }
+1 -1
supacode/Features/App/Reducer/AppFeature.swift
··· 254 254 repoSettingsState.globalPullRequestMergeStrategy = 255 255 state.settings.pullRequestMergeStrategy 256 256 state.settings.repositorySettings = repoSettingsState 257 - case .general, .notifications, .worktree, .shortcuts, .updates, .github: 257 + case .general, .notifications, .worktree, .codingAgents, .shortcuts, .updates, .github: 258 258 state.settings.repositorySettings = nil 259 259 } 260 260 return .none
+1 -1
supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift
··· 333 333 subtitle: pullRequest.title, 334 334 kind: .openPullRequest(worktreeID), 335 335 priorityTier: 2 336 - ) 336 + ), 337 337 ] 338 338 339 339 if let readyItem = makeReadyItem() {
+2 -30
supacode/Features/Repositories/Views/WorktreeRow.swift
··· 61 61 hideSubtitleOnMatch: Bool, 62 62 showsPullRequestInfo: Bool, 63 63 isRunScriptRunning: Bool, 64 + isTaskRunning: Bool, 64 65 showsNotificationIndicator: Bool, 65 66 notifications: [WorktreeTerminalNotification], 66 67 shortcutHint: String? ··· 73 74 self.showsNotificationIndicator = showsNotificationIndicator 74 75 self.notifications = notifications 75 76 self.shortcutHint = shortcutHint 76 - self.isBusy = row.isArchiving || row.isDeleting || row.isPending 77 + self.isBusy = row.isArchiving || row.isDeleting || row.isPending || isTaskRunning 77 78 78 79 // Worktree color. 79 80 self.worktreeColor = ··· 409 410 410 411 extension LabelStyle where Self == VerticallyCenteredLabelStyle { 411 412 static var verticallyCentered: VerticallyCenteredLabelStyle { .init() } 412 - } 413 - 414 - // MARK: - Shimmer effect. 415 - 416 - private struct ShimmerModifier: ViewModifier { 417 - let isActive: Bool 418 - @State private var phase = false 419 - 420 - func body(content: Content) -> some View { 421 - content 422 - .mask( 423 - LinearGradient( 424 - colors: isActive ? [.black.opacity(0.6), .black, .black.opacity(0.6)] : [.black], 425 - startPoint: phase ? UnitPoint(x: 1, y: 1) : UnitPoint(x: -0.5, y: -0.5), 426 - endPoint: phase ? UnitPoint(x: 1.5, y: 1.5) : UnitPoint(x: 0, y: 0) 427 - ) 428 - .animation( 429 - isActive ? .linear(duration: 1.5).delay(0.25).repeatForever(autoreverses: false) : nil, 430 - value: phase 431 - ) 432 - ) 433 - .task(id: isActive) { phase = isActive } 434 - } 435 - } 436 - 437 - extension View { 438 - func shimmer(isActive: Bool) -> some View { 439 - modifier(ShimmerModifier(isActive: isActive)) 440 - } 441 413 } 442 414 443 415 // MARK: - Pulsing dot.
+1
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 161 161 hideSubtitleOnMatch: hideSubtitleOnMatch, 162 162 showsPullRequestInfo: !draggingWorktreeIDs.contains(row.id), 163 163 isRunScriptRunning: store.state.runScriptWorktreeIDs.contains(row.id), 164 + isTaskRunning: terminalManager.stateIfExists(for: row.id)?.taskStatus == .running, 164 165 showsNotificationIndicator: terminalManager.hasUnseenNotifications(for: row.id), 165 166 notifications: terminalManager.stateIfExists(for: row.id)?.notifications ?? [], 166 167 shortcutHint: shortcutHint
+14
supacode/Features/Settings/BusinessLogic/AgentHookCommandOwnership.swift
··· 1 + nonisolated enum AgentHookCommandOwnership { 2 + /// Returns `true` when the command was installed by Supacode. 3 + static func isSupacodeManagedCommand(_ command: String?) -> Bool { 4 + guard let command else { return false } 5 + if command.contains(AgentHookSettingsCommand.socketPathEnvVar) { return true } 6 + return isLegacyCommand(command) 7 + } 8 + 9 + /// Returns `true` for commands from older Supacode versions. 10 + static func isLegacyCommand(_ command: String) -> Bool { 11 + command.contains(AgentHookSettingsCommand.legacyCLIPathEnvVar) 12 + && command.contains(AgentHookSettingsCommand.legacyAgentHookMarker) 13 + } 14 + }
+44
supacode/Features/Settings/BusinessLogic/AgentHookPayloadSupport.swift
··· 1 + import Foundation 2 + 3 + nonisolated enum AgentHookPayloadSupport { 4 + static func extractHookGroups<T: Encodable>( 5 + from payload: T, 6 + invalidConfiguration: @autoclosure () -> Error 7 + ) throws -> [String: [JSONValue]] { 8 + guard 9 + let objectValue = try JSONValue(payload).objectValue, 10 + let hooksValue = objectValue["hooks"]?.objectValue 11 + else { 12 + throw invalidConfiguration() 13 + } 14 + var result: [String: [JSONValue]] = [:] 15 + for (event, value) in hooksValue { 16 + guard let groups = value.arrayValue else { 17 + throw invalidConfiguration() 18 + } 19 + result[event] = groups 20 + } 21 + return result 22 + } 23 + } 24 + 25 + nonisolated struct AgentHookGroup: Encodable { 26 + let matcher: String? 27 + let hooks: [AgentCommandHook] 28 + 29 + init(matcher: String? = nil, hooks: [AgentCommandHook]) { 30 + self.matcher = matcher 31 + self.hooks = hooks 32 + } 33 + } 34 + 35 + nonisolated struct AgentCommandHook: Encodable { 36 + let type = "command" 37 + let command: String 38 + let timeout: Int 39 + 40 + init(command: String, timeout: Int) { 41 + self.command = command 42 + self.timeout = timeout 43 + } 44 + }
+36
supacode/Features/Settings/BusinessLogic/AgentHookSettingsCommand.swift
··· 1 + nonisolated enum AgentHookSettingsCommand { 2 + /// Marker present in all current Supacode hook commands. 3 + /// `AgentHookCommandOwnership` uses this to identify managed commands. 4 + static let socketPathEnvVar = "SUPACODE_SOCKET_PATH" 5 + 6 + /// Markers present in legacy Supacode hook commands (pre-socket). 7 + static let legacyCLIPathEnvVar = "SUPACODE_CLI_PATH" 8 + static let legacyAgentHookMarker = "agent-hook" 9 + 10 + private static let envCheck = 11 + #"[ -n "${SUPACODE_SOCKET_PATH:-}" ]"# 12 + + #" && [ -n "${SUPACODE_WORKTREE_ID:-}" ]"# 13 + + #" && [ -n "${SUPACODE_TAB_ID:-}" ]"# 14 + + #" && [ -n "${SUPACODE_SURFACE_ID:-}" ]"# 15 + 16 + private static let ids = 17 + "$SUPACODE_WORKTREE_ID $SUPACODE_TAB_ID $SUPACODE_SURFACE_ID" 18 + 19 + /// Sends `worktreeID tabID surfaceID 1|0` over a Unix socket. 20 + static func busyCommand(active: Bool) -> String { 21 + let flag = active ? "1" : "0" 22 + let send = 23 + #"echo "\#(ids) \#(flag)""# 24 + + #" | /usr/bin/nc -U -w1 "$SUPACODE_SOCKET_PATH""# 25 + return "\(envCheck) && \(send) 2>/dev/null || true" 26 + } 27 + 28 + /// Forwards the raw hook event JSON (from stdin) to the socket. 29 + /// Header: `worktreeID tabID surfaceID agent`. 30 + static func notificationCommand(agent: String) -> String { 31 + let send = 32 + #"{ printf '%s \#(agent)\n' "\#(ids)"; cat; }"# 33 + + #" | /usr/bin/nc -U -w1 "$SUPACODE_SOCKET_PATH""# 34 + return "\(envCheck) && \(send) 2>/dev/null || true" 35 + } 36 + }
+220
supacode/Features/Settings/BusinessLogic/AgentHookSettingsFileInstaller.swift
··· 1 + import Foundation 2 + 3 + private nonisolated let settingsInstallerLogger = SupaLogger("Settings") 4 + 5 + nonisolated struct AgentHookSettingsFileInstaller { 6 + struct Errors { 7 + let invalidEventHooks: @Sendable (String) -> Error 8 + let invalidHooksObject: @Sendable () -> Error 9 + let invalidJSON: @Sendable (String) -> Error 10 + let invalidRootObject: @Sendable () -> Error 11 + } 12 + 13 + private enum LoadError: Error { 14 + case invalidRootObject 15 + } 16 + 17 + let fileManager: FileManager 18 + let errors: Errors 19 + let logWarning: @Sendable (String) -> Void 20 + 21 + init( 22 + fileManager: FileManager, 23 + errors: Errors, 24 + logWarning: @escaping @Sendable (String) -> Void = { settingsInstallerLogger.warning($0) } 25 + ) { 26 + self.fileManager = fileManager 27 + self.errors = errors 28 + self.logWarning = logWarning 29 + } 30 + 31 + /// Returns `true` when at least one command from the given hook groups 32 + /// is present in the settings file. 33 + func containsMatchingHooks( 34 + settingsURL: URL, 35 + hookGroupsByEvent: [String: [JSONValue]] 36 + ) -> Bool { 37 + do { 38 + let settingsObject = try loadSettingsObject(at: settingsURL) 39 + guard let hooksValue = settingsObject["hooks"], 40 + let hooksObject = hooksValue.objectValue 41 + else { 42 + return false 43 + } 44 + let expectedCommands = Self.commands(from: hookGroupsByEvent) 45 + guard !expectedCommands.isEmpty else { return false } 46 + for (_, value) in hooksObject { 47 + guard let groups = value.arrayValue else { continue } 48 + for group in groups { 49 + guard let groupObject = group.objectValue, 50 + let hooks = groupObject["hooks"]?.arrayValue 51 + else { continue } 52 + for hook in hooks { 53 + guard let hookObject = hook.objectValue, 54 + let command = hookObject["command"]?.stringValue 55 + else { continue } 56 + if expectedCommands.contains(command) { return true } 57 + } 58 + } 59 + } 60 + return false 61 + } catch { 62 + if !Self.isFileNotFound(error) { 63 + logWarning("Failed to inspect hook settings at \(settingsURL.path): \(error)") 64 + } 65 + return false 66 + } 67 + } 68 + 69 + private static func commands(from hookGroupsByEvent: [String: [JSONValue]]) -> Set<String> { 70 + var commands = Set<String>() 71 + for (_, groups) in hookGroupsByEvent { 72 + for group in groups { 73 + guard let groupObject = group.objectValue, 74 + let hooks = groupObject["hooks"]?.arrayValue 75 + else { continue } 76 + for hook in hooks { 77 + guard let hookObject = hook.objectValue, 78 + let command = hookObject["command"]?.stringValue 79 + else { continue } 80 + commands.insert(command) 81 + } 82 + } 83 + } 84 + return commands 85 + } 86 + 87 + /// Removes matching hooks and any legacy Supacode-owned commands. 88 + func uninstall( 89 + settingsURL: URL, 90 + hookGroupsByEvent: @autoclosure () throws -> [String: [JSONValue]] 91 + ) throws { 92 + let settingsObject = try loadSettingsObject(at: settingsURL) 93 + let commandsToPrune = Self.commands(from: try hookGroupsByEvent()) 94 + var mergedObject = settingsObject 95 + var hooksObject = (mergedObject["hooks"]?.objectValue) ?? [:] 96 + for event in hooksObject.keys { 97 + let existing = try existingGroups(for: event, hooksObject: hooksObject) 98 + let filtered = existing.compactMap { prunedGroup($0, removing: commandsToPrune) } 99 + if filtered.isEmpty { 100 + hooksObject.removeValue(forKey: event) 101 + } else { 102 + hooksObject[event] = .array(filtered) 103 + } 104 + } 105 + mergedObject["hooks"] = .object(hooksObject) 106 + try writeSettings(mergedObject, to: settingsURL) 107 + } 108 + 109 + func install( 110 + settingsURL: URL, 111 + hookGroupsByEvent: @autoclosure () throws -> [String: [JSONValue]] 112 + ) throws { 113 + let settingsObject = try loadSettingsObject(at: settingsURL) 114 + let mergedObject = try mergedSettingsObject( 115 + from: settingsObject, 116 + hookGroupsByEvent: try hookGroupsByEvent() 117 + ) 118 + try writeSettings(mergedObject, to: settingsURL) 119 + } 120 + 121 + private func writeSettings(_ object: [String: JSONValue], to url: URL) throws { 122 + try fileManager.createDirectory( 123 + at: url.deletingLastPathComponent(), 124 + withIntermediateDirectories: true 125 + ) 126 + let encoder = JSONEncoder() 127 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 128 + let data = try encoder.encode(JSONValue.object(object)) 129 + try data.write(to: url, options: .atomic) 130 + } 131 + 132 + private func loadSettingsObject(at url: URL) throws -> [String: JSONValue] { 133 + guard fileManager.fileExists(atPath: url.path) else { return [:] } 134 + let data = try Data(contentsOf: url) 135 + do { 136 + let jsonValue = try JSONDecoder().decode(JSONValue.self, from: data) 137 + guard let object = jsonValue.objectValue else { 138 + throw LoadError.invalidRootObject 139 + } 140 + return object 141 + } catch LoadError.invalidRootObject { 142 + throw errors.invalidRootObject() 143 + } catch { 144 + throw errors.invalidJSON(error.localizedDescription) 145 + } 146 + } 147 + 148 + private static func isFileNotFound(_ error: Error) -> Bool { 149 + let nsError = error as NSError 150 + return nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError 151 + } 152 + 153 + private func mergedSettingsObject( 154 + from settingsObject: [String: JSONValue], 155 + hookGroupsByEvent: [String: [JSONValue]] 156 + ) throws -> [String: JSONValue] { 157 + var mergedObject = settingsObject 158 + var hooksObject: [String: JSONValue] 159 + if let hooksValue = mergedObject["hooks"] { 160 + guard let existingHooksObject = hooksValue.objectValue else { 161 + throw errors.invalidHooksObject() 162 + } 163 + hooksObject = existingHooksObject 164 + } else { 165 + hooksObject = [:] 166 + } 167 + 168 + // Only prune commands that belong to the feature being installed 169 + // (or uninstalled). This preserves hooks from other features. 170 + let commandsToPrune = Self.commands(from: hookGroupsByEvent) 171 + for event in hooksObject.keys { 172 + let existing = try existingGroups(for: event, hooksObject: hooksObject) 173 + let filtered = existing.compactMap { prunedGroup($0, removing: commandsToPrune) } 174 + if filtered.isEmpty { 175 + hooksObject.removeValue(forKey: event) 176 + } else { 177 + hooksObject[event] = .array(filtered) 178 + } 179 + } 180 + 181 + // Add the new hooks. 182 + for (event, canonicalGroups) in hookGroupsByEvent { 183 + let existing = hooksObject[event]?.arrayValue ?? [] 184 + hooksObject[event] = .array(existing + canonicalGroups) 185 + } 186 + 187 + mergedObject["hooks"] = .object(hooksObject) 188 + return mergedObject 189 + } 190 + 191 + private func existingGroups( 192 + for event: String, 193 + hooksObject: [String: JSONValue] 194 + ) throws -> [JSONValue] { 195 + guard let existingValue = hooksObject[event] else { return [] } 196 + guard let groups = existingValue.arrayValue else { 197 + throw errors.invalidEventHooks(event) 198 + } 199 + return groups 200 + } 201 + 202 + private func prunedGroup(_ group: JSONValue, removing commandsToPrune: Set<String>) -> JSONValue? { 203 + guard var groupObject = group.objectValue else { return group } 204 + guard let hooksValue = groupObject["hooks"] else { return group } 205 + guard let hooks = hooksValue.arrayValue else { return group } 206 + let filteredHooks = hooks.filter { hook in 207 + guard let hookObject = hook.objectValue, 208 + let command = hookObject["command"]?.stringValue 209 + else { return true } 210 + // Remove if it matches the specific feature being installed, 211 + // or if it's a legacy Supacode command. 212 + if commandsToPrune.contains(command) { return false } 213 + if AgentHookCommandOwnership.isLegacyCommand(command) { return false } 214 + return true 215 + } 216 + guard !filteredHooks.isEmpty else { return nil } 217 + groupObject["hooks"] = .array(filteredHooks) 218 + return .object(groupObject) 219 + } 220 + }
+61
supacode/Features/Settings/BusinessLogic/ClaudeHookSettings.swift
··· 1 + import Foundation 2 + 3 + nonisolated enum ClaudeHookSettings { 4 + fileprivate static let busyOn = AgentHookSettingsCommand.busyCommand(active: true) 5 + fileprivate static let busyOff = AgentHookSettingsCommand.busyCommand(active: false) 6 + fileprivate static let notify = AgentHookSettingsCommand.notificationCommand(agent: "claude") 7 + 8 + static func progressHookGroupsByEvent() throws -> [String: [JSONValue]] { 9 + try AgentHookPayloadSupport.extractHookGroups( 10 + from: ClaudeProgressPayload(), 11 + invalidConfiguration: ClaudeHookSettingsError.invalidConfiguration 12 + ) 13 + } 14 + 15 + static func notificationHookGroupsByEvent() throws -> [String: [JSONValue]] { 16 + try AgentHookPayloadSupport.extractHookGroups( 17 + from: ClaudeNotificationPayload(), 18 + invalidConfiguration: ClaudeHookSettingsError.invalidConfiguration 19 + ) 20 + } 21 + } 22 + 23 + nonisolated enum ClaudeHookSettingsError: Error { 24 + case invalidConfiguration 25 + } 26 + 27 + // MARK: - Progress hooks. 28 + 29 + // UserPromptSubmit sets busy, Stop/SessionEnd/PostToolUseFailure clears it. 30 + private nonisolated struct ClaudeProgressPayload: Encodable { 31 + let hooks: [String: [AgentHookGroup]] = [ 32 + "UserPromptSubmit": [ 33 + .init(hooks: [ 34 + .init(command: ClaudeHookSettings.busyOn, timeout: 10), 35 + ]), 36 + ], 37 + "Stop": [ 38 + .init(hooks: [.init(command: ClaudeHookSettings.busyOff, timeout: 10)]) 39 + ], 40 + "PostToolUseFailure": [ 41 + .init(hooks: [.init(command: ClaudeHookSettings.busyOff, timeout: 5)]) 42 + ], 43 + "SessionEnd": [ 44 + .init(matcher: "", hooks: [.init(command: ClaudeHookSettings.busyOff, timeout: 1)]) 45 + ], 46 + ] 47 + } 48 + 49 + // MARK: - Notification hooks. 50 + 51 + // Stop forwards lastAssistantMessage, Notification forwards message/title. 52 + private nonisolated struct ClaudeNotificationPayload: Encodable { 53 + let hooks: [String: [AgentHookGroup]] = [ 54 + "Stop": [ 55 + .init(hooks: [.init(command: ClaudeHookSettings.notify, timeout: 10)]) 56 + ], 57 + "Notification": [ 58 + .init(matcher: "", hooks: [.init(command: ClaudeHookSettings.notify, timeout: 10)]) 59 + ], 60 + ] 61 + }
+107
supacode/Features/Settings/BusinessLogic/ClaudeSettingsInstaller.swift
··· 1 + import Foundation 2 + 3 + nonisolated struct ClaudeSettingsInstaller { 4 + let homeDirectoryURL: URL 5 + let fileManager: FileManager 6 + 7 + init( 8 + homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, 9 + fileManager: FileManager = .default 10 + ) { 11 + self.homeDirectoryURL = homeDirectoryURL 12 + self.fileManager = fileManager 13 + } 14 + 15 + func isInstalled(progress: Bool) -> Bool { 16 + let groups: [String: [JSONValue]] 17 + do { 18 + groups = 19 + try progress 20 + ? ClaudeHookSettings.progressHookGroupsByEvent() 21 + : ClaudeHookSettings.notificationHookGroupsByEvent() 22 + } catch { 23 + Self.reportInvalidHookConfiguration(error, progress: progress) 24 + return false 25 + } 26 + return fileInstaller.containsMatchingHooks( 27 + settingsURL: settingsURL, 28 + hookGroupsByEvent: groups 29 + ) 30 + } 31 + 32 + func installProgressHooks() throws { 33 + try fileInstaller.install( 34 + settingsURL: settingsURL, 35 + hookGroupsByEvent: try ClaudeHookSettings.progressHookGroupsByEvent() 36 + ) 37 + } 38 + 39 + func installNotificationHooks() throws { 40 + try fileInstaller.install( 41 + settingsURL: settingsURL, 42 + hookGroupsByEvent: try ClaudeHookSettings.notificationHookGroupsByEvent() 43 + ) 44 + } 45 + 46 + func uninstallProgressHooks() throws { 47 + try fileInstaller.uninstall( 48 + settingsURL: settingsURL, 49 + hookGroupsByEvent: try ClaudeHookSettings.progressHookGroupsByEvent() 50 + ) 51 + } 52 + 53 + func uninstallNotificationHooks() throws { 54 + try fileInstaller.uninstall( 55 + settingsURL: settingsURL, 56 + hookGroupsByEvent: try ClaudeHookSettings.notificationHookGroupsByEvent() 57 + ) 58 + } 59 + 60 + private var settingsURL: URL { 61 + Self.settingsURL(homeDirectoryURL: homeDirectoryURL) 62 + } 63 + 64 + static func settingsURL(homeDirectoryURL: URL) -> URL { 65 + homeDirectoryURL 66 + .appendingPathComponent(".claude", isDirectory: true) 67 + .appendingPathComponent("settings.json", isDirectory: false) 68 + } 69 + 70 + private static func reportInvalidHookConfiguration(_ error: Error, progress: Bool) { 71 + #if DEBUG 72 + assertionFailure("Claude \(progress ? "progress" : "notification") hook configuration is invalid: \(error)") 73 + #endif 74 + } 75 + 76 + private var fileInstaller: AgentHookSettingsFileInstaller { 77 + AgentHookSettingsFileInstaller( 78 + fileManager: fileManager, 79 + errors: .init( 80 + invalidEventHooks: { ClaudeSettingsInstallerError.invalidEventHooks($0) }, 81 + invalidHooksObject: { ClaudeSettingsInstallerError.invalidHooksObject }, 82 + invalidJSON: { ClaudeSettingsInstallerError.invalidJSON($0) }, 83 + invalidRootObject: { ClaudeSettingsInstallerError.invalidRootObject } 84 + ) 85 + ) 86 + } 87 + } 88 + 89 + nonisolated enum ClaudeSettingsInstallerError: Error, Equatable, LocalizedError { 90 + case invalidEventHooks(String) 91 + case invalidHooksObject 92 + case invalidJSON(String) 93 + case invalidRootObject 94 + 95 + var errorDescription: String? { 96 + switch self { 97 + case .invalidEventHooks(let event): 98 + "Claude settings use an unsupported hooks shape for \(event)." 99 + case .invalidHooksObject: 100 + "Claude settings use an unsupported hooks shape." 101 + case .invalidJSON(let detail): 102 + "Claude settings must be valid JSON before Supacode can install hooks (\(detail))." 103 + case .invalidRootObject: 104 + "Claude settings must be a JSON object before Supacode can install hooks." 105 + } 106 + } 107 + }
+53
supacode/Features/Settings/BusinessLogic/CodexHookSettings.swift
··· 1 + import Foundation 2 + 3 + nonisolated enum CodexHookSettings { 4 + fileprivate static let busyOn = AgentHookSettingsCommand.busyCommand(active: true) 5 + fileprivate static let busyOff = AgentHookSettingsCommand.busyCommand(active: false) 6 + fileprivate static let notify = AgentHookSettingsCommand.notificationCommand(agent: "codex") 7 + 8 + static func progressHookGroupsByEvent() throws -> [String: [JSONValue]] { 9 + try AgentHookPayloadSupport.extractHookGroups( 10 + from: CodexProgressPayload(), 11 + invalidConfiguration: CodexHookSettingsError.invalidConfiguration 12 + ) 13 + } 14 + 15 + static func notificationHookGroupsByEvent() throws -> [String: [JSONValue]] { 16 + try AgentHookPayloadSupport.extractHookGroups( 17 + from: CodexNotificationPayload(), 18 + invalidConfiguration: CodexHookSettingsError.invalidConfiguration 19 + ) 20 + } 21 + } 22 + 23 + nonisolated enum CodexHookSettingsError: Error { 24 + case invalidConfiguration 25 + } 26 + 27 + // MARK: - Progress hooks. 28 + 29 + // Codex fires UserPromptSubmit, Stop, PreToolUse (Bash), and SessionStart. 30 + // Only Submit/Stop are used for busy tracking. 31 + private nonisolated struct CodexProgressPayload: Encodable { 32 + let hooks: [String: [AgentHookGroup]] = [ 33 + "UserPromptSubmit": [ 34 + .init(hooks: [ 35 + .init(command: CodexHookSettings.busyOn, timeout: 10), 36 + ]), 37 + ], 38 + "Stop": [ 39 + .init(hooks: [.init(command: CodexHookSettings.busyOff, timeout: 10)]) 40 + ], 41 + ] 42 + } 43 + 44 + // MARK: - Notification hooks. 45 + 46 + // Codex only supports Stop for meaningful notification content. 47 + private nonisolated struct CodexNotificationPayload: Encodable { 48 + let hooks: [String: [AgentHookGroup]] = [ 49 + "Stop": [ 50 + .init(hooks: [.init(command: CodexHookSettings.notify, timeout: 10)]) 51 + ], 52 + ] 53 + }
+191
supacode/Features/Settings/BusinessLogic/CodexSettingsInstaller.swift
··· 1 + import Darwin 2 + import Foundation 3 + 4 + nonisolated struct CodexSettingsInstaller { 5 + struct CommandResult: Equatable, Sendable { 6 + let status: Int32 7 + let standardError: String 8 + } 9 + 10 + let homeDirectoryURL: URL 11 + let fileManager: FileManager 12 + let runEnableHooksCommand: @Sendable () async throws -> CommandResult 13 + 14 + init( 15 + homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, 16 + fileManager: FileManager = .default 17 + ) { 18 + self.init( 19 + homeDirectoryURL: homeDirectoryURL, 20 + fileManager: fileManager, 21 + runEnableHooksCommand: Self.runEnableHooksCommand 22 + ) 23 + } 24 + 25 + init( 26 + homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, 27 + fileManager: FileManager = .default, 28 + runEnableHooksCommand: @escaping @Sendable () async throws -> CommandResult 29 + ) { 30 + self.homeDirectoryURL = homeDirectoryURL 31 + self.fileManager = fileManager 32 + self.runEnableHooksCommand = runEnableHooksCommand 33 + } 34 + 35 + func isInstalled(progress: Bool) -> Bool { 36 + let groups: [String: [JSONValue]] 37 + do { 38 + groups = 39 + try progress 40 + ? CodexHookSettings.progressHookGroupsByEvent() 41 + : CodexHookSettings.notificationHookGroupsByEvent() 42 + } catch { 43 + Self.reportInvalidHookConfiguration(error, progress: progress) 44 + return false 45 + } 46 + return fileInstaller.containsMatchingHooks( 47 + settingsURL: settingsURL, 48 + hookGroupsByEvent: groups 49 + ) 50 + } 51 + 52 + func installProgressHooks() async throws { 53 + try await enableHooksFeature() 54 + try fileInstaller.install( 55 + settingsURL: settingsURL, 56 + hookGroupsByEvent: try CodexHookSettings.progressHookGroupsByEvent() 57 + ) 58 + } 59 + 60 + func installNotificationHooks() async throws { 61 + try await enableHooksFeature() 62 + try fileInstaller.install( 63 + settingsURL: settingsURL, 64 + hookGroupsByEvent: try CodexHookSettings.notificationHookGroupsByEvent() 65 + ) 66 + } 67 + 68 + func uninstallProgressHooks() throws { 69 + try fileInstaller.uninstall( 70 + settingsURL: settingsURL, 71 + hookGroupsByEvent: try CodexHookSettings.progressHookGroupsByEvent() 72 + ) 73 + } 74 + 75 + func uninstallNotificationHooks() throws { 76 + try fileInstaller.uninstall( 77 + settingsURL: settingsURL, 78 + hookGroupsByEvent: try CodexHookSettings.notificationHookGroupsByEvent() 79 + ) 80 + } 81 + 82 + private func enableHooksFeature() async throws { 83 + let commandResult = try await runEnableHooksCommand() 84 + guard commandResult.status == 0 else { 85 + throw CodexSettingsInstallerError.enableHooksFailed(commandResult.standardError) 86 + } 87 + } 88 + 89 + private var settingsURL: URL { 90 + Self.settingsURL(homeDirectoryURL: homeDirectoryURL) 91 + } 92 + 93 + static func settingsURL(homeDirectoryURL: URL) -> URL { 94 + homeDirectoryURL 95 + .appendingPathComponent(".codex", isDirectory: true) 96 + .appendingPathComponent("hooks.json", isDirectory: false) 97 + } 98 + 99 + static func runEnableHooksCommand() async throws -> CommandResult { 100 + let process = Process() 101 + process.executableURL = loginShellURL() 102 + process.arguments = ["-l", "-c", "codex features enable codex_hooks"] 103 + let errorPipe = Pipe() 104 + process.standardError = errorPipe 105 + let status = try await withCheckedThrowingContinuation { continuation in 106 + process.terminationHandler = { process in 107 + continuation.resume(returning: process.terminationStatus) 108 + } 109 + do { 110 + try process.run() 111 + } catch { 112 + continuation.resume(throwing: error) 113 + } 114 + } 115 + let standardError = 116 + String(bytes: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" 117 + if status == 127 { 118 + throw CodexSettingsInstallerError.codexUnavailable 119 + } 120 + return .init(status: status, standardError: standardError.trimmingCharacters(in: .whitespacesAndNewlines)) 121 + } 122 + 123 + static func loginShellURL( 124 + environment: [String: String] = ProcessInfo.processInfo.environment, 125 + currentUserShellPath: String? = currentUserShellPath() 126 + ) -> URL { 127 + let shellPath = 128 + normalizedShellPath(currentUserShellPath) 129 + ?? normalizedShellPath(environment["SHELL"]) 130 + ?? "/bin/zsh" 131 + return URL(fileURLWithPath: shellPath) 132 + } 133 + 134 + private static func currentUserShellPath() -> String? { 135 + guard let entry = getpwuid(getuid()), let shell = entry.pointee.pw_shell else { return nil } 136 + return String(cString: shell) 137 + } 138 + 139 + private static func normalizedShellPath(_ path: String?) -> String? { 140 + guard let path = path?.trimmingCharacters(in: .whitespacesAndNewlines), !path.isEmpty else { 141 + return nil 142 + } 143 + return path 144 + } 145 + 146 + private static func reportInvalidHookConfiguration(_ error: Error, progress: Bool) { 147 + #if DEBUG 148 + assertionFailure("Codex \(progress ? "progress" : "notification") hook configuration is invalid: \(error)") 149 + #endif 150 + } 151 + 152 + private var fileInstaller: AgentHookSettingsFileInstaller { 153 + AgentHookSettingsFileInstaller( 154 + fileManager: fileManager, 155 + errors: .init( 156 + invalidEventHooks: { CodexSettingsInstallerError.invalidEventHooks($0) }, 157 + invalidHooksObject: { CodexSettingsInstallerError.invalidHooksObject }, 158 + invalidJSON: { CodexSettingsInstallerError.invalidJSON($0) }, 159 + invalidRootObject: { CodexSettingsInstallerError.invalidRootObject } 160 + ) 161 + ) 162 + } 163 + } 164 + 165 + nonisolated enum CodexSettingsInstallerError: Error, Equatable, LocalizedError { 166 + case codexUnavailable 167 + case enableHooksFailed(String) 168 + case invalidEventHooks(String) 169 + case invalidHooksObject 170 + case invalidJSON(String) 171 + case invalidRootObject 172 + 173 + var errorDescription: String? { 174 + switch self { 175 + case .codexUnavailable: 176 + "Codex must be installed and available in your login shell before Supacode can install hooks." 177 + case .enableHooksFailed(let details): 178 + details.isEmpty 179 + ? "Supacode could not enable the Codex hooks feature." 180 + : "Supacode could not enable the Codex hooks feature: \(details)" 181 + case .invalidEventHooks(let event): 182 + "Codex hooks use an unsupported shape for \(event)." 183 + case .invalidHooksObject: 184 + "Codex hooks use an unsupported shape." 185 + case .invalidJSON(let detail): 186 + "Codex hooks must be valid JSON before Supacode can install hooks (\(detail))." 187 + case .invalidRootObject: 188 + "Codex hooks must be a JSON object before Supacode can install hooks." 189 + } 190 + } 191 + }
+38
supacode/Features/Settings/Models/AgentHooksInstallState.swift
··· 1 + nonisolated enum AgentHooksInstallState: Equatable, Sendable { 2 + case checking 3 + case installed 4 + case notInstalled 5 + case installing 6 + case uninstalling 7 + case failed(String) 8 + 9 + var isLoading: Bool { 10 + switch self { 11 + case .checking, .installing, .uninstalling: true 12 + default: false 13 + } 14 + } 15 + 16 + var isInstalled: Bool { 17 + if case .installed = self { return true } 18 + return false 19 + } 20 + 21 + var isFailure: Bool { 22 + if case .failed = self { return true } 23 + return false 24 + } 25 + 26 + var errorMessage: String? { 27 + guard case .failed(let message) = self else { return nil } 28 + return message 29 + } 30 + } 31 + 32 + /// Identifies a specific hook feature for a specific agent. 33 + enum AgentHookSlot: Equatable, Sendable { 34 + case claudeProgress 35 + case claudeNotifications 36 + case codexProgress 37 + case codexNotifications 38 + }
+94
supacode/Features/Settings/Models/JSONValue.swift
··· 1 + import Foundation 2 + 3 + nonisolated 4 + enum JSONValue: Hashable, Sendable, Codable, 5 + ExpressibleByNilLiteral, ExpressibleByBooleanLiteral, 6 + ExpressibleByIntegerLiteral, ExpressibleByFloatLiteral, 7 + ExpressibleByStringLiteral, ExpressibleByArrayLiteral, 8 + ExpressibleByDictionaryLiteral 9 + { 10 + case null 11 + case bool(Bool) 12 + case int(Int) 13 + case double(Double) 14 + case string(String) 15 + case array([Self]) 16 + case object([String: Self]) 17 + 18 + // MARK: - Codable. 19 + 20 + init(from decoder: Decoder) throws { 21 + let container = try decoder.singleValueContainer() 22 + if container.decodeNil() { 23 + self = .null 24 + } else if let value = try? container.decode(Bool.self) { 25 + self = .bool(value) 26 + } else if let value = try? container.decode(Int.self) { 27 + self = .int(value) 28 + } else if let value = try? container.decode(Double.self) { 29 + self = .double(value) 30 + } else if let value = try? container.decode(String.self) { 31 + self = .string(value) 32 + } else if let value = try? container.decode([Self].self) { 33 + self = .array(value) 34 + } else { 35 + self = .object(try container.decode([String: Self].self)) 36 + } 37 + } 38 + 39 + func encode(to encoder: Encoder) throws { 40 + var container = encoder.singleValueContainer() 41 + switch self { 42 + case .null: try container.encodeNil() 43 + case .bool(let value): try container.encode(value) 44 + case .int(let value): try container.encode(value) 45 + case .double(let value): try container.encode(value) 46 + case .string(let value): try container.encode(value) 47 + case .array(let value): try container.encode(value) 48 + case .object(let value): try container.encode(value) 49 + } 50 + } 51 + 52 + // MARK: - Expressible-by literals. 53 + 54 + init(nilLiteral: ()) { self = .null } 55 + init(booleanLiteral value: Bool) { self = .bool(value) } 56 + init(integerLiteral value: Int) { self = .int(value) } 57 + init(floatLiteral value: Double) { self = .double(value) } 58 + init(stringLiteral value: String) { self = .string(value) } 59 + init(arrayLiteral elements: Self...) { self = .array(elements) } 60 + 61 + init(dictionaryLiteral elements: (String, Self)...) { 62 + self = .object(.init(uniqueKeysWithValues: elements)) 63 + } 64 + 65 + // MARK: - Accessors. 66 + 67 + var arrayValue: [Self]? { 68 + guard case .array(let value) = self else { return nil } 69 + return value 70 + } 71 + 72 + var objectValue: [String: Self]? { 73 + guard case .object(let value) = self else { return nil } 74 + return value 75 + } 76 + 77 + var stringValue: String? { 78 + guard case .string(let value) = self else { return nil } 79 + return value 80 + } 81 + 82 + // MARK: - Encodable bridging. 83 + 84 + init<T: Encodable>(_ value: T) throws { 85 + let data = try JSONEncoder().encode(EncodableBox(value)) 86 + self = try JSONDecoder().decode(Self.self, from: data) 87 + } 88 + } 89 + 90 + private nonisolated struct EncodableBox<T: Encodable>: Encodable { 91 + let value: T 92 + init(_ value: T) { self.value = value } 93 + func encode(to encoder: Encoder) throws { try value.encode(to: encoder) } 94 + }
+91 -1
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 32 32 var defaultWorktreeBaseDirectoryPath: String 33 33 var autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? 34 34 var shortcutOverrides: [AppShortcutID: AppShortcutOverride] 35 + var claudeProgressState = AgentHooksInstallState.checking 36 + var claudeNotificationsState = AgentHooksInstallState.checking 37 + var codexProgressState = AgentHooksInstallState.checking 38 + var codexNotificationsState = AgentHooksInstallState.checking 35 39 // nil = settings window closed, non-nil = open to this section. 36 40 // The view layer opens the settings window when this becomes non-nil. 37 41 var selection: SettingsSection? ··· 116 120 case resetAllShortcuts 117 121 case requestAutoDeleteDaysChange(AutoDeletePeriod?) 118 122 case resolvedAutoDeleteAffectedCount(AutoDeletePeriod, affectedCount: Int) 123 + case agentHookChecked(AgentHookSlot, installed: Bool) 124 + case agentHookInstallTapped(AgentHookSlot) 125 + case agentHookUninstallTapped(AgentHookSlot) 126 + case agentHookActionCompleted(AgentHookSlot, Result<Bool, Error>) 119 127 case repositorySettings(RepositorySettingsFeature.Action) 120 128 case alert(PresentationAction<Alert>) 121 129 case delegate(Delegate) ··· 134 142 } 135 143 136 144 @Dependency(AnalyticsClient.self) private var analyticsClient 145 + @Dependency(ClaudeSettingsClient.self) private var claudeSettingsClient 146 + @Dependency(CodexSettingsClient.self) private var codexSettingsClient 137 147 @Dependency(RepositoryPersistenceClient.self) private var repositoryPersistence 138 148 @Dependency(SystemNotificationClient.self) private var systemNotificationClient 139 149 @Dependency(\.date.now) private var now ··· 144 154 switch action { 145 155 case .task: 146 156 @Shared(.settingsFile) var settingsFile 147 - return .send(.settingsLoaded(settingsFile.global)) 157 + return .merge( 158 + .send(.settingsLoaded(settingsFile.global)), 159 + .run { [claudeSettingsClient, codexSettingsClient] send in 160 + async let claudeProgressInstalled = claudeSettingsClient.checkInstalled(true) 161 + async let claudeNotificationsInstalled = claudeSettingsClient.checkInstalled(false) 162 + async let codexProgressInstalled = codexSettingsClient.checkInstalled(true) 163 + async let codexNotificationsInstalled = codexSettingsClient.checkInstalled(false) 164 + 165 + await send(.agentHookChecked(.claudeProgress, installed: await claudeProgressInstalled)) 166 + await send( 167 + .agentHookChecked(.claudeNotifications, installed: await claudeNotificationsInstalled)) 168 + await send(.agentHookChecked(.codexProgress, installed: await codexProgressInstalled)) 169 + await send( 170 + .agentHookChecked(.codexNotifications, installed: await codexNotificationsInstalled)) 171 + } 172 + ) 148 173 149 174 case .settingsLoaded(let settings): 150 175 let normalizedDefaultEditorID = OpenWorktreeAction.normalizedDefaultEditorID(settings.defaultEditorID) ··· 224 249 } 225 250 return .none 226 251 252 + case .agentHookChecked(let slot, let installed): 253 + state[hookSlot: slot] = installed ? .installed : .notInstalled 254 + return .none 255 + 256 + case .agentHookInstallTapped(let slot): 257 + guard !state[hookSlot: slot].isLoading else { return .none } 258 + state[hookSlot: slot] = .installing 259 + return .run { [claudeSettingsClient, codexSettingsClient] send in 260 + do { 261 + switch slot { 262 + case .claudeProgress: try await claudeSettingsClient.installProgress() 263 + case .claudeNotifications: try await claudeSettingsClient.installNotifications() 264 + case .codexProgress: try await codexSettingsClient.installProgress() 265 + case .codexNotifications: try await codexSettingsClient.installNotifications() 266 + } 267 + await send(.agentHookActionCompleted(slot, .success(true))) 268 + } catch { 269 + await send(.agentHookActionCompleted(slot, .failure(error))) 270 + } 271 + } 272 + 273 + case .agentHookUninstallTapped(let slot): 274 + guard !state[hookSlot: slot].isLoading else { return .none } 275 + state[hookSlot: slot] = .uninstalling 276 + return .run { [claudeSettingsClient, codexSettingsClient] send in 277 + do { 278 + switch slot { 279 + case .claudeProgress: try await claudeSettingsClient.uninstallProgress() 280 + case .claudeNotifications: try await claudeSettingsClient.uninstallNotifications() 281 + case .codexProgress: try await codexSettingsClient.uninstallProgress() 282 + case .codexNotifications: try await codexSettingsClient.uninstallNotifications() 283 + } 284 + await send(.agentHookActionCompleted(slot, .success(false))) 285 + } catch { 286 + await send(.agentHookActionCompleted(slot, .failure(error))) 287 + } 288 + } 289 + 290 + case .agentHookActionCompleted(let slot, .success(let installed)): 291 + state[hookSlot: slot] = installed ? .installed : .notInstalled 292 + return .none 293 + 294 + case .agentHookActionCompleted(let slot, .failure(let error)): 295 + state[hookSlot: slot] = .failed(error.localizedDescription) 296 + return .none 297 + 227 298 case .updateShortcut(let id, let override): 228 299 if let override { 229 300 state.shortcutOverrides[id] = override ··· 364 435 settings.copyUntrackedOnWorktreeCreate 365 436 repositorySettings?.globalPullRequestMergeStrategy = 366 437 settings.pullRequestMergeStrategy 438 + } 439 + 440 + subscript(hookSlot slot: AgentHookSlot) -> AgentHooksInstallState { 441 + get { 442 + switch slot { 443 + case .claudeProgress: claudeProgressState 444 + case .claudeNotifications: claudeNotificationsState 445 + case .codexProgress: codexProgressState 446 + case .codexNotifications: codexNotificationsState 447 + } 448 + } 449 + set { 450 + switch slot { 451 + case .claudeProgress: claudeProgressState = newValue 452 + case .claudeNotifications: claudeNotificationsState = newValue 453 + case .codexProgress: codexProgressState = newValue 454 + case .codexNotifications: codexNotificationsState = newValue 455 + } 456 + } 367 457 } 368 458 }
+95
supacode/Features/Settings/Views/CodingAgentsSettingsView.swift
··· 1 + import ComposableArchitecture 2 + import SwiftUI 3 + 4 + struct CodingAgentsSettingsView: View { 5 + let store: StoreOf<SettingsFeature> 6 + 7 + var body: some View { 8 + Form { 9 + Section( 10 + footer: Text("Hooks are optional and designed to extend Supacode without affecting core functionality.") 11 + ) {} 12 + Section { 13 + AgentInstallRow( 14 + installAction: { store.send(.agentHookInstallTapped(.claudeProgress)) }, 15 + uninstallAction: { store.send(.agentHookUninstallTapped(.claudeProgress)) }, 16 + installState: store.claudeProgressState, 17 + title: "Progress", 18 + subtitle: "Display agent activity in tab and sidebar." 19 + ) 20 + AgentInstallRow( 21 + installAction: { store.send(.agentHookInstallTapped(.claudeNotifications)) }, 22 + uninstallAction: { store.send(.agentHookUninstallTapped(.claudeNotifications)) }, 23 + installState: store.claudeNotificationsState, 24 + title: "Notifications", 25 + subtitle: "Forward richer notifications to Supacode." 26 + ) 27 + } header: { 28 + Label("Claude Code", image: "claude-code-mark") 29 + } footer: { 30 + Text("Applied to `~/.claude/settings.json`.") 31 + } 32 + Section { 33 + AgentInstallRow( 34 + installAction: { store.send(.agentHookInstallTapped(.codexProgress)) }, 35 + uninstallAction: { store.send(.agentHookUninstallTapped(.codexProgress)) }, 36 + installState: store.codexProgressState, 37 + title: "Progress", 38 + subtitle: "Display agent activity in tab and sidebar." 39 + ) 40 + AgentInstallRow( 41 + installAction: { store.send(.agentHookInstallTapped(.codexNotifications)) }, 42 + uninstallAction: { store.send(.agentHookUninstallTapped(.codexNotifications)) }, 43 + installState: store.codexNotificationsState, 44 + title: "Notifications", 45 + subtitle: "Forward richer notifications to Supacode." 46 + ) 47 + } header: { 48 + Label("Codex", image: "codex-mark") 49 + } footer: { 50 + Text("Applied to `~/.codex/hooks.json`.") 51 + } 52 + } 53 + .formStyle(.grouped) 54 + .padding(.top, -20) 55 + .padding(.leading, -8) 56 + .padding(.trailing, -6) 57 + .navigationTitle("Coding Agents") 58 + } 59 + } 60 + 61 + private struct AgentInstallRow: View { 62 + let installAction: () -> Void 63 + let uninstallAction: () -> Void 64 + let installState: AgentHooksInstallState 65 + let title: String 66 + let subtitle: String 67 + 68 + var body: some View { 69 + LabeledContent { 70 + switch installState { 71 + case .checking: 72 + ProgressView() 73 + case .installed: 74 + ControlGroup { 75 + Label("Installed", systemImage: "checkmark") 76 + Button("Uninstall", role: .destructive, action: uninstallAction) 77 + } 78 + case .notInstalled, .failed: 79 + Button("Install", action: installAction) 80 + case .installing: 81 + Button("Installing\u{2026}") {} 82 + .disabled(true) 83 + case .uninstalling: 84 + Button("Uninstalling\u{2026}") {} 85 + .disabled(true) 86 + } 87 + } label: { 88 + Text(title) 89 + Text(subtitle) 90 + if let message = installState.errorMessage { 91 + Text(message).foregroundStyle(.red) 92 + } 93 + } 94 + } 95 + }
+1
supacode/Features/Settings/Views/SettingsSection.swift
··· 4 4 case general 5 5 case notifications 6 6 case worktree 7 + case codingAgents 7 8 case shortcuts 8 9 case updates 9 10 case github
+4
supacode/Features/Settings/Views/SettingsView.swift
··· 59 59 .tag(SettingsSection.notifications) 60 60 Label("Worktrees", systemImage: "list.dash") 61 61 .tag(SettingsSection.worktree) 62 + Label("Coding Agents", systemImage: "hammer") 63 + .tag(SettingsSection.codingAgents) 62 64 Label("GitHub", image: "github-mark") 63 65 .tag(SettingsSection.github) 64 66 Label("Shortcuts", systemImage: "keyboard") ··· 87 89 NotificationsSettingsView(store: settingsStore) 88 90 case .worktree: 89 91 WorktreeSettingsView(store: settingsStore) 92 + case .codingAgents: 93 + CodingAgentsSettingsView(store: settingsStore) 90 94 case .shortcuts: 91 95 KeyboardShortcutsSettingsView(store: settingsStore) 92 96 case .updates:
+37 -1
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 1 + import Foundation 1 2 import Observation 2 3 import Sharing 3 4 ··· 7 8 @Observable 8 9 final class WorktreeTerminalManager { 9 10 private let runtime: GhosttyRuntime 11 + private(set) var socketServer: AgentHookSocketServer? 10 12 private var states: [Worktree.ID: WorktreeTerminalState] = [:] 11 13 private var notificationsEnabled = true 12 14 private var lastNotificationIndicatorCount: Int? ··· 16 18 var saveLayoutSnapshot: ((Worktree.ID, TerminalLayoutSnapshot?) -> Void)? 17 19 var loadLayoutSnapshot: ((Worktree.ID) -> TerminalLayoutSnapshot?)? 18 20 19 - init(runtime: GhosttyRuntime) { 21 + init(runtime: GhosttyRuntime, socketServer: AgentHookSocketServer? = nil) { 20 22 self.runtime = runtime 23 + let resolvedServer = socketServer ?? AgentHookSocketServer() 24 + guard resolvedServer.socketPath != nil else { 25 + self.socketServer = nil 26 + terminalLogger.warning("Agent hook socket server unavailable") 27 + return 28 + } 29 + self.socketServer = resolvedServer 30 + configureSocketServer(resolvedServer) 31 + } 32 + 33 + private func configureSocketServer(_ server: AgentHookSocketServer) { 34 + server.onBusy = { [weak self] worktreeID, tabID, surfaceID, active in 35 + let decoded = worktreeID.removingPercentEncoding ?? worktreeID 36 + guard let state = self?.states[decoded] else { 37 + terminalLogger.debug("Dropped busy update for unknown worktree \(decoded)") 38 + return 39 + } 40 + state.setAgentBusy( 41 + surfaceID: surfaceID, 42 + tabID: TerminalTabID(rawValue: tabID), 43 + active: active 44 + ) 45 + } 46 + server.onNotification = { [weak self] worktreeID, _, surfaceID, notification in 47 + let decoded = worktreeID.removingPercentEncoding ?? worktreeID 48 + guard let state = self?.states[decoded] else { 49 + terminalLogger.debug("Dropped hook notification for unknown worktree \(decoded)") 50 + return 51 + } 52 + let title = notification.title ?? notification.agent 53 + let body = notification.body ?? "" 54 + state.appendHookNotification(title: title, body: body, surfaceID: surfaceID) 55 + } 21 56 } 22 57 23 58 func handleCommand(_ command: TerminalClient.Command) { ··· 153 188 worktree: worktree, 154 189 runSetupScript: runSetupScript 155 190 ) 191 + state.socketPath = socketServer?.socketPath 156 192 // Load saved layout snapshot for restoration (skip when a setup script is pending). 157 193 if !runSetupScript { 158 194 state.pendingLayoutSnapshot = loadLayoutSnapshot?(worktree.id)
+121 -8
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 1 1 import AppKit 2 2 import CoreGraphics 3 + import Dependencies 3 4 import Foundation 4 5 import GhosttyKit 5 6 import Observation ··· 7 8 8 9 private let blockingScriptLogger = SupaLogger("BlockingScript") 9 10 private let layoutLogger = SupaLogger("Layout") 11 + private let terminalStateLogger = SupaLogger("Terminal") 10 12 11 13 @MainActor 12 14 @Observable ··· 25 27 private var surfaces: [UUID: GhosttySurfaceView] = [:] 26 28 private var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] 27 29 var tabIsRunningById: [TerminalTabID: Bool] = [:] 30 + var socketPath: String? 28 31 private(set) var shouldHideTabBar = false 29 32 private var blockingScripts: [TerminalTabID: BlockingScriptKind] = [:] 30 33 private var blockingScriptLaunchDirectories: [TerminalTabID: URL] = [:] ··· 38 41 private var lastWindowIsVisible: Bool? 39 42 var notifications: [WorktreeTerminalNotification] = [] 40 43 var notificationsEnabled = true 44 + @ObservationIgnored @Dependency(\.date.now) private var now 45 + private var recentHookBySurfaceID: [UUID: (text: String, recordedAt: Date)] = [:] 41 46 var hasUnseenNotification: Bool { 42 47 notifications.contains { !$0.isRead } 43 48 } 49 + #if DEBUG 50 + var debugRecentHookCount: Int { 51 + recentHookBySurfaceID.count 52 + } 53 + #endif 44 54 var isSelected: () -> Bool = { false } 45 55 var onNotificationReceived: ((String, String) -> Void)? 46 56 var onNotificationIndicatorChanged: (() -> Void)? ··· 69 79 } 70 80 71 81 var taskStatus: WorktreeTaskStatus { 72 - tabIsRunningById.values.contains(true) ? .running : .idle 82 + trees.keys.contains(where: { isTabBusy($0) }) ? .running : .idle 83 + } 84 + 85 + private func isTabBusy(_ tabId: TerminalTabID) -> Bool { 86 + guard let tree = trees[tabId] else { return false } 87 + return tree.leaves().contains { surface in 88 + isRunningProgressState(surface.bridge.state.progressState) 89 + || surface.bridge.state.agentBusy 90 + } 73 91 } 74 92 75 93 func isBlockingScriptRunning(kind: BlockingScriptKind) -> Bool { ··· 213 231 blockingScripts[tabId] = kind 214 232 blockingScriptLaunchDirectories[tabId] = launch.directoryURL 215 233 lastBlockingScriptTabByKind[kind] = tabId 234 + tabManager.updateDirty(tabId, isDirty: true) 235 + emitTaskStatusIfChanged() 216 236 217 237 blockingScriptLogger.info("Started \(kind.tabTitle) for worktree \(worktree.id)") 218 238 return tabId ··· 260 280 } 261 281 tabManager.selectTab(tabId) 262 282 focusSurface(in: tabId) 283 + emitTaskStatusIfChanged() 284 + } 285 + 286 + /// Sets or clears the agent busy flag on a specific surface. 287 + func setAgentBusy(surfaceID: UUID, tabID: TerminalTabID, active: Bool) { 288 + guard let surface = surfaces[surfaceID] else { 289 + terminalStateLogger.debug("Dropped busy update for unknown surface \(surfaceID) in worktree \(worktree.id)") 290 + return 291 + } 292 + surface.bridge.state.agentBusy = active 293 + tabManager.updateDirty(tabID, isDirty: isTabBusy(tabID)) 263 294 emitTaskStatusIfChanged() 264 295 } 265 296 ··· 572 603 trees.removeAll() 573 604 focusedSurfaceIdByTab.removeAll() 574 605 tabIsRunningById.removeAll() 606 + // Agent busy state lives on GhosttySurfaceState and is cleaned up 607 + // when surfaces are removed. 575 608 let pendingKinds = Set(blockingScripts.values) 576 609 blockingScripts.removeAll() 577 610 lastBlockingScriptTabByKind.removeAll() ··· 894 927 reportedTabId: TerminalTabID? 895 928 ) { 896 929 tabManager.unlockAndUpdateTitle(tabId, title: "\(worktree.name) \(nextTabIndex())") 930 + tabManager.updateDirty(tabId, isDirty: isTabBusy(tabId)) 931 + emitTaskStatusIfChanged() 897 932 898 933 Task { @MainActor [weak self] in 899 934 guard let self else { ··· 908 943 } 909 944 } 910 945 946 + private func surfaceEnvironment(tabId: TerminalTabID, surfaceID: UUID) -> [String: String] { 947 + var env = worktree.scriptEnvironment 948 + env["SUPACODE_WORKTREE_ID"] = 949 + worktree.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) 950 + ?? worktree.id 951 + env["SUPACODE_TAB_ID"] = tabId.rawValue.uuidString 952 + env["SUPACODE_SURFACE_ID"] = surfaceID.uuidString 953 + if let socketPath { 954 + env["SUPACODE_SOCKET_PATH"] = socketPath 955 + } 956 + return env 957 + } 958 + 911 959 private func createSurface( 912 960 tabId: TerminalTabID, 913 961 command: String? = nil, ··· 916 964 inheritingFromSurfaceId: UUID?, 917 965 context: ghostty_surface_context_e 918 966 ) -> GhosttySurfaceView { 967 + let surfaceID = UUID() 919 968 let inherited = inheritedSurfaceConfig(fromSurfaceId: inheritingFromSurfaceId, context: context) 920 969 let view = GhosttySurfaceView( 970 + id: surfaceID, 921 971 runtime: runtime, 922 972 workingDirectory: workingDirectoryOverride ?? inherited.workingDirectory ?? worktree.workingDirectory, 923 973 command: command, 924 974 initialInput: initialInput, 925 - environmentVariables: worktree.scriptEnvironment, 975 + environmentVariables: surfaceEnvironment(tabId: tabId, surfaceID: surfaceID), 926 976 fontSize: inherited.fontSize, 927 977 context: context 928 978 ) ··· 1049 1099 emitFocusChangedIfNeeded(surface.id) 1050 1100 } 1051 1101 1052 - private func appendNotification(title: String, body: String, surfaceId: UUID) { 1102 + /// Appends a notification from an agent hook on a specific surface. 1103 + func appendHookNotification(title: String, body: String, surfaceID: UUID) { 1104 + guard surfaces[surfaceID] != nil else { 1105 + terminalStateLogger.debug("Dropped hook notification for unknown surface \(surfaceID) in worktree \(worktree.id)") 1106 + return 1107 + } 1108 + // Record for deduplication against later OSC 9 notifications. 1109 + if let normalized = Self.normalizedText("\(title) \(body)") { 1110 + recentHookBySurfaceID[surfaceID] = (text: normalized, recordedAt: now) 1111 + } 1112 + appendNotification(title: title, body: body, surfaceId: surfaceID, fromHook: true) 1113 + } 1114 + 1115 + private func appendNotification( 1116 + title: String, 1117 + body: String, 1118 + surfaceId: UUID, 1119 + fromHook: Bool = false 1120 + ) { 1053 1121 let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) 1054 1122 let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) 1055 1123 guard !(trimmedTitle.isEmpty && trimmedBody.isEmpty) else { return } ··· 1067 1135 ) 1068 1136 emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) 1069 1137 } 1138 + // Suppress OSC 9 system notifications that duplicate a recent hook notification. 1139 + if !fromHook, shouldSuppressDesktopNotification(title: trimmedTitle, body: trimmedBody, surfaceId: surfaceId) { 1140 + return 1141 + } 1070 1142 onNotificationReceived?(trimmedTitle, trimmedBody) 1071 1143 } 1072 1144 1145 + // MARK: - Notification deduplication (matches supaterm's approach). 1146 + 1147 + private static let notificationCoalescingWindow: TimeInterval = 2 1148 + 1149 + private static let genericCompletionTexts: Set<String> = [ 1150 + "agent turn complete", 1151 + "task complete", 1152 + "turn complete", 1153 + ] 1154 + 1155 + private func shouldSuppressDesktopNotification(title: String, body: String, surfaceId: UUID) -> Bool { 1156 + guard 1157 + let terminalText = Self.normalizedText("\(title) \(body)"), 1158 + let recent = recentHookBySurfaceID[surfaceId], 1159 + now.timeIntervalSince(recent.recordedAt) <= Self.notificationCoalescingWindow 1160 + else { 1161 + return false 1162 + } 1163 + if terminalText == recent.text { return true } 1164 + if recent.text.hasPrefix(terminalText) { return true } 1165 + if Self.genericCompletionTexts.contains(terminalText) { return true } 1166 + return false 1167 + } 1168 + 1169 + private static func normalizedText(_ value: String) -> String? { 1170 + let collapsed = 1171 + value 1172 + .split(whereSeparator: \.isWhitespace) 1173 + .joined(separator: " ") 1174 + .lowercased() 1175 + .trimmingCharacters(in: .punctuationCharacters) 1176 + return collapsed.isEmpty ? nil : collapsed 1177 + } 1178 + 1179 + private func cleanupSurfaceState(for surfaceID: UUID) { 1180 + recentHookBySurfaceID.removeValue(forKey: surfaceID) 1181 + surfaces.removeValue(forKey: surfaceID) 1182 + } 1183 + 1073 1184 private func removeTree(for tabId: TerminalTabID) { 1074 1185 guard let tree = trees.removeValue(forKey: tabId) else { return } 1075 1186 for surface in tree.leaves() { 1076 1187 surface.closeSurface() 1077 - surfaces.removeValue(forKey: surface.id) 1188 + cleanupSurfaceState(for: surface.id) 1078 1189 } 1079 1190 focusedSurfaceIdByTab.removeValue(forKey: tabId) 1080 1191 tabIsRunningById.removeValue(forKey: tabId) ··· 1100 1211 isRunningProgressState(surface.bridge.state.progressState) 1101 1212 } 1102 1213 tabIsRunningById[tabId] = isRunningNow 1103 - tabManager.updateDirty(tabId, isDirty: isRunningNow) 1214 + tabManager.updateDirty(tabId, isDirty: isTabBusy(tabId)) 1104 1215 emitTaskStatusIfChanged() 1105 1216 } 1106 1217 ··· 1199 1310 guard surfaces[view.id] != nil else { return } 1200 1311 guard let tabId = tabId(containing: view.id), let tree = trees[tabId] else { 1201 1312 view.closeSurface() 1202 - surfaces.removeValue(forKey: view.id) 1313 + cleanupSurfaceState(for: view.id) 1203 1314 return 1204 1315 } 1205 1316 guard let node = tree.find(id: view.id) else { 1206 1317 view.closeSurface() 1207 - surfaces.removeValue(forKey: view.id) 1318 + cleanupSurfaceState(for: view.id) 1208 1319 return 1209 1320 } 1210 1321 let nextSurface = ··· 1213 1324 : nil 1214 1325 let newTree = tree.removing(node) 1215 1326 view.closeSurface() 1216 - surfaces.removeValue(forKey: view.id) 1327 + cleanupSurfaceState(for: view.id) 1217 1328 if newTree.isEmpty { 1218 1329 trees.removeValue(forKey: tabId) 1219 1330 focusedSurfaceIdByTab.removeValue(forKey: tabId) 1331 + tabIsRunningById.removeValue(forKey: tabId) 1220 1332 cleanupBlockingScriptLaunchDirectory(for: tabId) 1221 1333 tabManager.closeTab(tabId) 1222 1334 updateShouldHideTabBar() ··· 1229 1341 lastBlockingScriptTabByKind.removeValue(forKey: kind) 1230 1342 } 1231 1343 } 1344 + emitTaskStatusIfChanged() 1232 1345 return 1233 1346 } 1234 1347 updateTree(newTree, for: tabId)
+12 -19
supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift
··· 10 10 11 11 var body: some View { 12 12 HStack(spacing: TerminalTabBarMetrics.contentSpacing) { 13 - if tab.isDirty || tab.icon != nil { 14 - ZStack { 15 - if tab.isDirty { 16 - ProgressView() 17 - .controlSize(.small) 18 - .tint(isActive ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText) 19 - } else if let icon = tab.icon { 20 - Image(systemName: icon) 21 - .imageScale(.small) 22 - .foregroundStyle( 23 - tab.tintColor?.color ?? (isActive ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText) 24 - ) 25 - } 26 - } 27 - .frame( 28 - width: TerminalTabBarMetrics.closeButtonSize, 29 - height: TerminalTabBarMetrics.closeButtonSize 30 - ) 31 - .accessibilityHidden(true) 13 + if let icon = tab.icon { 14 + Image(systemName: icon) 15 + .imageScale(.small) 16 + .foregroundStyle( 17 + tab.tintColor?.color ?? (isActive ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText) 18 + ) 19 + .frame( 20 + width: TerminalTabBarMetrics.closeButtonSize, 21 + height: TerminalTabBarMetrics.closeButtonSize 22 + ) 23 + .accessibilityHidden(true) 32 24 } 33 25 Text(tab.title) 34 26 .font(.caption) 35 27 .lineLimit(1) 36 28 .foregroundStyle(isActive ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText) 29 + .shimmer(isActive: tab.isDirty) 37 30 Spacer(minLength: TerminalTabBarMetrics.contentTrailingSpacing) 38 31 ZStack { 39 32 if showsShortcutHint, let shortcutHint {
+315
supacode/Infrastructure/AgentHookSocketServer.swift
··· 1 + import Darwin 2 + import Foundation 3 + 4 + private nonisolated let socketLogger = SupaLogger("AgentHookSocket") 5 + 6 + /// Lightweight Unix domain socket server that receives agent hook 7 + /// messages from `nc -U`. 8 + /// 9 + /// Two message formats are supported: 10 + /// - **Busy flag**: `<worktreeID> <tabID> <surfaceID> <0|1>\n` 11 + /// - **Notification**: `<worktreeID> <tabID> <surfaceID> <agent>\n<JSON payload>\n` 12 + @MainActor 13 + final class AgentHookSocketServer { 14 + private(set) var socketPath: String? 15 + 16 + private var listenTask: Task<Void, Never>? 17 + /// (worktreeID, tabID, surfaceID, active). 18 + var onBusy: ((String, UUID, UUID, Bool) -> Void)? 19 + /// (worktreeID, tabID, surfaceID, notification). 20 + var onNotification: ((String, UUID, UUID, AgentHookNotification) -> Void)? 21 + 22 + init() { 23 + let uid = getuid() 24 + let pid = ProcessInfo.processInfo.processIdentifier 25 + let directory = "/tmp/supacode-\(uid)" 26 + let path = "\(directory)/pid-\(pid)" 27 + 28 + do { 29 + try FileManager.default.createDirectory( 30 + atPath: directory, 31 + withIntermediateDirectories: true, 32 + attributes: [.posixPermissions: 0o700] 33 + ) 34 + } catch { 35 + socketLogger.warning("Failed to create socket directory: \(error)") 36 + return 37 + } 38 + 39 + Self.pruneStaleSocketFiles(in: directory) 40 + unlink(path) 41 + guard startListening(path: path) else { return } 42 + socketPath = path 43 + } 44 + 45 + /// Removes socket files left behind by processes that are no longer running. 46 + private nonisolated static func pruneStaleSocketFiles(in directory: String) { 47 + guard 48 + let entries = try? FileManager.default.contentsOfDirectory(atPath: directory) 49 + else { return } 50 + for entry in entries { 51 + guard entry.hasPrefix("pid-"), 52 + let pid = Int32(entry.dropFirst(4)) 53 + else { continue } 54 + // kill(pid, 0) returns 0 if the process exists. 55 + guard kill(pid, 0) != 0 else { continue } 56 + let stalePath = "\(directory)/\(entry)" 57 + unlink(stalePath) 58 + socketLogger.info("Pruned stale socket: \(entry)") 59 + } 60 + } 61 + 62 + deinit { 63 + listenTask?.cancel() 64 + if let socketPath { 65 + unlink(socketPath) 66 + } 67 + } 68 + 69 + func shutdown() { 70 + listenTask?.cancel() 71 + listenTask = nil 72 + if let socketPath { 73 + unlink(socketPath) 74 + } 75 + socketPath = nil 76 + } 77 + 78 + // MARK: - Socket lifecycle. 79 + 80 + @discardableResult 81 + private func startListening(path: String) -> Bool { 82 + let socketFD = Self.createSocket(path: path) 83 + guard socketFD >= 0 else { return false } 84 + 85 + listenTask = Task.detached { [weak self] in 86 + socketLogger.info("Listening on \(path)") 87 + defer { close(socketFD) } 88 + 89 + while !Task.isCancelled { 90 + var pollFD = pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0) 91 + let ready = poll(&pollFD, 1, 200) 92 + if ready < 0 { 93 + guard errno == EINTR else { 94 + socketLogger.warning("poll() failed: \(String(cString: strerror(errno)))") 95 + break 96 + } 97 + continue 98 + } 99 + guard ready > 0 else { continue } 100 + 101 + guard let message = Self.acceptAndParse(socketFD: socketFD) else { 102 + continue 103 + } 104 + 105 + await MainActor.run { [weak self] in 106 + switch message { 107 + case .busy(let worktreeID, let tabID, let surfaceID, let active): 108 + self?.onBusy?(worktreeID, tabID, surfaceID, active) 109 + case .notification(let worktreeID, let tabID, let surfaceID, let notification): 110 + self?.onNotification?(worktreeID, tabID, surfaceID, notification) 111 + } 112 + } 113 + } 114 + } 115 + return true 116 + } 117 + 118 + // MARK: - Socket creation (nonisolated). 119 + 120 + private nonisolated static func createSocket(path: String) -> Int32 { 121 + let socketFD = socket(AF_UNIX, SOCK_STREAM, 0) 122 + guard socketFD >= 0 else { 123 + socketLogger.warning("socket() failed: \(String(cString: strerror(errno)))") 124 + return -1 125 + } 126 + 127 + var addr = sockaddr_un() 128 + addr.sun_family = sa_family_t(AF_UNIX) 129 + let pathBytes = path.utf8CString 130 + guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { 131 + socketLogger.warning("Socket path too long: \(path)") 132 + close(socketFD) 133 + return -1 134 + } 135 + _ = withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in 136 + pathBytes.withUnsafeBufferPointer { buffer in 137 + memcpy(sunPath, buffer.baseAddress!, buffer.count) 138 + } 139 + } 140 + 141 + let addrLen = socklen_t(MemoryLayout<sa_family_t>.size + pathBytes.count) 142 + let bindResult = withUnsafePointer(to: &addr) { ptr in 143 + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in 144 + bind(socketFD, sockaddrPtr, addrLen) 145 + } 146 + } 147 + guard bindResult == 0 else { 148 + socketLogger.warning("bind() failed: \(String(cString: strerror(errno)))") 149 + close(socketFD) 150 + return -1 151 + } 152 + 153 + guard listen(socketFD, 8) == 0 else { 154 + socketLogger.warning("listen() failed: \(String(cString: strerror(errno)))") 155 + close(socketFD) 156 + return -1 157 + } 158 + 159 + return socketFD 160 + } 161 + 162 + // MARK: - Connection handling (nonisolated). 163 + 164 + /// Maximum payload size (64 KB) to prevent unbounded memory growth. 165 + private nonisolated static let maxPayloadSize = 65_536 166 + 167 + nonisolated enum Message: Sendable { 168 + case busy(worktreeID: String, tabID: UUID, surfaceID: UUID, active: Bool) 169 + case notification(worktreeID: String, tabID: UUID, surfaceID: UUID, notification: AgentHookNotification) 170 + } 171 + 172 + private nonisolated static func acceptAndParse( 173 + socketFD: Int32 174 + ) -> Message? { 175 + let clientFD = accept(socketFD, nil, nil) 176 + guard clientFD >= 0 else { 177 + let err = errno 178 + if err != EAGAIN, err != EWOULDBLOCK { 179 + socketLogger.warning("accept() failed: \(String(cString: strerror(err)))") 180 + } 181 + return nil 182 + } 183 + 184 + guard let data = readPayload(from: clientFD) else { 185 + close(clientFD) 186 + return nil 187 + } 188 + close(clientFD) 189 + return parse(data: data) 190 + } 191 + 192 + nonisolated static func readPayload( 193 + from clientFD: Int32, 194 + readChunk: (Int32, UnsafeMutableBufferPointer<UInt8>) -> Int = { fileDescriptor, buffer in 195 + guard let baseAddress = buffer.baseAddress else { return 0 } 196 + return Darwin.read(fileDescriptor, baseAddress, buffer.count) 197 + } 198 + ) -> Data? { 199 + var data = Data() 200 + var buffer = [UInt8](repeating: 0, count: 4096) 201 + while true { 202 + let bytesRead = buffer.withUnsafeMutableBufferPointer { buffer in 203 + readChunk(clientFD, buffer) 204 + } 205 + if bytesRead < 0 { 206 + let err = errno 207 + socketLogger.warning("read() failed (\(err)): \(String(cString: strerror(err)))") 208 + return nil 209 + } 210 + if bytesRead == 0 { return data } 211 + data.append(contentsOf: buffer.prefix(bytesRead)) 212 + if data.count > maxPayloadSize { 213 + socketLogger.warning("Payload exceeded \(maxPayloadSize) bytes, dropping connection") 214 + return nil 215 + } 216 + } 217 + } 218 + 219 + nonisolated static func parse(data: Data) -> Message? { 220 + guard let rawString = String(data: data, encoding: .utf8) else { 221 + socketLogger.warning("Dropped non-UTF8 hook payload (\(data.count) bytes)") 222 + return nil 223 + } 224 + 225 + let raw = rawString.trimmingCharacters(in: .whitespacesAndNewlines) 226 + guard !raw.isEmpty else { 227 + socketLogger.debug("Dropped empty hook payload") 228 + return nil 229 + } 230 + 231 + // Format: worktreeID tabID surfaceID <flag|agent>. 232 + // Single line with 4 fields → busy flag. 233 + // Multiple lines → notification (4th field is agent, rest is JSON). 234 + let lines = raw.split(separator: "\n", omittingEmptySubsequences: false) 235 + let headerParts = lines[0].split(separator: " ", maxSplits: 3) 236 + guard 237 + headerParts.count >= 3, 238 + let tabID = UUID(uuidString: String(headerParts[1])), 239 + let surfaceID = UUID(uuidString: String(headerParts[2])) 240 + else { 241 + socketLogger.warning("Malformed header: \(lines[0])") 242 + return nil 243 + } 244 + 245 + let worktreeID = String(headerParts[0]) 246 + 247 + if lines.count == 1, headerParts.count == 4 { 248 + let active = String(headerParts[3]) != "0" 249 + return .busy(worktreeID: worktreeID, tabID: tabID, surfaceID: surfaceID, active: active) 250 + } 251 + 252 + // Multiple lines → notification. Fourth header field is the agent name. 253 + // Agent is a raw string intentionally — left open for custom agents 254 + // since the socket is already listening and anyone can send messages. 255 + let agent = headerParts.count >= 4 ? String(headerParts[3]) : "unknown" 256 + let jsonPayload = lines.dropFirst().joined(separator: "\n") 257 + 258 + guard let jsonData = jsonPayload.data(using: .utf8) else { 259 + socketLogger.warning("Invalid notification payload encoding") 260 + return nil 261 + } 262 + 263 + guard let notification = parseNotification(agent: agent, data: jsonData) else { 264 + return nil 265 + } 266 + return .notification( 267 + worktreeID: worktreeID, 268 + tabID: tabID, 269 + surfaceID: surfaceID, 270 + notification: notification 271 + ) 272 + } 273 + 274 + private nonisolated static func parseNotification( 275 + agent: String, 276 + data: Data 277 + ) -> AgentHookNotification? { 278 + guard let payload = try? JSONDecoder().decode(AgentHookPayload.self, from: data) else { 279 + let preview = String(data: data.prefix(200), encoding: .utf8) ?? "<non-UTF8>" 280 + socketLogger.warning("Failed to decode \(agent) notification payload: \(preview)") 281 + return nil 282 + } 283 + 284 + let body = payload.message ?? payload.lastAssistantMessage 285 + return AgentHookNotification( 286 + agent: agent, 287 + event: payload.hookEventName ?? "unknown", 288 + title: payload.title, 289 + body: body 290 + ) 291 + } 292 + } 293 + 294 + /// Parsed notification from a coding agent hook event. 295 + nonisolated struct AgentHookNotification: Equatable, Sendable { 296 + let agent: String 297 + let event: String 298 + let title: String? 299 + let body: String? 300 + } 301 + 302 + /// Raw JSON payload from a coding agent hook event. 303 + private nonisolated struct AgentHookPayload: Decodable { 304 + let hookEventName: String? 305 + let title: String? 306 + let message: String? 307 + let lastAssistantMessage: String? 308 + 309 + enum CodingKeys: String, CodingKey { 310 + case hookEventName = "hook_event_name" 311 + case title 312 + case message 313 + case lastAssistantMessage = "last_assistant_message" 314 + } 315 + }
+1
supacode/Infrastructure/Ghostty/GhosttySurfaceState.swift
··· 7 7 var title: String? 8 8 var pwd: String? 9 9 var promptTitle: ghostty_action_prompt_title_e? 10 + var agentBusy = false 10 11 var progressState: ghostty_action_progress_report_state_e? 11 12 var progressValue: Int? 12 13 var commandExitCode: Int?
+3 -1
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 66 66 } 67 67 68 68 private let runtime: GhosttyRuntime 69 - let id = UUID() 69 + let id: UUID 70 70 let bridge: GhosttySurfaceBridge 71 71 private(set) var surface: ghostty_surface_t? 72 72 private var surfaceRef: GhosttyRuntime.SurfaceReference? ··· 174 174 override var acceptsFirstResponder: Bool { true } 175 175 176 176 init( 177 + id: UUID, 177 178 runtime: GhosttyRuntime, 178 179 workingDirectory: URL?, 179 180 command: String? = nil, ··· 182 183 fontSize: Float32? = nil, 183 184 context: ghostty_surface_context_e 184 185 ) { 186 + self.id = id 185 187 self.runtime = runtime 186 188 self.bridge = GhosttySurfaceBridge() 187 189 self.fontSize = fontSize ?? 0
+66
supacode/Support/ShimmerModifier.swift
··· 1 + import SwiftUI 2 + 3 + struct ShimmerModifier: ViewModifier { 4 + let isActive: Bool 5 + @State private var animating = false 6 + @Environment(\.layoutDirection) private var layoutDirection 7 + 8 + private let bandSize: CGFloat = 0.3 9 + private let animation: Animation = .linear(duration: 1.5).delay(0.25).repeatForever(autoreverses: false) 10 + private let gradient = Gradient(colors: [ 11 + .black.opacity(0.6), 12 + .black, 13 + .black.opacity(0.6), 14 + ]) 15 + 16 + private var min: CGFloat { 0 - bandSize } 17 + private var max: CGFloat { 1 + bandSize } 18 + 19 + private var startPoint: UnitPoint { 20 + if layoutDirection == .rightToLeft { 21 + return animating ? UnitPoint(x: 0, y: 1) : UnitPoint(x: max, y: min) 22 + } 23 + return animating ? UnitPoint(x: 1, y: 1) : UnitPoint(x: min, y: min) 24 + } 25 + 26 + private var endPoint: UnitPoint { 27 + if layoutDirection == .rightToLeft { 28 + return animating ? UnitPoint(x: min, y: max) : UnitPoint(x: 1, y: 0) 29 + } 30 + return animating ? UnitPoint(x: max, y: max) : UnitPoint(x: 0, y: 0) 31 + } 32 + 33 + func body(content: Content) -> some View { 34 + content 35 + .mask( 36 + LinearGradient( 37 + gradient: isActive ? gradient : Gradient(colors: [.black]), 38 + startPoint: startPoint, 39 + endPoint: endPoint 40 + ) 41 + ) 42 + .animation(isActive ? animation : nil, value: animating) 43 + .onChange(of: isActive) { oldValue, newValue in 44 + guard oldValue != newValue else { return } 45 + if newValue { 46 + animating = false 47 + Task { @MainActor in 48 + animating = true 49 + } 50 + } else { 51 + animating = false 52 + } 53 + } 54 + .task { 55 + guard isActive else { return } 56 + try? await Task.sleep(for: .milliseconds(50)) 57 + animating = true 58 + } 59 + } 60 + } 61 + 62 + extension View { 63 + func shimmer(isActive: Bool) -> some View { 64 + modifier(ShimmerModifier(isActive: isActive)) 65 + } 66 + }
+332
supacodeTests/AgentBusyStateTests.swift
··· 1 + import ConcurrencyExtras 2 + import Dependencies 3 + import DependenciesTestSupport 4 + import Foundation 5 + import Testing 6 + 7 + @testable import supacode 8 + 9 + @MainActor 10 + struct AgentBusyStateTests { 11 + // MARK: - Surface → tab → worktree bubbling. 12 + 13 + @Test func setAgentBusyMakesTaskStatusRunning() { 14 + let worktree = makeWorktree() 15 + let fixture = makeStateWithSurface(worktree: worktree) 16 + #expect(fixture.manager.taskStatus(for: worktree.id) == .idle) 17 + 18 + fixture.state.setAgentBusy(surfaceID: fixture.surface.id, tabID: fixture.tabId, active: true) 19 + 20 + #expect(fixture.manager.taskStatus(for: worktree.id) == .running) 21 + } 22 + 23 + @Test func clearAgentBusyReturnsToIdle() { 24 + let worktree = makeWorktree() 25 + let fixture = makeStateWithSurface(worktree: worktree) 26 + 27 + fixture.state.setAgentBusy(surfaceID: fixture.surface.id, tabID: fixture.tabId, active: true) 28 + #expect(fixture.manager.taskStatus(for: worktree.id) == .running) 29 + 30 + fixture.state.setAgentBusy(surfaceID: fixture.surface.id, tabID: fixture.tabId, active: false) 31 + #expect(fixture.manager.taskStatus(for: worktree.id) == .idle) 32 + } 33 + 34 + @Test func setAgentBusyMarksTabDirty() { 35 + let fixture = makeStateWithSurface() 36 + 37 + // Complete the blocking script to clear initial dirty state. 38 + fixture.surface.bridge.onCommandFinished?(0) 39 + let tabBefore = fixture.state.tabManager.tabs.first { $0.id == fixture.tabId } 40 + #expect(tabBefore?.isDirty == false) 41 + 42 + fixture.state.setAgentBusy(surfaceID: fixture.surface.id, tabID: fixture.tabId, active: true) 43 + 44 + let tabAfter = fixture.state.tabManager.tabs.first { $0.id == fixture.tabId } 45 + #expect(tabAfter?.isDirty == true) 46 + } 47 + 48 + @Test func clearAgentBusyClearsTabDirty() { 49 + let fixture = makeStateWithSurface() 50 + 51 + // Complete the blocking script first. 52 + fixture.surface.bridge.onCommandFinished?(0) 53 + fixture.state.setAgentBusy(surfaceID: fixture.surface.id, tabID: fixture.tabId, active: true) 54 + fixture.state.setAgentBusy(surfaceID: fixture.surface.id, tabID: fixture.tabId, active: false) 55 + 56 + let tab = fixture.state.tabManager.tabs.first { $0.id == fixture.tabId } 57 + #expect(tab?.isDirty == false) 58 + } 59 + 60 + @Test func setAgentBusyOnUnknownSurfaceIsNoOp() { 61 + let worktree = makeWorktree() 62 + let fixture = makeStateWithSurface(worktree: worktree) 63 + 64 + fixture.state.setAgentBusy(surfaceID: UUID(), tabID: fixture.tabId, active: true) 65 + 66 + #expect(fixture.manager.taskStatus(for: worktree.id) == .idle) 67 + } 68 + 69 + @Test func closingBusySurfaceClearsTaskStatus() { 70 + let worktree = makeWorktree() 71 + let fixture = makeStateWithSurface(worktree: worktree) 72 + 73 + fixture.state.setAgentBusy(surfaceID: fixture.surface.id, tabID: fixture.tabId, active: true) 74 + #expect(fixture.manager.taskStatus(for: worktree.id) == .running) 75 + 76 + fixture.state.closeTab(fixture.tabId) 77 + #expect(fixture.manager.taskStatus(for: worktree.id) == .idle) 78 + } 79 + 80 + @Test func multipleSurfacesBusyInDifferentTabs() { 81 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 82 + let worktree = makeWorktree() 83 + 84 + manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo a")) 85 + manager.handleCommand(.runBlockingScript(worktree, kind: .delete, script: "echo b")) 86 + 87 + guard let state = manager.stateIfExists(for: worktree.id) else { 88 + Issue.record("Expected worktree state") 89 + return 90 + } 91 + let tabs = state.tabManager.tabs.map(\.id) 92 + guard tabs.count >= 2 else { 93 + Issue.record("Expected at least two tabs") 94 + return 95 + } 96 + 97 + guard 98 + let surfaceA = state.splitTree(for: tabs[0]).root?.leftmostLeaf(), 99 + let surfaceB = state.splitTree(for: tabs[1]).root?.leftmostLeaf() 100 + else { 101 + Issue.record("Expected surfaces in both tabs") 102 + return 103 + } 104 + 105 + state.setAgentBusy(surfaceID: surfaceA.id, tabID: tabs[0], active: true) 106 + state.setAgentBusy(surfaceID: surfaceB.id, tabID: tabs[1], active: true) 107 + #expect(manager.taskStatus(for: worktree.id) == .running) 108 + 109 + // Clear one — still running because the other is busy. 110 + state.setAgentBusy(surfaceID: surfaceA.id, tabID: tabs[0], active: false) 111 + #expect(manager.taskStatus(for: worktree.id) == .running) 112 + 113 + // Clear the other — now idle. 114 + state.setAgentBusy(surfaceID: surfaceB.id, tabID: tabs[1], active: false) 115 + #expect(manager.taskStatus(for: worktree.id) == .idle) 116 + } 117 + 118 + @Test func taskStatusChangedEmittedOnBusyToggle() async { 119 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 120 + let worktree = makeWorktree() 121 + let stream = manager.eventStream() 122 + 123 + manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) 124 + 125 + guard let state = manager.stateIfExists(for: worktree.id), 126 + let tabId = state.tabManager.selectedTabId, 127 + let surface = state.splitTree(for: tabId).root?.leftmostLeaf() 128 + else { 129 + Issue.record("Expected blocking script tab and surface") 130 + return 131 + } 132 + 133 + state.setAgentBusy(surfaceID: surface.id, tabID: tabId, active: true) 134 + 135 + let event = await nextEvent(stream) { event in 136 + if case .taskStatusChanged(_, let status) = event, status == .running { 137 + return true 138 + } 139 + return false 140 + } 141 + #expect(event != nil) 142 + } 143 + 144 + // MARK: - Notification deduplication. 145 + 146 + @Test(.dependencies) func hookNotificationRecordedForDedup() { 147 + withDependencies { 148 + $0.date = .constant(Date(timeIntervalSince1970: 1000)) 149 + } operation: { 150 + let fixture = makeStateWithSurface() 151 + 152 + fixture.state.appendHookNotification( 153 + title: "Done", 154 + body: "All complete", 155 + surfaceID: fixture.surface.id, 156 + ) 157 + 158 + #expect(fixture.state.notifications.count == 1) 159 + #expect(fixture.state.notifications[0].title == "Done") 160 + } 161 + } 162 + 163 + @Test(.dependencies) func oscNotificationSuppressedWithinWindow() { 164 + withDependencies { 165 + $0.date = .constant(Date(timeIntervalSince1970: 1000)) 166 + } operation: { 167 + let fixture = makeStateWithSurface() 168 + var systemNotificationCount = 0 169 + fixture.state.onNotificationReceived = { _, _ in 170 + systemNotificationCount += 1 171 + } 172 + 173 + // Hook notification fires system notification. 174 + fixture.state.appendHookNotification( 175 + title: "Done", 176 + body: "Task complete", 177 + surfaceID: fixture.surface.id, 178 + ) 179 + #expect(systemNotificationCount == 1) 180 + 181 + // OSC 9 with identical text within the 2s window (via bridge callback). 182 + fixture.surface.bridge.onDesktopNotification?("Done", "Task complete") 183 + 184 + // The system notification should be suppressed (still 1). 185 + #expect(systemNotificationCount == 1) 186 + // But the in-app notification is still recorded. 187 + #expect(fixture.state.notifications.count == 2) 188 + } 189 + } 190 + 191 + @Test(.dependencies) func oscNotificationNotSuppressedAfterWindow() { 192 + let baseDate = Date(timeIntervalSince1970: 1000) 193 + let currentDate = LockIsolated(baseDate) 194 + 195 + withDependencies { 196 + $0.date = .init { currentDate.value } 197 + } operation: { 198 + let fixture = makeStateWithSurface() 199 + var systemNotificationCount = 0 200 + fixture.state.onNotificationReceived = { _, _ in 201 + systemNotificationCount += 1 202 + } 203 + 204 + // Hook notification at t=1000. 205 + fixture.state.appendHookNotification( 206 + title: "Done", 207 + body: "All complete", 208 + surfaceID: fixture.surface.id, 209 + ) 210 + #expect(systemNotificationCount == 1) 211 + 212 + // OSC 9 at t=1003 (beyond the 2s window). 213 + currentDate.setValue(baseDate.addingTimeInterval(3)) 214 + fixture.surface.bridge.onDesktopNotification?("Done", "All complete") 215 + 216 + // Not suppressed — fires system notification. 217 + #expect(systemNotificationCount == 2) 218 + } 219 + } 220 + 221 + @Test(.dependencies) func genericCompletionTextSuppressedWithinWindow() { 222 + withDependencies { 223 + $0.date = .constant(Date(timeIntervalSince1970: 1000)) 224 + } operation: { 225 + let fixture = makeStateWithSurface() 226 + var systemNotificationCount = 0 227 + fixture.state.onNotificationReceived = { _, _ in 228 + systemNotificationCount += 1 229 + } 230 + 231 + // Hook notification with specific text. 232 + fixture.state.appendHookNotification( 233 + title: "Claude", 234 + body: "Refactored the module", 235 + surfaceID: fixture.surface.id, 236 + ) 237 + #expect(systemNotificationCount == 1) 238 + 239 + // OSC 9 with generic "Task Complete" text. 240 + fixture.surface.bridge.onDesktopNotification?("Task Complete", "") 241 + 242 + // Generic completion text is suppressed. 243 + #expect(systemNotificationCount == 1) 244 + } 245 + } 246 + 247 + @Test(.dependencies) func closingTabCleansRecentHookEntries() { 248 + withDependencies { 249 + $0.date = .constant(Date(timeIntervalSince1970: 1000)) 250 + } operation: { 251 + let fixture = makeStateWithSurface() 252 + 253 + fixture.state.appendHookNotification( 254 + title: "Done", 255 + body: "All complete", 256 + surfaceID: fixture.surface.id, 257 + ) 258 + #expect(fixture.state.debugRecentHookCount == 1) 259 + 260 + fixture.state.closeTab(fixture.tabId) 261 + 262 + #expect(fixture.state.debugRecentHookCount == 0) 263 + } 264 + } 265 + 266 + @Test(.dependencies) func closingSurfaceCleansRecentHookEntries() { 267 + withDependencies { 268 + $0.date = .constant(Date(timeIntervalSince1970: 1000)) 269 + } operation: { 270 + let fixture = makeStateWithSurface() 271 + #expect(fixture.state.performSplitAction(.newSplit(direction: .right), for: fixture.surface.id)) 272 + 273 + let leaves = fixture.state.splitTree(for: fixture.tabId).leaves() 274 + guard let splitSurface = leaves.first(where: { $0.id != fixture.surface.id }) else { 275 + Issue.record("Expected split surface") 276 + return 277 + } 278 + 279 + fixture.state.appendHookNotification( 280 + title: "Done", 281 + body: "All complete", 282 + surfaceID: splitSurface.id, 283 + ) 284 + #expect(fixture.state.debugRecentHookCount == 1) 285 + 286 + splitSurface.bridge.onCloseRequest?(false) 287 + 288 + #expect(fixture.state.debugRecentHookCount == 0) 289 + } 290 + } 291 + 292 + // MARK: - Helpers. 293 + 294 + private func makeWorktree() -> Worktree { 295 + Worktree( 296 + id: "/tmp/repo/wt-1", 297 + name: "wt-1", 298 + detail: "detail", 299 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), 300 + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), 301 + ) 302 + } 303 + 304 + private struct SurfaceFixture { 305 + let manager: WorktreeTerminalManager 306 + let state: WorktreeTerminalState 307 + let tabId: TerminalTabID 308 + let surface: GhosttySurfaceView 309 + } 310 + 311 + private func makeStateWithSurface(worktree: Worktree? = nil) -> SurfaceFixture { 312 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 313 + let resolvedWorktree = worktree ?? makeWorktree() 314 + 315 + manager.handleCommand(.runBlockingScript(resolvedWorktree, kind: .archive, script: "echo ok")) 316 + 317 + let state = manager.stateIfExists(for: resolvedWorktree.id)! 318 + let tabId = state.tabManager.selectedTabId! 319 + let surface = state.splitTree(for: tabId).root!.leftmostLeaf() 320 + return SurfaceFixture(manager: manager, state: state, tabId: tabId, surface: surface) 321 + } 322 + 323 + private func nextEvent( 324 + _ stream: AsyncStream<TerminalClient.Event>, 325 + matching predicate: (TerminalClient.Event) -> Bool 326 + ) async -> TerminalClient.Event? { 327 + for await event in stream where predicate(event) { 328 + return event 329 + } 330 + return nil 331 + } 332 + }
+85
supacodeTests/AgentHookCommandTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct AgentHookCommandTests { 7 + // MARK: - Command generation. 8 + 9 + @Test func busyActiveCommandContainsFlag1() { 10 + let command = AgentHookSettingsCommand.busyCommand(active: true) 11 + #expect(command.contains("$SUPACODE_SURFACE_ID 1")) 12 + } 13 + 14 + @Test func busyInactiveCommandContainsFlag0() { 15 + let command = AgentHookSettingsCommand.busyCommand(active: false) 16 + #expect(command.contains("$SUPACODE_SURFACE_ID 0")) 17 + } 18 + 19 + @Test func busyCommandChecksAllFourEnvVars() { 20 + let command = AgentHookSettingsCommand.busyCommand(active: true) 21 + #expect(command.contains("SUPACODE_SOCKET_PATH")) 22 + #expect(command.contains("SUPACODE_WORKTREE_ID")) 23 + #expect(command.contains("SUPACODE_TAB_ID")) 24 + #expect(command.contains("SUPACODE_SURFACE_ID")) 25 + } 26 + 27 + @Test func busyCommandSuppressesErrors() { 28 + let command = AgentHookSettingsCommand.busyCommand(active: true) 29 + #expect(command.hasSuffix("2>/dev/null || true")) 30 + } 31 + 32 + @Test func notificationCommandIncludesAgent() { 33 + let command = AgentHookSettingsCommand.notificationCommand(agent: "claude") 34 + #expect(command.contains("claude")) 35 + } 36 + 37 + @Test func notificationCommandIncludesAllThreeIDs() { 38 + let command = AgentHookSettingsCommand.notificationCommand(agent: "codex") 39 + #expect(command.contains("$SUPACODE_WORKTREE_ID")) 40 + #expect(command.contains("$SUPACODE_TAB_ID")) 41 + #expect(command.contains("$SUPACODE_SURFACE_ID")) 42 + } 43 + 44 + // MARK: - Command ownership. 45 + 46 + @Test func currentCommandIsRecognized() { 47 + let command = AgentHookSettingsCommand.busyCommand(active: true) 48 + #expect(AgentHookCommandOwnership.isSupacodeManagedCommand(command)) 49 + } 50 + 51 + @Test func notificationCommandIsRecognized() { 52 + let command = AgentHookSettingsCommand.notificationCommand(agent: "claude") 53 + #expect(AgentHookCommandOwnership.isSupacodeManagedCommand(command)) 54 + } 55 + 56 + @Test func legacyCommandIsRecognized() { 57 + let legacy = "SUPACODE_CLI_PATH=/usr/bin/supacode agent-hook --stop" 58 + #expect(AgentHookCommandOwnership.isSupacodeManagedCommand(legacy)) 59 + #expect(AgentHookCommandOwnership.isLegacyCommand(legacy)) 60 + } 61 + 62 + @Test func legacyCommandRequiresBothMarkers() { 63 + #expect(!AgentHookCommandOwnership.isLegacyCommand("SUPACODE_CLI_PATH only")) 64 + #expect(!AgentHookCommandOwnership.isLegacyCommand("agent-hook only")) 65 + } 66 + 67 + @Test func unrelatedCommandIsNotRecognized() { 68 + #expect(!AgentHookCommandOwnership.isSupacodeManagedCommand("echo hello")) 69 + #expect(!AgentHookCommandOwnership.isSupacodeManagedCommand(nil)) 70 + } 71 + 72 + @Test func currentCommandIsNotLegacy() { 73 + let command = AgentHookSettingsCommand.busyCommand(active: true) 74 + #expect(!AgentHookCommandOwnership.isLegacyCommand(command)) 75 + } 76 + 77 + // MARK: - Shared constants consistency. 78 + 79 + @Test func socketPathEnvVarPresentInGeneratedCommands() { 80 + let busy = AgentHookSettingsCommand.busyCommand(active: true) 81 + let notify = AgentHookSettingsCommand.notificationCommand(agent: "test") 82 + #expect(busy.contains(AgentHookSettingsCommand.socketPathEnvVar)) 83 + #expect(notify.contains(AgentHookSettingsCommand.socketPathEnvVar)) 84 + } 85 + }
+295
supacodeTests/AgentHookSettingsFileInstallerTests.swift
··· 1 + import ConcurrencyExtras 2 + import Foundation 3 + import Testing 4 + 5 + @testable import supacode 6 + 7 + struct AgentHookSettingsFileInstallerTests { 8 + private let fileManager = FileManager.default 9 + 10 + private func makeErrors() -> AgentHookSettingsFileInstaller.Errors { 11 + .init( 12 + invalidEventHooks: { TestInstallerError.invalidEventHooks($0) }, 13 + invalidHooksObject: { TestInstallerError.invalidHooksObject }, 14 + invalidJSON: { TestInstallerError.invalidJSON($0) }, 15 + invalidRootObject: { TestInstallerError.invalidRootObject }, 16 + ) 17 + } 18 + 19 + private func makeInstaller() -> AgentHookSettingsFileInstaller { 20 + AgentHookSettingsFileInstaller(fileManager: fileManager, errors: makeErrors()) 21 + } 22 + 23 + private func makeTempURL() -> URL { 24 + URL(fileURLWithPath: NSTemporaryDirectory()) 25 + .appendingPathComponent("supacode-test-\(UUID().uuidString)") 26 + .appendingPathComponent("settings.json") 27 + } 28 + 29 + private func sampleHookGroups() -> [String: [JSONValue]] { 30 + [ 31 + "Stop": [ 32 + .object([ 33 + "hooks": .array([ 34 + .object([ 35 + "type": "command", 36 + "command": .string(AgentHookSettingsCommand.busyCommand(active: false)), 37 + "timeout": 10, 38 + ]), 39 + ]), 40 + ]), 41 + ], 42 + ] 43 + } 44 + 45 + // MARK: - Install. 46 + 47 + @Test func installIntoEmptyFileCreatesCorrectStructure() throws { 48 + let url = makeTempURL() 49 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 50 + 51 + let installer = makeInstaller() 52 + try installer.install(settingsURL: url, hookGroupsByEvent: sampleHookGroups()) 53 + 54 + let data = try Data(contentsOf: url) 55 + let root = try JSONDecoder().decode(JSONValue.self, from: data) 56 + guard let hooksObject = root.objectValue?["hooks"]?.objectValue else { 57 + Issue.record("Expected hooks object") 58 + return 59 + } 60 + #expect(hooksObject["Stop"] != nil) 61 + let stopGroups = hooksObject["Stop"]?.arrayValue 62 + #expect(stopGroups?.count == 1) 63 + } 64 + 65 + @Test func installPreservesExistingNonHookKeys() throws { 66 + let url = makeTempURL() 67 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 68 + 69 + // Write a file with existing keys. 70 + let existing: JSONValue = .object(["customKey": "customValue"]) 71 + try fileManager.createDirectory( 72 + at: url.deletingLastPathComponent(), 73 + withIntermediateDirectories: true, 74 + ) 75 + try JSONEncoder().encode(existing).write(to: url) 76 + 77 + let installer = makeInstaller() 78 + try installer.install(settingsURL: url, hookGroupsByEvent: sampleHookGroups()) 79 + 80 + let data = try Data(contentsOf: url) 81 + let root = try JSONDecoder().decode(JSONValue.self, from: data) 82 + #expect(root.objectValue?["customKey"]?.stringValue == "customValue") 83 + #expect(root.objectValue?["hooks"] != nil) 84 + } 85 + 86 + @Test func installIsIdempotent() throws { 87 + let url = makeTempURL() 88 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 89 + 90 + let installer = makeInstaller() 91 + let groups = sampleHookGroups() 92 + try installer.install(settingsURL: url, hookGroupsByEvent: groups) 93 + try installer.install(settingsURL: url, hookGroupsByEvent: groups) 94 + 95 + let data = try Data(contentsOf: url) 96 + let root = try JSONDecoder().decode(JSONValue.self, from: data) 97 + let stopGroups = root.objectValue?["hooks"]?.objectValue?["Stop"]?.arrayValue 98 + // Should have exactly one group, not duplicates. 99 + #expect(stopGroups?.count == 1) 100 + } 101 + 102 + @Test func installPrunesLegacyCommands() throws { 103 + let url = makeTempURL() 104 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 105 + 106 + // Write a file with a legacy command. 107 + let legacy: JSONValue = .object([ 108 + "hooks": .object([ 109 + "Stop": .array([ 110 + .object([ 111 + "hooks": .array([ 112 + .object([ 113 + "type": "command", 114 + "command": "SUPACODE_CLI_PATH agent-hook --stop", 115 + ]), 116 + ]), 117 + ]), 118 + ]), 119 + ]), 120 + ]) 121 + try fileManager.createDirectory( 122 + at: url.deletingLastPathComponent(), 123 + withIntermediateDirectories: true, 124 + ) 125 + try JSONEncoder().encode(legacy).write(to: url) 126 + 127 + let installer = makeInstaller() 128 + try installer.install(settingsURL: url, hookGroupsByEvent: sampleHookGroups()) 129 + 130 + let data = try Data(contentsOf: url) 131 + let root = try JSONDecoder().decode(JSONValue.self, from: data) 132 + let stopGroups = root.objectValue?["hooks"]?.objectValue?["Stop"]?.arrayValue ?? [] 133 + 134 + // Legacy command should be gone, only the new one remains. 135 + for group in stopGroups { 136 + guard let hooks = group.objectValue?["hooks"]?.arrayValue else { continue } 137 + for hook in hooks { 138 + let cmd = hook.objectValue?["command"]?.stringValue ?? "" 139 + #expect(!cmd.contains("SUPACODE_CLI_PATH")) 140 + } 141 + } 142 + } 143 + 144 + // MARK: - Uninstall. 145 + 146 + @Test func uninstallRemovesOnlyMatchingCommands() throws { 147 + let url = makeTempURL() 148 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 149 + 150 + let installer = makeInstaller() 151 + let groups = sampleHookGroups() 152 + try installer.install(settingsURL: url, hookGroupsByEvent: groups) 153 + 154 + // Also add a third-party hook manually. 155 + var data = try Data(contentsOf: url) 156 + var root = try JSONDecoder().decode(JSONValue.self, from: data).objectValue! 157 + var hooks = root["hooks"]!.objectValue! 158 + var stopGroups = hooks["Stop"]!.arrayValue! 159 + stopGroups.append( 160 + .object([ 161 + "hooks": .array([ 162 + .object([ 163 + "type": "command", 164 + "command": "echo third-party", 165 + ]), 166 + ]), 167 + ])) 168 + hooks["Stop"] = .array(stopGroups) 169 + root["hooks"] = .object(hooks) 170 + let encoder = JSONEncoder() 171 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 172 + try encoder.encode(JSONValue.object(root)).write(to: url) 173 + 174 + // Uninstall our hooks. 175 + try installer.uninstall(settingsURL: url, hookGroupsByEvent: groups) 176 + 177 + data = try Data(contentsOf: url) 178 + let updated = try JSONDecoder().decode(JSONValue.self, from: data) 179 + let remaining = updated.objectValue?["hooks"]?.objectValue?["Stop"]?.arrayValue ?? [] 180 + 181 + // Third-party hook should remain. 182 + #expect(remaining.count == 1) 183 + let cmd = remaining[0].objectValue?["hooks"]?.arrayValue?[0].objectValue?["command"]?.stringValue 184 + #expect(cmd == "echo third-party") 185 + } 186 + 187 + @Test func uninstallOnMissingFileIsNoOp() throws { 188 + let url = makeTempURL() 189 + let installer = makeInstaller() 190 + // Should not throw — file doesn't exist. 191 + try installer.uninstall(settingsURL: url, hookGroupsByEvent: sampleHookGroups()) 192 + } 193 + 194 + // MARK: - containsMatchingHooks. 195 + 196 + @Test func containsMatchingHooksReturnsTrueWhenPresent() throws { 197 + let url = makeTempURL() 198 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 199 + 200 + let installer = makeInstaller() 201 + let groups = sampleHookGroups() 202 + try installer.install(settingsURL: url, hookGroupsByEvent: groups) 203 + 204 + #expect(installer.containsMatchingHooks(settingsURL: url, hookGroupsByEvent: groups)) 205 + } 206 + 207 + @Test func containsMatchingHooksReturnsFalseWhenMissing() { 208 + let url = makeTempURL() 209 + let installer = makeInstaller() 210 + #expect(!installer.containsMatchingHooks(settingsURL: url, hookGroupsByEvent: sampleHookGroups())) 211 + } 212 + 213 + @Test func containsMatchingHooksLogsInvalidJSONErrors() throws { 214 + let url = makeTempURL() 215 + let warnings = LockIsolated<[String]>([]) 216 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 217 + 218 + try fileManager.createDirectory( 219 + at: url.deletingLastPathComponent(), 220 + withIntermediateDirectories: true, 221 + ) 222 + try Data("not json".utf8).write(to: url) 223 + 224 + let installer = AgentHookSettingsFileInstaller( 225 + fileManager: fileManager, 226 + errors: makeErrors(), 227 + logWarning: { message in 228 + warnings.withValue { $0.append(message) } 229 + } 230 + ) 231 + 232 + #expect(!installer.containsMatchingHooks(settingsURL: url, hookGroupsByEvent: sampleHookGroups())) 233 + #expect(warnings.value.count == 1) 234 + #expect(warnings.value[0].contains(url.path)) 235 + } 236 + 237 + @Test func containsMatchingHooksDoesNotLogMissingFile() { 238 + let url = makeTempURL() 239 + let warnings = LockIsolated<[String]>([]) 240 + let installer = AgentHookSettingsFileInstaller( 241 + fileManager: fileManager, 242 + errors: makeErrors(), 243 + logWarning: { message in 244 + warnings.withValue { $0.append(message) } 245 + } 246 + ) 247 + 248 + #expect(!installer.containsMatchingHooks(settingsURL: url, hookGroupsByEvent: sampleHookGroups())) 249 + #expect(warnings.value.isEmpty) 250 + } 251 + 252 + // MARK: - Error handling. 253 + 254 + @Test func invalidJSONFileThrowsWithDetail() throws { 255 + let url = makeTempURL() 256 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 257 + 258 + try fileManager.createDirectory( 259 + at: url.deletingLastPathComponent(), 260 + withIntermediateDirectories: true, 261 + ) 262 + try Data("not json".utf8).write(to: url) 263 + 264 + let installer = makeInstaller() 265 + #expect(throws: TestInstallerError.self) { 266 + try installer.install(settingsURL: url, hookGroupsByEvent: sampleHookGroups()) 267 + } 268 + } 269 + 270 + @Test func jsonArrayRootThrows() throws { 271 + let url = makeTempURL() 272 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 273 + 274 + try fileManager.createDirectory( 275 + at: url.deletingLastPathComponent(), 276 + withIntermediateDirectories: true, 277 + ) 278 + try Data("[1,2,3]".utf8).write(to: url) 279 + 280 + let installer = makeInstaller() 281 + do { 282 + try installer.install(settingsURL: url, hookGroupsByEvent: sampleHookGroups()) 283 + Issue.record("Expected invalidRootObject error") 284 + } catch let error as TestInstallerError { 285 + #expect(error == .invalidRootObject) 286 + } 287 + } 288 + } 289 + 290 + private enum TestInstallerError: Error, Equatable { 291 + case invalidEventHooks(String) 292 + case invalidHooksObject 293 + case invalidJSON(String) 294 + case invalidRootObject 295 + }
+154
supacodeTests/AgentHookSocketServerTests.swift
··· 1 + import Darwin 2 + import Foundation 3 + import Testing 4 + 5 + @testable import supacode 6 + 7 + @MainActor 8 + struct AgentHookSocketServerTests { 9 + // MARK: - Busy message parsing. 10 + 11 + @Test func parsesValidBusyActiveMessage() { 12 + let worktreeID = "/tmp/repo/wt-1" 13 + let tabID = UUID() 14 + let surfaceID = UUID() 15 + let raw = "\(worktreeID) \(tabID.uuidString) \(surfaceID.uuidString) 1" 16 + let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) 17 + 18 + guard case .busy(let wID, let tID, let sID, let active) = message else { 19 + Issue.record("Expected busy message, got \(String(describing: message))") 20 + return 21 + } 22 + #expect(wID == worktreeID) 23 + #expect(tID == tabID) 24 + #expect(sID == surfaceID) 25 + #expect(active == true) 26 + } 27 + 28 + @Test func parsesValidBusyInactiveMessage() { 29 + let tabID = UUID() 30 + let surfaceID = UUID() 31 + let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) 0" 32 + let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) 33 + 34 + guard case .busy(_, _, _, let active) = message else { 35 + Issue.record("Expected busy message") 36 + return 37 + } 38 + #expect(active == false) 39 + } 40 + 41 + @Test func nonZeroBusyFlagTreatedAsActive() { 42 + let tabID = UUID() 43 + let surfaceID = UUID() 44 + let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) anything" 45 + let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) 46 + 47 + guard case .busy(_, _, _, let active) = message else { 48 + Issue.record("Expected busy message") 49 + return 50 + } 51 + #expect(active == true) 52 + } 53 + 54 + // MARK: - Notification message parsing. 55 + 56 + @Test func parsesValidNotificationWithPayload() { 57 + let tabID = UUID() 58 + let surfaceID = UUID() 59 + let payload = #"{"hook_event_name":"Stop","title":"Done","message":"All tasks complete"}"# 60 + let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) claude\n\(payload)" 61 + let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) 62 + 63 + guard case .notification(_, let tID, let sID, let notification) = message else { 64 + Issue.record("Expected notification message, got \(String(describing: message))") 65 + return 66 + } 67 + #expect(tID == tabID) 68 + #expect(sID == surfaceID) 69 + #expect(notification.agent == "claude") 70 + #expect(notification.event == "Stop") 71 + #expect(notification.title == "Done") 72 + #expect(notification.body == "All tasks complete") 73 + } 74 + 75 + @Test func parsesNotificationWithLastAssistantMessageFallback() { 76 + let tabID = UUID() 77 + let surfaceID = UUID() 78 + let payload = #"{"hook_event_name":"Stop","last_assistant_message":"fallback body"}"# 79 + let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) codex\n\(payload)" 80 + let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) 81 + 82 + guard case .notification(_, _, _, let notification) = message else { 83 + Issue.record("Expected notification message") 84 + return 85 + } 86 + #expect(notification.agent == "codex") 87 + #expect(notification.body == "fallback body") 88 + } 89 + 90 + @Test func invalidJSONPayloadDropsNotification() { 91 + let tabID = UUID() 92 + let surfaceID = UUID() 93 + let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) claude\nnot json at all" 94 + let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) 95 + 96 + #expect(message == nil) 97 + } 98 + 99 + // MARK: - Malformed messages. 100 + 101 + @Test func malformedHeaderWithFewerThanThreeFieldsReturnsNil() { 102 + let message = AgentHookSocketServer.parse(data: Data("wt only-two-fields".utf8)) 103 + #expect(message == nil) 104 + } 105 + 106 + @Test func invalidTabIDReturnsNil() { 107 + let surfaceID = UUID() 108 + let raw = "wt not-a-uuid \(surfaceID.uuidString) 1" 109 + let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) 110 + #expect(message == nil) 111 + } 112 + 113 + @Test func invalidSurfaceIDReturnsNil() { 114 + let tabID = UUID() 115 + let raw = "wt \(tabID.uuidString) not-a-uuid 1" 116 + let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) 117 + #expect(message == nil) 118 + } 119 + 120 + @Test func emptyInputReturnsNil() { 121 + let message = AgentHookSocketServer.parse(data: Data()) 122 + #expect(message == nil) 123 + } 124 + 125 + @Test func whitespaceOnlyInputReturnsNil() { 126 + let message = AgentHookSocketServer.parse(data: Data(" \n \n ".utf8)) 127 + #expect(message == nil) 128 + } 129 + 130 + // MARK: - Agent name defaults. 131 + 132 + @Test func missingAgentNameDefaultsToUnknown() { 133 + let tabID = UUID() 134 + let surfaceID = UUID() 135 + // Only 3 header fields + a second line → notification with no agent. 136 + let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString)\n{\"hook_event_name\":\"Stop\"}" 137 + let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) 138 + 139 + guard case .notification(_, _, _, let notification) = message else { 140 + Issue.record("Expected notification message") 141 + return 142 + } 143 + #expect(notification.agent == "unknown") 144 + } 145 + 146 + @Test func readPayloadReturnsNilOnReadError() { 147 + let payload = AgentHookSocketServer.readPayload(from: -1) { _, _ in 148 + errno = EIO 149 + return -1 150 + } 151 + 152 + #expect(payload == nil) 153 + } 154 + }
+74
supacodeTests/CodexSettingsInstallerTests.swift
··· 1 + import ConcurrencyExtras 2 + import Foundation 3 + import Testing 4 + 5 + @testable import supacode 6 + 7 + struct CodexSettingsInstallerTests { 8 + private let fileManager = FileManager.default 9 + 10 + private func makeTempHomeURL() -> URL { 11 + URL(fileURLWithPath: NSTemporaryDirectory()) 12 + .appendingPathComponent("supacode-codex-installer-\(UUID().uuidString)", isDirectory: true) 13 + } 14 + 15 + @Test func installProgressHooksRunsEnableHooksCommand() async throws { 16 + let homeURL = makeTempHomeURL() 17 + let runCount = LockIsolated(0) 18 + defer { try? fileManager.removeItem(at: homeURL) } 19 + 20 + let installer = CodexSettingsInstaller( 21 + homeDirectoryURL: homeURL, 22 + fileManager: fileManager, 23 + runEnableHooksCommand: { 24 + runCount.setValue(runCount.value + 1) 25 + return .init(status: 0, standardError: "") 26 + } 27 + ) 28 + 29 + try await installer.installProgressHooks() 30 + 31 + #expect(runCount.value == 1) 32 + #expect(fileManager.fileExists(atPath: CodexSettingsInstaller.settingsURL(homeDirectoryURL: homeURL).path)) 33 + } 34 + 35 + @Test func installProgressHooksThrowsCodexUnavailable() async { 36 + let homeURL = makeTempHomeURL() 37 + let installer = CodexSettingsInstaller( 38 + homeDirectoryURL: homeURL, 39 + fileManager: fileManager, 40 + runEnableHooksCommand: { 41 + throw CodexSettingsInstallerError.codexUnavailable 42 + } 43 + ) 44 + 45 + do { 46 + try await installer.installProgressHooks() 47 + Issue.record("Expected codexUnavailable error") 48 + } catch let error as CodexSettingsInstallerError { 49 + #expect(error == .codexUnavailable) 50 + } catch { 51 + Issue.record("Unexpected error: \(error)") 52 + } 53 + } 54 + 55 + @Test func installProgressHooksThrowsEnableHooksFailedForNonZeroExit() async { 56 + let homeURL = makeTempHomeURL() 57 + let installer = CodexSettingsInstaller( 58 + homeDirectoryURL: homeURL, 59 + fileManager: fileManager, 60 + runEnableHooksCommand: { 61 + .init(status: 1, standardError: "boom") 62 + } 63 + ) 64 + 65 + do { 66 + try await installer.installProgressHooks() 67 + Issue.record("Expected enableHooksFailed error") 68 + } catch let error as CodexSettingsInstallerError { 69 + #expect(error == .enableHooksFailed("boom")) 70 + } catch { 71 + Issue.record("Unexpected error: \(error)") 72 + } 73 + } 74 + }
+3 -3
supacodeTests/CommandPaletteFeatureTests.swift
··· 49 49 copyIgnored: false, 50 50 copyUntracked: false 51 51 ) 52 - ) 52 + ), 53 53 ] 54 54 55 55 let items = CommandPaletteFeature.commandPaletteItems(from: state) ··· 74 74 description: "Focus the split to the right.", 75 75 action: "goto_split:right", 76 76 actionKey: "goto_split" 77 - ) 77 + ), 78 78 ] 79 79 ) 80 80 ··· 98 98 description: "", 99 99 action: "goto_split:right", 100 100 actionKey: "goto_split" 101 - ) 101 + ), 102 102 ] 103 103 ) 104 104
+221
supacodeTests/SettingsFeatureAgentHookTests.swift
··· 1 + import ComposableArchitecture 2 + import ConcurrencyExtras 3 + import DependenciesTestSupport 4 + import Foundation 5 + import Testing 6 + 7 + @testable import supacode 8 + 9 + @MainActor 10 + struct SettingsFeatureAgentHookTests { 11 + @Test(.dependencies) func agentHookCheckedSetsInstalled() async { 12 + var state = SettingsFeature.State() 13 + state.claudeProgressState = .checking 14 + 15 + let store = TestStore(initialState: state) { 16 + SettingsFeature() 17 + } 18 + 19 + await store.send(.agentHookChecked(.claudeProgress, installed: true)) { 20 + $0.claudeProgressState = .installed 21 + } 22 + } 23 + 24 + @Test(.dependencies) func agentHookCheckedSetsNotInstalled() async { 25 + var state = SettingsFeature.State() 26 + state.codexNotificationsState = .checking 27 + 28 + let store = TestStore(initialState: state) { 29 + SettingsFeature() 30 + } 31 + 32 + await store.send(.agentHookChecked(.codexNotifications, installed: false)) { 33 + $0.codexNotificationsState = .notInstalled 34 + } 35 + } 36 + 37 + @Test(.dependencies) func installTransitionsToInstalledOnSuccess() async { 38 + var state = SettingsFeature.State() 39 + state.claudeProgressState = .notInstalled 40 + 41 + let store = TestStore(initialState: state) { 42 + SettingsFeature() 43 + } withDependencies: { 44 + $0[ClaudeSettingsClient.self].installProgress = {} 45 + } 46 + 47 + await store.send(.agentHookInstallTapped(.claudeProgress)) { 48 + $0.claudeProgressState = .installing 49 + } 50 + await store.receive(\.agentHookActionCompleted) { 51 + $0.claudeProgressState = .installed 52 + } 53 + } 54 + 55 + @Test(.dependencies) func installTransitionsToFailedOnError() async { 56 + var state = SettingsFeature.State() 57 + state.codexProgressState = .notInstalled 58 + 59 + let store = TestStore(initialState: state) { 60 + SettingsFeature() 61 + } withDependencies: { 62 + $0[CodexSettingsClient.self].installProgress = { 63 + throw CodexSettingsInstallerError.codexUnavailable 64 + } 65 + } 66 + 67 + await store.send(.agentHookInstallTapped(.codexProgress)) { 68 + $0.codexProgressState = .installing 69 + } 70 + await store.receive(\.agentHookActionCompleted) { 71 + $0.codexProgressState = .failed(CodexSettingsInstallerError.codexUnavailable.localizedDescription) 72 + } 73 + } 74 + 75 + @Test(.dependencies) func installWhileLoadingIsNoOp() async { 76 + var state = SettingsFeature.State() 77 + state.claudeProgressState = .installing 78 + 79 + let store = TestStore(initialState: state) { 80 + SettingsFeature() 81 + } 82 + 83 + await store.send(.agentHookInstallTapped(.claudeProgress)) 84 + // No state change, no effect — the guard short-circuits. 85 + } 86 + 87 + @Test(.dependencies) func uninstallTransitionsToNotInstalledOnSuccess() async { 88 + var state = SettingsFeature.State() 89 + state.claudeNotificationsState = .installed 90 + 91 + let store = TestStore(initialState: state) { 92 + SettingsFeature() 93 + } withDependencies: { 94 + $0[ClaudeSettingsClient.self].uninstallNotifications = {} 95 + } 96 + 97 + await store.send(.agentHookUninstallTapped(.claudeNotifications)) { 98 + $0.claudeNotificationsState = .uninstalling 99 + } 100 + await store.receive(\.agentHookActionCompleted) { 101 + $0.claudeNotificationsState = .notInstalled 102 + } 103 + } 104 + 105 + @Test(.dependencies) func uninstallWhileLoadingIsNoOp() async { 106 + var state = SettingsFeature.State() 107 + state.codexNotificationsState = .checking 108 + 109 + let store = TestStore(initialState: state) { 110 + SettingsFeature() 111 + } 112 + 113 + await store.send(.agentHookUninstallTapped(.codexNotifications)) 114 + } 115 + 116 + @Test(.dependencies) func taskStartsInstalledChecksInParallel() async { 117 + let startedChecks = LockIsolated<Set<String>>([]) 118 + let continuations = LockIsolated<[CheckedContinuation<Void, Never>]>([]) 119 + 120 + let store = TestStore(initialState: SettingsFeature.State()) { 121 + SettingsFeature() 122 + } withDependencies: { 123 + $0[ClaudeSettingsClient.self].checkInstalled = { progress in 124 + let key = progress ? "claudeProgress" : "claudeNotifications" 125 + startedChecks.withValue { $0.insert(key) } 126 + await withCheckedContinuation { continuation in 127 + continuations.withValue { $0.append(continuation) } 128 + } 129 + return progress 130 + } 131 + $0[CodexSettingsClient.self].checkInstalled = { progress in 132 + let key = progress ? "codexProgress" : "codexNotifications" 133 + startedChecks.withValue { $0.insert(key) } 134 + await withCheckedContinuation { continuation in 135 + continuations.withValue { $0.append(continuation) } 136 + } 137 + return progress 138 + } 139 + } 140 + 141 + await store.send(.task) 142 + await store.receive(\.settingsLoaded) 143 + await store.receive(\.delegate.settingsChanged) 144 + 145 + await eventually { 146 + startedChecks.value.count == 4 147 + } 148 + 149 + continuations.withValue { continuations in 150 + for continuation in continuations { 151 + continuation.resume() 152 + } 153 + continuations.removeAll() 154 + } 155 + 156 + await store.receive(\.agentHookChecked) { 157 + $0.claudeProgressState = .installed 158 + } 159 + await store.receive(\.agentHookChecked) { 160 + $0.claudeNotificationsState = .notInstalled 161 + } 162 + await store.receive(\.agentHookChecked) { 163 + $0.codexProgressState = .installed 164 + } 165 + await store.receive(\.agentHookChecked) { 166 + $0.codexNotificationsState = .notInstalled 167 + } 168 + } 169 + 170 + @Test(.dependencies) func taskChecksAllFourHookSlotsOnStartup() async { 171 + let checkedSlots = LockIsolated<[String]>([]) 172 + 173 + let store = TestStore(initialState: SettingsFeature.State()) { 174 + SettingsFeature() 175 + } withDependencies: { 176 + $0[ClaudeSettingsClient.self].checkInstalled = { progress in 177 + checkedSlots.withValue { $0.append(progress ? "claudeProgress" : "claudeNotifications") } 178 + return progress 179 + } 180 + $0[CodexSettingsClient.self].checkInstalled = { progress in 181 + checkedSlots.withValue { $0.append(progress ? "codexProgress" : "codexNotifications") } 182 + return progress 183 + } 184 + } 185 + 186 + await store.send(.task) 187 + await store.receive(\.settingsLoaded) 188 + await store.receive(\.delegate.settingsChanged) 189 + await store.receive(\.agentHookChecked) { 190 + $0.claudeProgressState = .installed 191 + } 192 + await store.receive(\.agentHookChecked) { 193 + $0.claudeNotificationsState = .notInstalled 194 + } 195 + await store.receive(\.agentHookChecked) { 196 + $0.codexProgressState = .installed 197 + } 198 + await store.receive(\.agentHookChecked) { 199 + $0.codexNotificationsState = .notInstalled 200 + } 201 + 202 + #expect( 203 + Set(checkedSlots.value) == [ 204 + "claudeProgress", 205 + "claudeNotifications", 206 + "codexProgress", 207 + "codexNotifications", 208 + ]) 209 + } 210 + 211 + private func eventually( 212 + maxYields: Int = 100, 213 + _ predicate: () -> Bool 214 + ) async { 215 + for _ in 0..<maxYields { 216 + if predicate() { return } 217 + await Task.yield() 218 + } 219 + Issue.record("Condition was not satisfied before timeout") 220 + } 221 + }
+24
supacodeTests/SettingsFeatureTests.swift
··· 35 35 36 36 let store = TestStore(initialState: SettingsFeature.State()) { 37 37 SettingsFeature() 38 + } withDependencies: { 39 + $0[ClaudeSettingsClient.self].checkInstalled = { _ in false } 40 + $0[CodexSettingsClient.self].checkInstalled = { _ in false } 38 41 } 39 42 40 43 await store.send(.task) ··· 59 62 $0.terminalThemeSyncEnabled = false 60 63 } 61 64 await store.receive(\.delegate.settingsChanged) 65 + await receiveStartupHookChecks(from: store) 62 66 } 63 67 64 68 @Test(.dependencies) func savesUpdatesChanges() async { ··· 494 498 495 499 let store = TestStore(initialState: SettingsFeature.State()) { 496 500 SettingsFeature() 501 + } withDependencies: { 502 + $0[ClaudeSettingsClient.self].checkInstalled = { _ in false } 503 + $0[CodexSettingsClient.self].checkInstalled = { _ in false } 497 504 } 498 505 499 506 await store.send(.task) ··· 501 508 $0.shortcutOverrides = [.openSettings: override] 502 509 } 503 510 await store.receive(\.delegate.settingsChanged) 511 + await receiveStartupHookChecks(from: store) 504 512 } 505 513 506 514 // MARK: - Auto-Delete Archived Worktrees Setting ··· 730 738 return try JSONSerialization.data(withJSONObject: dict) 731 739 } 732 740 } 741 + 742 + @MainActor 743 + private func receiveStartupHookChecks(from store: TestStoreOf<SettingsFeature>) async { 744 + await store.receive(\.agentHookChecked) { 745 + $0.claudeProgressState = .notInstalled 746 + } 747 + await store.receive(\.agentHookChecked) { 748 + $0.claudeNotificationsState = .notInstalled 749 + } 750 + await store.receive(\.agentHookChecked) { 751 + $0.codexProgressState = .notInstalled 752 + } 753 + await store.receive(\.agentHookChecked) { 754 + $0.codexNotificationsState = .notInstalled 755 + } 756 + }
+90 -15
supacodeTests/WorktreeTerminalManagerTests.swift
··· 1 + import Dependencies 1 2 import Foundation 2 3 import Testing 3 4 ··· 87 88 #expect(event == .setupScriptConsumed(worktreeID: worktree.id)) 88 89 } 89 90 91 + @Test func unavailableSocketServerIsDiscarded() { 92 + let server = AgentHookSocketServer() 93 + server.shutdown() 94 + 95 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime(), socketServer: server) 96 + let worktree = makeWorktree() 97 + let state = manager.state(for: worktree) 98 + 99 + #expect(manager.socketServer == nil) 100 + #expect(state.socketPath == nil) 101 + } 102 + 103 + @Test func socketBusyRoutesToDecodedWorktreeState() { 104 + let server = AgentHookSocketServer() 105 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime(), socketServer: server) 106 + let worktree = makeWorktree(id: "/tmp/repo/wt with spaces") 107 + 108 + manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) 109 + 110 + guard let state = manager.stateIfExists(for: worktree.id), 111 + let tabId = state.tabManager.selectedTabId, 112 + let surface = state.splitTree(for: tabId).root?.leftmostLeaf(), 113 + let encodedID = worktree.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) 114 + else { 115 + Issue.record("Expected blocking script tab and socket server") 116 + return 117 + } 118 + 119 + server.onBusy?(encodedID, tabId.rawValue, surface.id, true) 120 + 121 + #expect(manager.taskStatus(for: worktree.id) == .running) 122 + } 123 + 124 + @Test func socketNotificationRoutesToDecodedWorktreeState() { 125 + withDependencies { 126 + $0.date.now = Date(timeIntervalSince1970: 1_234) 127 + } operation: { 128 + let server = AgentHookSocketServer() 129 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime(), socketServer: server) 130 + let worktree = makeWorktree(id: "/tmp/repo/wt with spaces") 131 + 132 + manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) 133 + 134 + guard let state = manager.stateIfExists(for: worktree.id), 135 + let tabId = state.tabManager.selectedTabId, 136 + let surface = state.splitTree(for: tabId).root?.leftmostLeaf(), 137 + let encodedID = worktree.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) 138 + else { 139 + Issue.record("Expected blocking script tab and socket server") 140 + return 141 + } 142 + 143 + server.onNotification?( 144 + encodedID, 145 + tabId.rawValue, 146 + surface.id, 147 + AgentHookNotification(agent: "codex", event: "Stop", title: "Done", body: "All complete") 148 + ) 149 + 150 + #expect( 151 + state.notifications.contains { 152 + $0.title == "Done" && $0.body == "All complete" 153 + } 154 + ) 155 + } 156 + } 157 + 90 158 @Test func notificationIndicatorUsesCurrentCountOnStreamStart() async { 91 159 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 92 160 let worktree = makeWorktree() ··· 98 166 title: "Unread", 99 167 body: "body", 100 168 isRead: false 101 - ) 169 + ), 102 170 ] 103 171 state.onNotificationIndicatorChanged?() 104 172 state.notifications = [ ··· 107 175 title: "Read", 108 176 body: "body", 109 177 isRead: true 110 - ) 178 + ), 111 179 ] 112 180 113 181 let stream = manager.eventStream() ··· 128 196 129 197 #expect(manager.taskStatus(for: worktree.id) == .idle) 130 198 131 - let tab1 = TerminalTabID() 132 - let tab2 = TerminalTabID() 133 - state.tabIsRunningById[tab1] = false 134 - state.tabIsRunningById[tab2] = false 199 + guard 200 + let tab1 = state.createTab(), 201 + let tab2 = state.createTab(focusing: false), 202 + let surface1 = state.splitTree(for: tab1).root?.leftmostLeaf(), 203 + let surface2 = state.splitTree(for: tab2).root?.leftmostLeaf() 204 + else { 205 + Issue.record("Expected tabs and surfaces") 206 + return 207 + } 208 + 135 209 #expect(manager.taskStatus(for: worktree.id) == .idle) 136 210 137 - state.tabIsRunningById[tab2] = true 211 + surface2.bridge.state.agentBusy = true 138 212 #expect(manager.taskStatus(for: worktree.id) == .running) 139 213 140 - state.tabIsRunningById[tab1] = true 214 + surface1.bridge.state.agentBusy = true 141 215 #expect(manager.taskStatus(for: worktree.id) == .running) 142 216 143 - state.tabIsRunningById[tab2] = false 217 + surface2.bridge.state.agentBusy = false 144 218 #expect(manager.taskStatus(for: worktree.id) == .running) 145 219 146 - state.tabIsRunningById[tab1] = false 220 + surface1.bridge.state.agentBusy = false 147 221 #expect(manager.taskStatus(for: worktree.id) == .idle) 148 222 } 149 223 ··· 648 722 #expect(state.tabManager.selectedTabId == selectedBefore) 649 723 } 650 724 651 - private func makeWorktree() -> Worktree { 652 - Worktree( 653 - id: "/tmp/repo/wt-1", 654 - name: "wt-1", 725 + private func makeWorktree(id: String = "/tmp/repo/wt-1") -> Worktree { 726 + let name = URL(fileURLWithPath: id).lastPathComponent 727 + return Worktree( 728 + id: id, 729 + name: name, 655 730 detail: "detail", 656 - workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), 731 + workingDirectory: URL(fileURLWithPath: id), 657 732 repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") 658 733 ) 659 734 }