From cfcb36ea74e2442e5fcf74c32046691a82403d45 Mon Sep 17 00:00:00 2001 From: Viorel-Cosmin Miron Date: Sun, 20 Sep 2026 08:48:14 +0200 Subject: [PATCH 1/5] Add Devin CLI as a supported coding agent (#602) Devin CLI reads hooks from the "hooks" key of ~/.config/devin/config.json in Claude's shape, minus the Notification event and with snake_case tool names in matchers. The integration mirrors Claude's: SessionStart / UserPromptSubmit / PreToolUse drive busy, PostToolUse drives idle, the ask_user_question|exit_plan_mode PreToolUse matcher drives awaiting_input, and PermissionRequest stands in for Claude's permission Notification (awaiting_input plus a fixed notify, since its payload carries no displayable text). Stop emits idle and forwards last_assistant_message as the notify body. Also route Cmd+V image paste to Devin's Ctrl+V chord, the same native paste translation Claude gets, and install both bundled skills under ~/.config/devin/skills. --- .../AgentHookSettingsCommand.swift | 18 ++ .../AgentIntegrationFactory.swift | 17 ++ .../BusinessLogic/DevinHookSettings.swift | 79 +++++++ .../DevinSettingsInstaller.swift | 97 +++++++++ .../Models/SkillAgent.swift | 12 +- .../devin-mark.imageset/Contents.json | 16 ++ .../devin-mark.imageset/devin-mark.svg | 1 + .../Ghostty/GhosttySurfaceView.swift | 3 +- supacodeTests/DevinHookSettingsTests.swift | 122 +++++++++++ .../DevinSettingsInstallerTests.swift | 192 ++++++++++++++++++ supacodeTests/GhosttySurfaceViewTests.swift | 21 +- supacodeTests/SkillAgentTests.swift | 15 +- 12 files changed, 576 insertions(+), 17 deletions(-) create mode 100644 SupacodeSettingsShared/BusinessLogic/DevinHookSettings.swift create mode 100644 SupacodeSettingsShared/BusinessLogic/DevinSettingsInstaller.swift create mode 100644 supacode/Assets.xcassets/devin-mark.imageset/Contents.json create mode 100644 supacode/Assets.xcassets/devin-mark.imageset/devin-mark.svg create mode 100644 supacodeTests/DevinHookSettingsTests.swift create mode 100644 supacodeTests/DevinSettingsInstallerTests.swift diff --git a/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsCommand.swift b/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsCommand.swift index 083c4d687..1059b781d 100644 --- a/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsCommand.swift +++ b/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsCommand.swift @@ -94,10 +94,28 @@ nonisolated enum AgentHookSettingsCommand { return "\(oscGuardExpr) && { \(steps.joined(separator: "; ")); } >/dev/null 2>&1 || true \(ownershipMarker)" } + /// Devin `PermissionRequest`: the agent is parked on an approval prompt, which + /// is Claude's `Notification` stand-in (Devin has no `Notification` event). Its + /// stdin payload (`tool_name` / `tool_input`) carries no displayable text, so + /// the notify is a fixed string rather than the stdin-sourced one. + static func devinPermissionRequestCommand(agent: SkillAgent) -> String { + let steps: [String] = [ + AgentPresenceOSC.ttyResolveSnippet, + AgentPresenceOSC.emitShell(event: .awaitingInput, agent: agent), + AgentPresenceOSC.emitFixedNotifyShell( + agent: agent, title: Self.inputNeededNotifyTitle, body: Self.inputNeededNotifyBody), + ] + return "\(oscGuardExpr) && { \(steps.joined(separator: "; ")); } >/dev/null 2>&1 || true \(ownershipMarker)" + } + /// Fixed headline / body for the error notification the Stop hook raises. static let errorNotifyTitle = "Agent error" static let errorNotifyBody = "Session stopped on an error" + /// Fixed headline / body for the notification Devin's PermissionRequest hook raises. + static let inputNeededNotifyTitle = "Input needed" + static let inputNeededNotifyBody = "Devin is waiting for a permission decision" + /// Guard for the OSC command: a surface id present (the no-op-outside-Supacode /// gate). Fires both locally and over SSH; the pid suffix inside the presence /// emit is what's gated on the socket path, not the emission itself. diff --git a/SupacodeSettingsShared/BusinessLogic/AgentIntegrationFactory.swift b/SupacodeSettingsShared/BusinessLogic/AgentIntegrationFactory.swift index 5e60776da..d526b6f9a 100644 --- a/SupacodeSettingsShared/BusinessLogic/AgentIntegrationFactory.swift +++ b/SupacodeSettingsShared/BusinessLogic/AgentIntegrationFactory.swift @@ -25,6 +25,7 @@ nonisolated enum AgentIntegrationFactory { case .claude: claude(configDirectoryURL: resolvedConfigDir, fileManager: fileManager) case .codex: codex(configDirectoryURL: resolvedConfigDir, fileManager: fileManager) case .copilot: copilot(configDirectoryURL: resolvedConfigDir, fileManager: fileManager) + case .devin: devin(configDirectoryURL: resolvedConfigDir, fileManager: fileManager) case .grok: grok(configDirectoryURL: resolvedConfigDir, fileManager: fileManager) case .hermes: hermes(configDirectoryURL: resolvedConfigDir, fileManager: fileManager) case .kimi: kimi(configDirectoryURL: resolvedConfigDir, fileManager: fileManager) @@ -94,6 +95,22 @@ nonisolated enum AgentIntegrationFactory { ] } + private static func devin(configDirectoryURL: URL, fileManager: FileManager) + -> [AgentIntegration.Component] + { + let installer = DevinSettingsInstaller( + configDirectoryURL: configDirectoryURL, fileManager: fileManager) + return [ + AgentIntegration.Component( + kind: .hooks, + state: { try installer.installState() }, + install: { try installer.installAllHooks() }, + uninstall: { try installer.uninstallAllHooks() } + ), + skillsComponent(agent: .devin, configDirectoryURL: configDirectoryURL), + ] + } + private static func grok(configDirectoryURL: URL, fileManager: FileManager) -> [AgentIntegration.Component] { diff --git a/SupacodeSettingsShared/BusinessLogic/DevinHookSettings.swift b/SupacodeSettingsShared/BusinessLogic/DevinHookSettings.swift new file mode 100644 index 000000000..6b8e2240f --- /dev/null +++ b/SupacodeSettingsShared/BusinessLogic/DevinHookSettings.swift @@ -0,0 +1,79 @@ +import Foundation + +nonisolated enum DevinHookSettings { + /// Canonical hook map for Devin. One composite command per (event, + /// matcher) slot keeps the prune-and-replace cycle idempotent. + static func hooksByEvent() throws -> [String: [JSONValue]] { + try AgentHookPayloadSupport.extractHookGroups( + from: DevinHooksPayload(), + invalidConfiguration: DevinHookSettingsError.invalidConfiguration + ) + } +} + +nonisolated enum DevinHookSettingsError: Error { + case invalidConfiguration +} + +// MARK: - Hook payload. + +// Devin reads `hooks` from `~/.config/devin/config.json` in Claude's events +// shape, with two deltas: there is no `Notification` event, and hook matchers +// see snake_case tool names (`ask_user_question`, not `AskUserQuestion`). The +// busy/idle/awaitingInput mapping otherwise mirrors `ClaudeHooksPayload`: +// `PermissionRequest` stands in for Claude's permission `Notification`, and +// `Stop` carries `last_assistant_message`, so the stdin-sourced notify lands +// the turn's final response like Claude's idle branch. `PostCompaction` is +// intentionally not mapped: it fires after compaction finishes, so it can't +// drive the compacting badge. +private nonisolated struct DevinHooksPayload: Encodable { + static let awaitingInputToolMatcher = "ask_user_question|exit_plan_mode" + + private static let busy = AgentHookSettingsCommand.compositeCommand( + events: [.busy], forwardStdinAsNotification: false, agent: .devin) + private static let idle = AgentHookSettingsCommand.compositeCommand( + events: [.idle], forwardStdinAsNotification: false, agent: .devin) + private static let awaitingInput = AgentHookSettingsCommand.compositeCommand( + events: [.awaitingInput], forwardStdinAsNotification: false, agent: .devin) + private static let permissionRequest = AgentHookSettingsCommand.devinPermissionRequestCommand( + agent: .devin) + private static let idleAndNotify = AgentHookSettingsCommand.compositeCommand( + events: [.idle], forwardStdinAsNotification: true, agent: .devin) + private static let sessionStart = AgentHookSettingsCommand.compositeCommand( + events: [.sessionStart], forwardStdinAsNotification: false, agent: .devin) + private static let sessionEndAndIdle = AgentHookSettingsCommand.compositeCommand( + events: [.sessionEnd, .idle], forwardStdinAsNotification: false, agent: .devin) + + let hooks: [String: [AgentHookGroup]] = [ + "SessionStart": [ + .init(hooks: [.init(command: Self.sessionStart, timeout: AgentHookSettingsCommand.timeoutSeconds)]) + ], + "UserPromptSubmit": [ + .init(hooks: [.init(command: Self.busy, timeout: AgentHookSettingsCommand.timeoutSeconds)]) + ], + "PreToolUse": [ + .init(matcher: "", hooks: [.init(command: Self.busy, timeout: AgentHookSettingsCommand.timeoutSeconds)]), + // Array-order: matched-by-name fires AFTER matcher-"", so awaiting wins. + .init( + matcher: Self.awaitingInputToolMatcher, + hooks: [.init(command: Self.awaitingInput, timeout: AgentHookSettingsCommand.timeoutSeconds)] + ), + ], + "PermissionRequest": [ + .init( + matcher: "", + hooks: [.init(command: Self.permissionRequest, timeout: AgentHookSettingsCommand.timeoutSeconds)]) + ], + "PostToolUse": [ + .init(matcher: "", hooks: [.init(command: Self.idle, timeout: AgentHookSettingsCommand.timeoutSeconds)]) + ], + "Stop": [ + .init( + matcher: "", hooks: [.init(command: Self.idleAndNotify, timeout: AgentHookSettingsCommand.timeoutSeconds)]) + ], + "SessionEnd": [ + .init( + matcher: "", hooks: [.init(command: Self.sessionEndAndIdle, timeout: AgentHookSettingsCommand.timeoutSeconds)]) + ], + ] +} diff --git a/SupacodeSettingsShared/BusinessLogic/DevinSettingsInstaller.swift b/SupacodeSettingsShared/BusinessLogic/DevinSettingsInstaller.swift new file mode 100644 index 000000000..1459d914c --- /dev/null +++ b/SupacodeSettingsShared/BusinessLogic/DevinSettingsInstaller.swift @@ -0,0 +1,97 @@ +import Foundation + +/// Top-level installer for Devin hooks. Merges the Supacode hook map into the +/// `"hooks"` key of `~/.config/devin/config.json` — Devin's main settings file, +/// so the shared prune-and-replace installer must preserve every sibling key +/// (model, permissions, …) it doesn't own. +nonisolated struct DevinSettingsInstaller { + let configDirectoryURL: URL + let fileManager: FileManager + + init( + homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, + configDirectoryURL: URL? = nil, + fileManager: FileManager = .default + ) { + self.configDirectoryURL = + configDirectoryURL + ?? homeDirectoryURL.appending(path: ".config/devin", directoryHint: .isDirectory) + self.fileManager = fileManager + } + + /// Install state for the unified hook map. The file installer's prune + /// step covers every event the integration writes, eliminating stale + /// duplicates left by older Supacode versions. + func installState() throws -> ComponentInstallState { + let groups: [String: [JSONValue]] + do { + groups = try DevinHookSettings.hooksByEvent() + } catch { + Self.reportInvalidHookConfiguration(error) + return .notInstalled + } + return try fileInstaller.installState(settingsURL: settingsURL, hookGroupsByEvent: groups) + } + + func installAllHooks() throws { + try fileInstaller.install( + settingsURL: settingsURL, + hookGroupsByEvent: try DevinHookSettings.hooksByEvent() + ) + } + + func uninstallAllHooks() throws { + try fileInstaller.uninstall( + settingsURL: settingsURL, + hookGroupsByEvent: try DevinHookSettings.hooksByEvent() + ) + } + + private static func reportInvalidHookConfiguration(_ error: Error) { + #if DEBUG + assertionFailure("Devin hook configuration is invalid: \(error)") + #endif + } + + private var settingsURL: URL { + configDirectoryURL.appending(path: "config.json", directoryHint: .notDirectory) + } + + static func settingsURL(homeDirectoryURL: URL) -> URL { + homeDirectoryURL + .appending(path: ".config/devin", directoryHint: .isDirectory) + .appending(path: "config.json", directoryHint: .notDirectory) + } + + private var fileInstaller: AgentHookSettingsFileInstaller { + AgentHookSettingsFileInstaller( + fileManager: fileManager, + errors: .init( + invalidEventHooks: { DevinSettingsInstallerError.invalidEventHooks($0) }, + invalidHooksObject: { DevinSettingsInstallerError.invalidHooksObject }, + invalidJSON: { DevinSettingsInstallerError.invalidJSON($0) }, + invalidRootObject: { DevinSettingsInstallerError.invalidRootObject } + ) + ) + } +} + +nonisolated enum DevinSettingsInstallerError: Error, Equatable, LocalizedError { + case invalidEventHooks(String) + case invalidHooksObject + case invalidJSON(String) + case invalidRootObject + + var errorDescription: String? { + switch self { + case .invalidEventHooks(let event): + "Devin config uses an unsupported hooks shape for \(event)." + case .invalidHooksObject: + "Devin config uses an unsupported hooks shape." + case .invalidJSON(let detail): + "Devin config must be valid JSON before Supacode can install hooks (\(detail))." + case .invalidRootObject: + "Devin config must be a JSON object before Supacode can install hooks." + } + } +} diff --git a/SupacodeSettingsShared/Models/SkillAgent.swift b/SupacodeSettingsShared/Models/SkillAgent.swift index 5548fbdbc..9d91df60b 100644 --- a/SupacodeSettingsShared/Models/SkillAgent.swift +++ b/SupacodeSettingsShared/Models/SkillAgent.swift @@ -5,6 +5,7 @@ public nonisolated enum SkillAgent: String, Equatable, Sendable, CaseIterable, C case claude case codex case copilot + case devin case grok case hermes case kimi @@ -15,14 +16,16 @@ public nonisolated enum SkillAgent: String, Equatable, Sendable, CaseIterable, C case pi /// Path under the user's home where the agent stores its config - /// (e.g. `.gemini/antigravity-cli`, `.claude`, `.codex`, `.copilot`, `.grok`, - /// `.hermes`, `.kimi-code`, `.kiro`, `.omp/agent`, `.pi/agent`, `.config/opencode`). + /// (e.g. `.gemini/antigravity-cli`, `.claude`, `.codex`, `.copilot`, + /// `.config/devin`, `.grok`, `.hermes`, `.kimi-code`, `.kiro`, `.omp/agent`, + /// `.pi/agent`, `.config/opencode`). public var configDirectoryName: String { switch self { case .antigravity: ".gemini/antigravity-cli" case .claude: ".claude" case .codex: ".codex" case .copilot: ".copilot" + case .devin: ".config/devin" case .grok: ".grok" case .hermes: ".hermes" case .kimi: ".kimi-code" @@ -40,6 +43,7 @@ public nonisolated enum SkillAgent: String, Equatable, Sendable, CaseIterable, C case .claude: "Claude Code" case .codex: "Codex" case .copilot: "Copilot CLI" + case .devin: "Devin CLI" case .grok: "Grok Code" case .hermes: "Hermes" case .kimi: "Kimi Code" @@ -57,6 +61,7 @@ public nonisolated enum SkillAgent: String, Equatable, Sendable, CaseIterable, C case .claude: "claude-code-mark" case .codex: "codex-mark" case .copilot: "copilot-mark" + case .devin: "devin-mark" case .grok: "grok-mark" case .hermes: "hermes-mark" case .kimi: "kimi-mark" @@ -90,7 +95,8 @@ public nonisolated enum SkillAgent: String, Equatable, Sendable, CaseIterable, C case .activityBadge, .idleBadge, .skills: true case .inputNeededBadge: - self == .claude || self == .grok || self == .copilot || self == .kimi || self == .opencode + self == .claude || self == .grok || self == .copilot || self == .devin || self == .kimi + || self == .opencode case .errorDetection: self == .claude || self == .antigravity case .compactionBadge: diff --git a/supacode/Assets.xcassets/devin-mark.imageset/Contents.json b/supacode/Assets.xcassets/devin-mark.imageset/Contents.json new file mode 100644 index 000000000..917a42a65 --- /dev/null +++ b/supacode/Assets.xcassets/devin-mark.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "devin-mark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/supacode/Assets.xcassets/devin-mark.imageset/devin-mark.svg b/supacode/Assets.xcassets/devin-mark.imageset/devin-mark.svg new file mode 100644 index 000000000..1c2c0f7bf --- /dev/null +++ b/supacode/Assets.xcassets/devin-mark.imageset/devin-mark.svg @@ -0,0 +1 @@ + diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index c2ed76d96..be7a0894f 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -1361,7 +1361,8 @@ final class GhosttySurfaceView: NSView, Identifiable { ) -> Bool { guard event.type == .keyDown else { return false } guard !keySequenceActive, keyTableDepth == 0 else { return false } - guard imagePasteAgents.contains(.claude) else { return false } + // Agents that paste images on the Ctrl+V chord get Cmd+V translated to it. + guard !imagePasteAgents.isDisjoint(with: [.claude, .devin]) else { return false } guard isExactCommandV(event) else { return false } guard let types = pasteboardTypes(), types.contains(where: isImagePasteboardType) else { return false } return types.allSatisfy { !isTextOrFilePasteboardType($0) } diff --git a/supacodeTests/DevinHookSettingsTests.swift b/supacodeTests/DevinHookSettingsTests.swift new file mode 100644 index 000000000..18d8d3ec6 --- /dev/null +++ b/supacodeTests/DevinHookSettingsTests.swift @@ -0,0 +1,122 @@ +import Foundation +import Testing + +@testable import SupacodeSettingsShared + +struct DevinHookSettingsTests { + @Test func hooksByEventCoverCoreEvents() throws { + let groups = try DevinHookSettings.hooksByEvent() + #expect(groups["SessionStart"] != nil) + #expect(groups["UserPromptSubmit"] != nil) + #expect(groups["PreToolUse"] != nil) + #expect(groups["PermissionRequest"] != nil) + #expect(groups["PostToolUse"] != nil) + #expect(groups["Stop"] != nil) + #expect(groups["SessionEnd"] != nil) + // Devin has no Notification event: its permission prompt arrives as + // PermissionRequest instead. + #expect(groups["Notification"] == nil) + } + + @Test func preToolUseOrdersAwaitingAfterBusy() throws { + let preToolUse = try #require(try DevinHookSettings.hooksByEvent()["PreToolUse"]) + #expect(preToolUse.count == 2) + #expect(preToolUse.first?.objectValue?["matcher"]?.stringValue == "") + // Devin hook matchers see snake_case tool names. + #expect(preToolUse.last?.objectValue?["matcher"]?.stringValue == "ask_user_question|exit_plan_mode") + } + + @Test func everyCommandCarriesOwnershipSentinel() throws { + let commands = try Self.commandStrings(from: try DevinHookSettings.hooksByEvent()) + #expect(commands.allSatisfy { $0.contains(AgentHookSettingsCommand.ownershipMarker) }) + } + + @Test func everyCommandTargetsDevinAgent() throws { + let commands = try Self.commandStrings(from: try DevinHookSettings.hooksByEvent()) + #expect(commands.allSatisfy { $0.contains("start=devin;") }) + } + + @Test func everyCommandOnlyNamesForwardedOrLocalVariables() throws { + // The shared command shape is held to the Grok-motivated allowlist: a bare + // `$VAR` is only ever a forwarded SUPACODE_* var or a `__` local. + let commands = try Self.commandStrings(from: try DevinHookSettings.hooksByEvent()) + #expect(!commands.isEmpty) + #expect(commands.allSatisfy { !ManagedHookCommandVariables.names(in: $0).isEmpty }) + #expect(commands.allSatisfy { ManagedHookCommandVariables.unexpected(in: $0).isEmpty }) + } + + @Test func postToolUseFiresIdleNotBusy() throws { + let postToolUse = try #require(try DevinHookSettings.hooksByEvent()["PostToolUse"]) + let commands = Self.commandStrings(in: postToolUse) + #expect(commands.allSatisfy { $0.contains("event=idle") }) + #expect(commands.allSatisfy { !$0.contains("event=busy") }) + } + + @Test func permissionRequestFiresAwaitingInputAndFixedNotify() throws { + // Devin has no Notification event, so a permission prompt would never reach + // the stdin-sourced notify leg; it emits a fixed notify instead. + let permissionRequest = try #require(try DevinHookSettings.hooksByEvent()["PermissionRequest"]) + let commands = Self.commandStrings(in: permissionRequest) + #expect(commands.allSatisfy { $0.contains("event=awaiting_input") }) + #expect(commands.allSatisfy { $0.contains("kind=notify") }) + #expect(commands.allSatisfy { $0.contains("title=") && $0.contains("body=") }) + } + + @Test func devinEmittedLifecycleEventsParseAsPresence() throws { + // Pin the emit-to-parse coupling end to end: pull each event's metadata + // straight from the emitted OSC sequence and run it through the real parser, + // so a HookEvent rename, a compositeCommand typo, or an OSC framing bug + // can't silently kill presence over SSH. + let commands = try Self.commandStrings(from: try DevinHookSettings.hooksByEvent()) + let signals = commands.flatMap { Self.parsedPresenceSignals(in: $0) } + for event in ["session_start", "busy", "idle", "awaiting_input", "session_end"] { + #expect(signals.contains { $0.agent == "devin" && $0.eventRawValue == event }) + } + } + + @Test func timeoutsArePositive() throws { + let groups = try DevinHookSettings.hooksByEvent() + let timeouts = groups.values.flatMap { group in + group.flatMap { entry in + entry.objectValue?["hooks"]?.arrayValue?.compactMap { hook in + Self.timeoutValue(from: hook.objectValue?["timeout"]) + } ?? [] + } + } + #expect(!timeouts.isEmpty) + #expect(timeouts.allSatisfy { $0 > 0 }) + } + + private static func timeoutValue(from value: JSONValue?) -> Int? { + guard let value else { return nil } + switch value { + case .int(let timeout): return timeout + case .double(let timeout): return Int(timeout) + default: return nil + } + } + + /// Parse every OSC 3008 presence signal a composite command emits, mirroring + /// libghostty's `id;metadata` split. The `%s` pid placeholder is dropped to + /// match the no-pid remote wire the parser receives over SSH. + private static func parsedPresenceSignals(in command: String) -> [AgentPresenceOSC.Signal] { + command.components(separatedBy: "]3008;").dropFirst().compactMap { chunk in + guard let stEnd = chunk.range(of: #"\033"#) else { return nil } + let sequence = chunk[.. [String] { + groups.values.flatMap { commandStrings(in: $0) } + } + + private static func commandStrings(in groups: [JSONValue]) -> [String] { + groups.flatMap { group in + group.objectValue?["hooks"]?.arrayValue?.compactMap { + $0.objectValue?["command"]?.stringValue + } ?? [] + } + } +} diff --git a/supacodeTests/DevinSettingsInstallerTests.swift b/supacodeTests/DevinSettingsInstallerTests.swift new file mode 100644 index 000000000..20405d9d9 --- /dev/null +++ b/supacodeTests/DevinSettingsInstallerTests.swift @@ -0,0 +1,192 @@ +import Foundation +import Testing + +@testable import SupacodeSettingsShared + +struct DevinSettingsInstallerTests { + private let fileManager = FileManager.default + + private func makeTempHomeURL() -> URL { + URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("supacode-devin-installer-\(UUID().uuidString)", isDirectory: true) + } + + @Test func installStateIsNotInstalledWhenFileMissing() throws { + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + let installer = DevinSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + #expect(try installer.installState() == .notInstalled) + } + + @Test func installStateThrowsWhenFileIsUnreadableAsUTF8() throws { + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + let settingsURL = DevinSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) + try fileManager.createDirectory( + at: settingsURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + // Lead bytes that are invalid UTF-8: the file exists but yields no state, + // which must not be reported as "not installed". + try Data([0xFF, 0xFE, 0xFD, 0x00]).write(to: settingsURL) + + let installer = DevinSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + #expect(throws: (any Error).self) { try installer.installState() } + } + + @Test func installStateReturnsOutdatedWhenManagedBodyDrifted() throws { + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + let settingsURL = DevinSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) + try fileManager.createDirectory( + at: settingsURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + // Ownership marker present but SessionStart carries a stale busy command: + // an older Supacode wrote this, so the user must get the Update affordance. + let staleCommand = AgentHookSettingsCommand.compositeCommand( + events: [.busy], forwardStdinAsNotification: false, agent: .devin) + let stale: JSONValue = .object([ + "hooks": .object([ + "SessionStart": .array([ + .object([ + "hooks": .array([ + .object([ + "type": "command", + "command": .string(staleCommand), + "timeout": 5, + ]) + ]) + ]) + ]) + ]) + ]) + try JSONEncoder().encode(stale).write(to: settingsURL) + + let installer = DevinSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + #expect(try installer.installState() == .outdated) + } + + @Test func installAllHooksWritesManagedHooksIntoConfigJson() throws { + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + let installer = DevinSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + try installer.installAllHooks() + + let settingsURL = DevinSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) + #expect(fileManager.fileExists(atPath: settingsURL.path)) + + let data = try Data(contentsOf: settingsURL) + let root = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(root.objectValue?["hooks"]?.objectValue?["SessionStart"] != nil) + #expect(root.objectValue?["hooks"]?.objectValue?["PermissionRequest"] != nil) + #expect(try installer.installState() == .installed) + } + + @Test func uninstallRemovesManagedHooks() throws { + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + let installer = DevinSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + try installer.installAllHooks() + try installer.uninstallAllHooks() + + let settingsURL = DevinSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) + let data = try Data(contentsOf: settingsURL) + let root = try JSONDecoder().decode(JSONValue.self, from: data) + let hooksObject = root.objectValue?["hooks"]?.objectValue ?? [:] + #expect(hooksObject.isEmpty) + #expect(try installer.installState() == .notInstalled) + } + + @Test func installPreservesOtherConfigKeysAndUserAuthoredHooks() throws { + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + // config.json is Devin's main settings file: sibling keys (model, + // permissions, read_config_from, …) and user-authored hooks must survive. + let settingsURL = DevinSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) + try fileManager.createDirectory( + at: settingsURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let existing = """ + { + "model": "opus-4.5", + "permissions": { "allow": ["exec(git status)"] }, + "hooks": { + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "prettier --write" + } + ] + } + ] + } + } + """ + try existing.write(to: settingsURL, atomically: true, encoding: .utf8) + + let installer = DevinSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + try installer.installAllHooks() + + let data = try Data(contentsOf: settingsURL) + let root = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(root.objectValue?["model"]?.stringValue == "opus-4.5") + #expect(root.objectValue?["permissions"]?.objectValue != nil) + + let text = try String(contentsOf: settingsURL, encoding: .utf8) + #expect(text.contains("prettier --write")) + #expect(text.contains(AgentHookSettingsCommand.ownershipMarker)) + #expect(try installer.installState() == .installed) + } + + @Test func uninstallPreservesOtherConfigKeysAndUserAuthoredHooks() throws { + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + let settingsURL = DevinSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) + try fileManager.createDirectory( + at: settingsURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let existing = """ + { + "model": "opus-4.5", + "hooks": { + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "prettier --write" + } + ] + } + ] + } + } + """ + try existing.write(to: settingsURL, atomically: true, encoding: .utf8) + + let installer = DevinSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + try installer.installAllHooks() + try installer.uninstallAllHooks() + + let data = try Data(contentsOf: settingsURL) + let root = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(root.objectValue?["model"]?.stringValue == "opus-4.5") + + let text = try String(contentsOf: settingsURL, encoding: .utf8) + #expect(text.contains("prettier --write")) + #expect(!text.contains(AgentHookSettingsCommand.ownershipMarker)) + #expect(try installer.installState() == .notInstalled) + } +} diff --git a/supacodeTests/GhosttySurfaceViewTests.swift b/supacodeTests/GhosttySurfaceViewTests.swift index e7a81dba8..6aa72f8a1 100644 --- a/supacodeTests/GhosttySurfaceViewTests.swift +++ b/supacodeTests/GhosttySurfaceViewTests.swift @@ -170,16 +170,19 @@ struct GhosttySurfaceViewTests { #expect(GhosttySurfaceView.forwardableMenuItem(for: Self.optionCommandH(), in: menu) == nil) } - @Test func imageOnlyCommandVRoutesForClaudeImagePaste() { - #expect( - GhosttySurfaceView.shouldRouteCommandPasteToNativeImagePaste( - event: Self.commandV(), - pasteboardTypes: [.tiff], - imagePasteAgents: [.claude], - keySequenceActive: false, - keyTableDepth: 0 + @Test func imageOnlyCommandVRoutesForCtrlVImagePasteAgents() { + // Claude Code and Devin CLI both paste images on the Ctrl+V chord. + for agents: Set in [[.claude], [.devin]] { + #expect( + GhosttySurfaceView.shouldRouteCommandPasteToNativeImagePaste( + event: Self.commandV(), + pasteboardTypes: [.tiff], + imagePasteAgents: agents, + keySequenceActive: false, + keyTableDepth: 0 + ) ) - ) + } } @Test func imageCommandVDoesNotOverrideTextOrFilePaste() { diff --git a/supacodeTests/SkillAgentTests.swift b/supacodeTests/SkillAgentTests.swift index 38b833589..f43208bcc 100644 --- a/supacodeTests/SkillAgentTests.swift +++ b/supacodeTests/SkillAgentTests.swift @@ -12,8 +12,8 @@ struct SkillAgentTests { @Test func allCasesByDisplayNameOrdersBySettingsLabel() { #expect( SkillAgent.allCasesByDisplayName.map(\.displayName) == [ - "Claude Code", "Codex", "Copilot CLI", "Google Antigravity", "Grok Code", "Hermes", - "Kimi Code", "Kiro CLI", "Oh My Pi", "OpenCode", "Pi", + "Claude Code", "Codex", "Copilot CLI", "Devin CLI", "Google Antigravity", "Grok Code", + "Hermes", "Kimi Code", "Kiro CLI", "Oh My Pi", "OpenCode", "Pi", ] ) } @@ -25,6 +25,13 @@ struct SkillAgentTests { #expect(SkillAgent.antigravity.configDirectoryName == ".gemini/antigravity-cli") } + @Test func devinIdentityUsesExpectedDisplayAndAssetNames() { + #expect(SkillAgent.devin.rawValue == "devin") + #expect(SkillAgent.devin.displayName == "Devin CLI") + #expect(SkillAgent.devin.assetName == "devin-mark") + #expect(SkillAgent.devin.configDirectoryName == ".config/devin") + } + @Test func hermesIdentityUsesExpectedDisplayAndAssetNames() { #expect(SkillAgent.hermes.rawValue == "hermes") #expect(SkillAgent.hermes.displayName == "Hermes") @@ -65,7 +72,7 @@ struct SkillAgentTests { // Varying rows, authored from each agent's installed hook events. #expect( SkillAgent.allCases.filter { $0.supports(.inputNeededBadge) } - == [.claude, .copilot, .grok, .kimi, .opencode]) + == [.claude, .copilot, .devin, .grok, .kimi, .opencode]) #expect(SkillAgent.allCases.filter { $0.supports(.errorDetection) } == [.antigravity, .claude]) #expect(SkillAgent.allCases.filter { $0.supports(.compactionBadge) } == [.claude]) #expect(SkillAgent.allCases.filter { !$0.supports(.notifications) } == [.opencode]) @@ -151,7 +158,7 @@ struct SkillAgentTests { @Test func relocatableAgentsInstallDirectlyIntoCustomConfigDir() async throws { // Every relocatable agent whose install is pure file I/O (no CLI subprocess). - let agents: [SkillAgent] = [.claude, .copilot, .grok, .hermes, .kimi, .omp, .pi, .opencode] + let agents: [SkillAgent] = [.claude, .copilot, .devin, .grok, .hermes, .kimi, .omp, .pi, .opencode] for agent in agents { let custom = URL(fileURLWithPath: NSTemporaryDirectory()) .appending(path: "supacode-custom-\(agent.rawValue)-\(UUID().uuidString)", directoryHint: .isDirectory) From 167c8caaa4fe4eac207e76c4e4fe7968aeb177a1 Mon Sep 17 00:00:00 2001 From: Viorel-Cosmin Miron Date: Sun, 20 Sep 2026 09:08:01 +0200 Subject: [PATCH 2/5] Detect drift in duplicated managed hook occurrences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installState compared managed hook commands as a Set, collapsing identical command strings across slots. Payloads that legitimately reuse one command — Devin's busy under both UserPromptSubmit and the catch-all PreToolUse group — then read as installed after a user deleted a single occurrence, so repair was never offered. Compare occurrences keyed by (event, matcher, command) as a multiset instead. The stricter slot-aware compare also catches a managed command parked under the wrong event and duplicated groups, for every agent on the shared installer. --- .../AgentHookSettingsFileInstaller.swift | 64 +++++++++++++------ .../AgentHookSettingsFileInstallerTests.swift | 63 ++++++++++++++++++ .../DevinSettingsInstallerTests.swift | 28 ++++++++ 3 files changed, 135 insertions(+), 20 deletions(-) diff --git a/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsFileInstaller.swift b/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsFileInstaller.swift index 700c67d77..d0bd2d7f1 100644 --- a/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsFileInstaller.swift +++ b/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsFileInstaller.swift @@ -23,12 +23,19 @@ nonisolated struct AgentHookSettingsFileInstaller { JSONHookSettingsFile(fileManager: fileManager, errors: errors) } - /// Compare the set of Supacode-managed commands present in the settings - /// file against the expected (canonical) set: - /// - `.installed` — actual Supacode commands == expected, no extras + /// Compare the Supacode-managed command occurrences present in the settings + /// file against the expected (canonical) occurrences: + /// - `.installed` — actual Supacode occurrences == expected, no extras /// - `.notInstalled` — no Supacode-managed commands at all - /// - `.outdated` — some present, but the set differs (extras, missing, - /// or stale variants from older Supacode versions) + /// - `.outdated` — some present, but occurrences differ (extras, missing, + /// stale variants, duplicates, or a managed command + /// parked under the wrong event/matcher) + /// + /// Occurrences are counted per (event, matcher, command) slot rather than + /// compared as a command `Set`: canonical payloads legitimately reuse one + /// command string in several slots (e.g. `busy` under both `UserPromptSubmit` + /// and the catch-all `PreToolUse` group), and a set would miss one of those + /// slots being deleted or duplicated. /// /// `additionalOutdatedIfInstalled` runs only when the command set already /// matches, against the **same** parsed snapshot (no second disk read). Use @@ -43,7 +50,7 @@ nonisolated struct AgentHookSettingsFileInstaller { ) throws -> ComponentInstallState { do { let settingsObject = try loadSettingsObject(at: settingsURL) - let expected = Self.commands(from: hookGroupsByEvent) + let expected = Self.expectedCommandOccurrences(from: hookGroupsByEvent) guard !expected.isEmpty else { return .notInstalled } let actual = Self.installedSupacodeCommands(in: settingsObject) if actual.isEmpty { return .notInstalled } @@ -58,17 +65,26 @@ nonisolated struct AgentHookSettingsFileInstaller { } } - /// All Supacode-marked `command` strings under the `hooks` map. Filters - /// via `AgentHookCommandOwnership` so user-authored hooks are never - /// treated as "ours." + /// One managed-command occurrence, identified by where it lives: the event, + /// the owning group's `matcher` (nil when the key is absent), and the command + /// string. Occurrence counts per slot are what `installState` compares. + private struct CommandOccurrence: Hashable { + let event: String + let matcher: JSONValue? + let command: String + } + + /// Supacode-managed occurrences under the `hooks` map, counted per + /// (event, matcher, command) slot. Filters via `AgentHookCommandOwnership` + /// so user-authored hooks are never treated as "ours." private static func installedSupacodeCommands( in settingsObject: [String: JSONValue] - ) -> Set { + ) -> [CommandOccurrence: Int] { guard let hooksValue = settingsObject["hooks"], let hooksObject = hooksValue.objectValue - else { return [] } - var commands = Set() - for (_, value) in hooksObject { + else { return [:] } + var occurrences: [CommandOccurrence: Int] = [:] + for (event, value) in hooksObject { guard let groups = value.arrayValue else { continue } for group in groups { guard let groupObject = group.objectValue, @@ -79,16 +95,21 @@ nonisolated struct AgentHookSettingsFileInstaller { let command = hookObject["command"]?.stringValue, AgentHookCommandOwnership.isSupacodeManagedCommand(command) else { continue } - commands.insert(command) + occurrences[ + CommandOccurrence(event: event, matcher: groupObject["matcher"], command: command), + default: 0 + ] += 1 } } } - return commands + return occurrences } - private static func commands(from hookGroupsByEvent: [String: [JSONValue]]) -> Set { - var commands = Set() - for (_, groups) in hookGroupsByEvent { + private static func expectedCommandOccurrences( + from hookGroupsByEvent: [String: [JSONValue]] + ) -> [CommandOccurrence: Int] { + var occurrences: [CommandOccurrence: Int] = [:] + for (event, groups) in hookGroupsByEvent { for group in groups { guard let groupObject = group.objectValue, let hooks = groupObject["hooks"]?.arrayValue @@ -97,11 +118,14 @@ nonisolated struct AgentHookSettingsFileInstaller { guard let hookObject = hook.objectValue, let command = hookObject["command"]?.stringValue else { continue } - commands.insert(command) + occurrences[ + CommandOccurrence(event: event, matcher: groupObject["matcher"], command: command), + default: 0 + ] += 1 } } } - return commands + return occurrences } /// Removes every Supacode-managed command (current and legacy) from the diff --git a/supacodeTests/AgentHookSettingsFileInstallerTests.swift b/supacodeTests/AgentHookSettingsFileInstallerTests.swift index 66cb663c7..ce3d0072c 100644 --- a/supacodeTests/AgentHookSettingsFileInstallerTests.swift +++ b/supacodeTests/AgentHookSettingsFileInstallerTests.swift @@ -45,6 +45,23 @@ struct AgentHookSettingsFileInstallerTests { ] } + /// Canonical payload where one command string legitimately occupies two + /// slots — the shape Devin's `busy` hook has (UserPromptSubmit + catch-all + /// PreToolUse). Guards the occurrence-level compare against Set collapse. + private func duplicatedCommandHookGroups() -> [String: [JSONValue]] { + let busy = AgentHookSettingsCommand.compositeCommand( + events: [.busy], forwardStdinAsNotification: false, agent: .devin) + let hook: JSONValue = .object([ + "type": "command", + "command": .string(busy), + "timeout": 2, + ]) + return [ + "UserPromptSubmit": [.object(["hooks": .array([hook])])], + "PreToolUse": [.object(["matcher": "", "hooks": .array([hook])])], + ] + } + // MARK: - Install. @Test func installIntoEmptyFileCreatesCorrectStructure() throws { @@ -346,6 +363,52 @@ struct AgentHookSettingsFileInstallerTests { #expect(try installer.installState(settingsURL: url, hookGroupsByEvent: sampleHookGroups()) != .installed) } + @Test func installStateIsOutdatedWhenOneDuplicateManagedOccurrenceIsRemoved() throws { + // The command Set compare collapses a duplicated managed command across + // slots: deleting the catch-all PreToolUse `busy` group leaves `busy` + // present under UserPromptSubmit, so the drift was invisible. Occurrences + // are counted per (event, matcher, command) slot, so this must be outdated. + let url = makeTempURL() + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } + + let installer = makeInstaller() + let groups = duplicatedCommandHookGroups() + try installer.install(settingsURL: url, hookGroupsByEvent: groups) + + var root = try JSONDecoder().decode(JSONValue.self, from: Data(contentsOf: url)) + .objectValue! + var hooks = root["hooks"]!.objectValue! + hooks["PreToolUse"] = .array( + hooks["PreToolUse"]!.arrayValue! + .filter { $0.objectValue?["matcher"] != .string("") }) + root["hooks"] = .object(hooks) + try JSONEncoder().encode(.object(root)).write(to: url) + + #expect(try installer.installState(settingsURL: url, hookGroupsByEvent: groups) == .outdated) + } + + @Test func installStateIsOutdatedWhenManagedCommandIsParkedUnderWrongEvent() throws { + // Same command string, wrong slot: moving `busy` from UserPromptSubmit to + // SessionStart keeps the command set identical but shifts an occurrence, + // which the slot-keyed compare must surface as drift. + let url = makeTempURL() + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } + + let installer = makeInstaller() + let groups = duplicatedCommandHookGroups() + try installer.install(settingsURL: url, hookGroupsByEvent: groups) + + var root = try JSONDecoder().decode(JSONValue.self, from: Data(contentsOf: url)) + .objectValue! + var hooks = root["hooks"]!.objectValue! + hooks["SessionStart"] = hooks["UserPromptSubmit"] + hooks["UserPromptSubmit"] = .array([]) + root["hooks"] = .object(hooks) + try JSONEncoder().encode(.object(root)).write(to: url) + + #expect(try installer.installState(settingsURL: url, hookGroupsByEvent: groups) == .outdated) + } + @Test func containsMatchingHooksLogsInvalidJSONErrors() throws { let url = makeTempURL() let warnings = LockIsolated<[String]>([]) diff --git a/supacodeTests/DevinSettingsInstallerTests.swift b/supacodeTests/DevinSettingsInstallerTests.swift index 20405d9d9..bfd7b8284 100644 --- a/supacodeTests/DevinSettingsInstallerTests.swift +++ b/supacodeTests/DevinSettingsInstallerTests.swift @@ -70,6 +70,34 @@ struct DevinSettingsInstallerTests { #expect(try installer.installState() == .outdated) } + @Test func installStateReturnsOutdatedWhenOneDuplicateBusyHookIsRemoved() throws { + // Devin's canonical payload reuses the same `busy` command under both + // UserPromptSubmit and the catch-all PreToolUse group. Removing only the + // PreToolUse occurrence must read as drift — a command-string Set compare + // would still see `busy` and wrongly report `.installed`. + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + let installer = DevinSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + try installer.installAllHooks() + + let settingsURL = DevinSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) + var root = try JSONDecoder().decode(JSONValue.self, from: Data(contentsOf: settingsURL)) + .objectValue! + var hooks = root["hooks"]!.objectValue! + // Drop the catch-all PreToolUse group (matcher ""), keep the awaiting-input + // matcher group — the user's other managed hooks stay untouched. + hooks["PreToolUse"] = .array( + hooks["PreToolUse"]!.arrayValue! + .filter { $0.objectValue?["matcher"] != .string("") }) + root["hooks"] = .object(hooks) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(.object(root)).write(to: settingsURL) + + #expect(try installer.installState() == .outdated) + } + @Test func installAllHooksWritesManagedHooksIntoConfigJson() throws { let homeURL = makeTempHomeURL() defer { try? fileManager.removeItem(at: homeURL) } From 01eea5341b5dbe54297a96bb368608f32aafb41a Mon Sep 17 00:00:00 2001 From: Viorel-Cosmin Miron Date: Sun, 20 Sep 2026 10:25:53 +0200 Subject: [PATCH 3/5] Make hook drift detection order-sensitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installState compared managed hook occurrences as an unordered multiset, so reordering groups within an event read as installed. Hook groups execute in array order, and Devin's payload depends on it: the catch-all PreToolUse busy matcher must precede the ask_user_question|exit_plan_mode awaiting-input matcher for the later emit to win. Reversed groups left the badge stuck on busy while Settings showed the integration current. Compare the ordered sequence of managed groups per event — matcher plus managed commands in array order — instead. Only managed groups count, so a user-authored group interleaved between ours still reads installed. --- .../AgentHookSettingsFileInstaller.swift | 77 ++++++++++--------- .../AgentHookSettingsFileInstallerTests.swift | 76 ++++++++++++++++++ .../DevinSettingsInstallerTests.swift | 24 ++++++ 3 files changed, 140 insertions(+), 37 deletions(-) diff --git a/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsFileInstaller.swift b/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsFileInstaller.swift index d0bd2d7f1..b7a6ef53b 100644 --- a/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsFileInstaller.swift +++ b/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsFileInstaller.swift @@ -31,11 +31,13 @@ nonisolated struct AgentHookSettingsFileInstaller { /// stale variants, duplicates, or a managed command /// parked under the wrong event/matcher) /// - /// Occurrences are counted per (event, matcher, command) slot rather than - /// compared as a command `Set`: canonical payloads legitimately reuse one - /// command string in several slots (e.g. `busy` under both `UserPromptSubmit` - /// and the catch-all `PreToolUse` group), and a set would miss one of those - /// slots being deleted or duplicated. + /// The comparison is the ordered sequence of managed groups per event + /// (matcher + managed commands, in array order) rather than a command + /// `Set`. Canonical payloads legitimately reuse one command string in + /// several slots (e.g. `busy` under both `UserPromptSubmit` and the + /// catch-all `PreToolUse` group), and hook groups execute in array order — + /// a reordered or partially deleted slot must read as drift, while a + /// user-authored group inserted between managed ones must not. /// /// `additionalOutdatedIfInstalled` runs only when the command set already /// matches, against the **same** parsed snapshot (no second disk read). Use @@ -65,41 +67,45 @@ nonisolated struct AgentHookSettingsFileInstaller { } } - /// One managed-command occurrence, identified by where it lives: the event, - /// the owning group's `matcher` (nil when the key is absent), and the command - /// string. Occurrence counts per slot are what `installState` compares. - private struct CommandOccurrence: Hashable { - let event: String + /// The managed content of one hook group, in execution order: the group's + /// `matcher` (nil when the key is absent) plus its Supacode-managed commands + /// in array order. `installState` compares the ordered sequence of these + /// per event, so reordered groups read as drift even when every command is + /// still present. + private struct ManagedGroupOccurrence: Hashable { let matcher: JSONValue? - let command: String + let commands: [String] } - /// Supacode-managed occurrences under the `hooks` map, counted per - /// (event, matcher, command) slot. Filters via `AgentHookCommandOwnership` - /// so user-authored hooks are never treated as "ours." + /// Supacode-managed groups under the `hooks` map, kept in array order per + /// event. A group contributes only its managed commands — user-authored + /// hooks and fully user-authored groups are skipped, so inserting a custom + /// group between managed ones is not drift. private static func installedSupacodeCommands( in settingsObject: [String: JSONValue] - ) -> [CommandOccurrence: Int] { + ) -> [String: [ManagedGroupOccurrence]] { guard let hooksValue = settingsObject["hooks"], let hooksObject = hooksValue.objectValue else { return [:] } - var occurrences: [CommandOccurrence: Int] = [:] + var occurrences: [String: [ManagedGroupOccurrence]] = [:] for (event, value) in hooksObject { guard let groups = value.arrayValue else { continue } - for group in groups { + let managed = groups.compactMap { group -> ManagedGroupOccurrence? in guard let groupObject = group.objectValue, let hooks = groupObject["hooks"]?.arrayValue - else { continue } - for hook in hooks { + else { return nil } + let commands = hooks.compactMap { hook -> String? in guard let hookObject = hook.objectValue, let command = hookObject["command"]?.stringValue, AgentHookCommandOwnership.isSupacodeManagedCommand(command) - else { continue } - occurrences[ - CommandOccurrence(event: event, matcher: groupObject["matcher"], command: command), - default: 0 - ] += 1 + else { return nil } + return command } + guard !commands.isEmpty else { return nil } + return ManagedGroupOccurrence(matcher: groupObject["matcher"], commands: commands) + } + if !managed.isEmpty { + occurrences[event] = managed } } return occurrences @@ -107,22 +113,19 @@ nonisolated struct AgentHookSettingsFileInstaller { private static func expectedCommandOccurrences( from hookGroupsByEvent: [String: [JSONValue]] - ) -> [CommandOccurrence: Int] { - var occurrences: [CommandOccurrence: Int] = [:] + ) -> [String: [ManagedGroupOccurrence]] { + var occurrences: [String: [ManagedGroupOccurrence]] = [:] for (event, groups) in hookGroupsByEvent { - for group in groups { + let managed = groups.compactMap { group -> ManagedGroupOccurrence? in guard let groupObject = group.objectValue, let hooks = groupObject["hooks"]?.arrayValue - else { continue } - for hook in hooks { - guard let hookObject = hook.objectValue, - let command = hookObject["command"]?.stringValue - else { continue } - occurrences[ - CommandOccurrence(event: event, matcher: groupObject["matcher"], command: command), - default: 0 - ] += 1 - } + else { return nil } + let commands = hooks.compactMap { $0.objectValue?["command"]?.stringValue } + guard !commands.isEmpty else { return nil } + return ManagedGroupOccurrence(matcher: groupObject["matcher"], commands: commands) + } + if !managed.isEmpty { + occurrences[event] = managed } } return occurrences diff --git a/supacodeTests/AgentHookSettingsFileInstallerTests.swift b/supacodeTests/AgentHookSettingsFileInstallerTests.swift index ce3d0072c..b53788f42 100644 --- a/supacodeTests/AgentHookSettingsFileInstallerTests.swift +++ b/supacodeTests/AgentHookSettingsFileInstallerTests.swift @@ -62,6 +62,30 @@ struct AgentHookSettingsFileInstallerTests { ] } + /// Two managed groups in one event whose order is load-bearing — the shape + /// of Devin's PreToolUse, where the catch-all `busy` group must precede the + /// specific awaiting-input matcher so the later emit wins. + private func orderedMultiGroupHookGroups() -> [String: [JSONValue]] { + let busy = AgentHookSettingsCommand.compositeCommand( + events: [.busy], forwardStdinAsNotification: false, agent: .devin) + let awaiting = AgentHookSettingsCommand.compositeCommand( + events: [.awaitingInput], forwardStdinAsNotification: false, agent: .devin) + func group(_ matcher: String, _ command: String) -> JSONValue { + .object([ + "matcher": .string(matcher), + "hooks": .array([ + .object(["type": "command", "command": .string(command), "timeout": 2]) + ]), + ]) + } + return [ + "PreToolUse": [ + group("", busy), + group("ask_user_question|exit_plan_mode", awaiting), + ] + ] + } + // MARK: - Install. @Test func installIntoEmptyFileCreatesCorrectStructure() throws { @@ -409,6 +433,58 @@ struct AgentHookSettingsFileInstallerTests { #expect(try installer.installState(settingsURL: url, hookGroupsByEvent: groups) == .outdated) } + @Test func installStateIsOutdatedWhenManagedGroupsAreReordered() throws { + // Hook groups execute in array order: reversing the catch-all busy group + // and the awaiting-input matcher group changes which emit wins. Every + // command is still present, so only an order-sensitive compare catches it. + let url = makeTempURL() + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } + + let installer = makeInstaller() + let groups = orderedMultiGroupHookGroups() + try installer.install(settingsURL: url, hookGroupsByEvent: groups) + + var root = try JSONDecoder().decode(JSONValue.self, from: Data(contentsOf: url)) + .objectValue! + var hooks = root["hooks"]!.objectValue! + hooks["PreToolUse"] = .array(hooks["PreToolUse"]!.arrayValue!.reversed()) + root["hooks"] = .object(hooks) + try JSONEncoder().encode(.object(root)).write(to: url) + + #expect(try installer.installState(settingsURL: url, hookGroupsByEvent: groups) == .outdated) + } + + @Test func installStateStaysInstalledWhenUserGroupIsInsertedBetweenManagedOnes() throws { + // Managed-group order is what matters — a user-authored group interleaved + // between ours changes absolute indices but not managed execution order, + // so it must not be reported as drift. + let url = makeTempURL() + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } + + let installer = makeInstaller() + let groups = orderedMultiGroupHookGroups() + try installer.install(settingsURL: url, hookGroupsByEvent: groups) + + var root = try JSONDecoder().decode(JSONValue.self, from: Data(contentsOf: url)) + .objectValue! + var hooks = root["hooks"]!.objectValue! + var preToolUse = hooks["PreToolUse"]!.arrayValue! + preToolUse.insert( + .object([ + "matcher": "Bash", + "hooks": .array([ + .object(["type": "command", "command": "echo user-hook", "timeout": 5]) + ]), + ]), + at: 1 + ) + hooks["PreToolUse"] = .array(preToolUse) + root["hooks"] = .object(hooks) + try JSONEncoder().encode(.object(root)).write(to: url) + + #expect(try installer.installState(settingsURL: url, hookGroupsByEvent: groups) == .installed) + } + @Test func containsMatchingHooksLogsInvalidJSONErrors() throws { let url = makeTempURL() let warnings = LockIsolated<[String]>([]) diff --git a/supacodeTests/DevinSettingsInstallerTests.swift b/supacodeTests/DevinSettingsInstallerTests.swift index bfd7b8284..d1c52efbf 100644 --- a/supacodeTests/DevinSettingsInstallerTests.swift +++ b/supacodeTests/DevinSettingsInstallerTests.swift @@ -98,6 +98,30 @@ struct DevinSettingsInstallerTests { #expect(try installer.installState() == .outdated) } + @Test func installStateReturnsOutdatedWhenPreToolUseMatcherOrderIsReversed() throws { + // Devin's PreToolUse groups execute in array order: the catch-all busy + // matcher must precede the awaiting-input matcher so ask_user_question + // resolves to awaiting_input. Reversed groups leave every command present + // but flip which emit wins — the compare must be order-sensitive. + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + let installer = DevinSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + try installer.installAllHooks() + + let settingsURL = DevinSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) + var root = try JSONDecoder().decode(JSONValue.self, from: Data(contentsOf: settingsURL)) + .objectValue! + var hooks = root["hooks"]!.objectValue! + hooks["PreToolUse"] = .array(hooks["PreToolUse"]!.arrayValue!.reversed()) + root["hooks"] = .object(hooks) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(.object(root)).write(to: settingsURL) + + #expect(try installer.installState() == .outdated) + } + @Test func installAllHooksWritesManagedHooksIntoConfigJson() throws { let homeURL = makeTempHomeURL() defer { try? fileManager.removeItem(at: homeURL) } From 6204b5b1e778c0ee5bf3baa644cb4a3d6bfa014f Mon Sep 17 00:00:00 2001 From: Viorel-Cosmin Miron Date: Sun, 20 Sep 2026 10:36:24 +0200 Subject: [PATCH 4/5] Compare full managed hook objects when checking install state installState still filtered managed hooks by command string and compared only matcher + command list, so a change to `type`, `timeout`, or `env` read as installed. Grok's env passthrough needed a separate additional closure for that reason. Change ManagedGroupOccurrence to keep the full managed hook JSONValue objects in array order. That catches metadata drift alongside command, occurrence-count, and ordering drift. Grok's env validation is now handled by the same comparison, so the additionalOutdatedIfInstalled hook and its closure are removed. --- .../AgentHookSettingsFileInstaller.swift | 64 +++++++++---------- .../BusinessLogic/GrokSettingsInstaller.swift | 8 +-- .../AgentHookSettingsFileInstallerTests.swift | 48 ++++++++++++++ .../GrokSettingsInstallerTests.swift | 2 +- 4 files changed, 81 insertions(+), 41 deletions(-) diff --git a/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsFileInstaller.swift b/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsFileInstaller.swift index b7a6ef53b..c9dad0401 100644 --- a/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsFileInstaller.swift +++ b/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsFileInstaller.swift @@ -23,32 +23,31 @@ nonisolated struct AgentHookSettingsFileInstaller { JSONHookSettingsFile(fileManager: fileManager, errors: errors) } - /// Compare the Supacode-managed command occurrences present in the settings - /// file against the expected (canonical) occurrences: - /// - `.installed` — actual Supacode occurrences == expected, no extras - /// - `.notInstalled` — no Supacode-managed commands at all - /// - `.outdated` — some present, but occurrences differ (extras, missing, - /// stale variants, duplicates, or a managed command - /// parked under the wrong event/matcher) + /// Compare the Supacode-managed hook objects present in the settings file + /// against the expected (canonical) managed hook objects: + /// - `.installed` — actual managed hooks == expected, including + /// execution-relevant fields (`type`, `timeout`, `env`, + /// etc.), no managed extras + /// - `.notInstalled` — no Supacode-managed hooks at all + /// - `.outdated` — some present, but they differ (extras, missing, + /// stale variants, duplicates, reordered groups, a managed + /// hook parked under the wrong event/matcher, or metadata + /// drift such as a changed `timeout` or `env`) /// /// The comparison is the ordered sequence of managed groups per event - /// (matcher + managed commands, in array order) rather than a command - /// `Set`. Canonical payloads legitimately reuse one command string in + /// (matcher + full managed hook objects, in array order) rather than a + /// command `Set`. Canonical payloads legitimately reuse one command string in /// several slots (e.g. `busy` under both `UserPromptSubmit` and the /// catch-all `PreToolUse` group), and hook groups execute in array order — - /// a reordered or partially deleted slot must read as drift, while a - /// user-authored group inserted between managed ones must not. - /// - /// `additionalOutdatedIfInstalled` runs only when the command set already - /// matches, against the **same** parsed snapshot (no second disk read). Use - /// it for non-command payload checks such as Grok's env passthrough map. + /// a reordered or partially deleted slot must read as drift. User-authored + /// groups and user-authored hooks interleaved with managed ones are skipped, + /// so inserting a custom group between ours does not read as drift. /// /// Throws when the file can't be read or parsed: an unreadable file is not /// an uninstalled one, and only the caller can decide what to do about it. func installState( settingsURL: URL, - hookGroupsByEvent: [String: [JSONValue]], - additionalOutdatedIfInstalled: (([String: JSONValue]) -> Bool)? = nil + hookGroupsByEvent: [String: [JSONValue]] ) throws -> ComponentInstallState { do { let settingsObject = try loadSettingsObject(at: settingsURL) @@ -57,9 +56,6 @@ nonisolated struct AgentHookSettingsFileInstaller { let actual = Self.installedSupacodeCommands(in: settingsObject) if actual.isEmpty { return .notInstalled } guard actual == expected else { return .outdated } - if let additionalOutdatedIfInstalled, additionalOutdatedIfInstalled(settingsObject) { - return .outdated - } return .installed } catch { logWarning("Failed to inspect hook settings at \(settingsURL.path): \(error)") @@ -68,19 +64,19 @@ nonisolated struct AgentHookSettingsFileInstaller { } /// The managed content of one hook group, in execution order: the group's - /// `matcher` (nil when the key is absent) plus its Supacode-managed commands - /// in array order. `installState` compares the ordered sequence of these - /// per event, so reordered groups read as drift even when every command is - /// still present. + /// `matcher` (nil when the key is absent) plus its Supacode-managed hook + /// objects in array order. `installState` compares the ordered sequence of + /// these per event, so reordered groups and metadata drift (`timeout`, + /// `type`, `env`, …) read as drift even when the command text is unchanged. private struct ManagedGroupOccurrence: Hashable { let matcher: JSONValue? - let commands: [String] + let hooks: [JSONValue] } /// Supacode-managed groups under the `hooks` map, kept in array order per - /// event. A group contributes only its managed commands — user-authored - /// hooks and fully user-authored groups are skipped, so inserting a custom - /// group between managed ones is not drift. + /// event. A group contributes only its Supacode-managed hook objects — + /// user-authored hooks and fully user-authored groups are skipped, so + /// inserting a custom group between managed ones is not drift. private static func installedSupacodeCommands( in settingsObject: [String: JSONValue] ) -> [String: [ManagedGroupOccurrence]] { @@ -94,15 +90,15 @@ nonisolated struct AgentHookSettingsFileInstaller { guard let groupObject = group.objectValue, let hooks = groupObject["hooks"]?.arrayValue else { return nil } - let commands = hooks.compactMap { hook -> String? in + let managedHooks = hooks.compactMap { hook -> JSONValue? in guard let hookObject = hook.objectValue, let command = hookObject["command"]?.stringValue, AgentHookCommandOwnership.isSupacodeManagedCommand(command) else { return nil } - return command + return hook } - guard !commands.isEmpty else { return nil } - return ManagedGroupOccurrence(matcher: groupObject["matcher"], commands: commands) + guard !managedHooks.isEmpty else { return nil } + return ManagedGroupOccurrence(matcher: groupObject["matcher"], hooks: managedHooks) } if !managed.isEmpty { occurrences[event] = managed @@ -120,9 +116,7 @@ nonisolated struct AgentHookSettingsFileInstaller { guard let groupObject = group.objectValue, let hooks = groupObject["hooks"]?.arrayValue else { return nil } - let commands = hooks.compactMap { $0.objectValue?["command"]?.stringValue } - guard !commands.isEmpty else { return nil } - return ManagedGroupOccurrence(matcher: groupObject["matcher"], commands: commands) + return ManagedGroupOccurrence(matcher: groupObject["matcher"], hooks: hooks) } if !managed.isEmpty { occurrences[event] = managed diff --git a/SupacodeSettingsShared/BusinessLogic/GrokSettingsInstaller.swift b/SupacodeSettingsShared/BusinessLogic/GrokSettingsInstaller.swift index 3739f3b00..b58a56603 100644 --- a/SupacodeSettingsShared/BusinessLogic/GrokSettingsInstaller.swift +++ b/SupacodeSettingsShared/BusinessLogic/GrokSettingsInstaller.swift @@ -22,9 +22,8 @@ nonisolated struct GrokSettingsInstaller { /// Install state for the unified hook map. See /// `ClaudeSettingsInstaller.installState()` for rationale. /// - /// After the shared command-set check, also requires every managed hook to - /// carry the canonical Grok env passthrough map, inspected on the same - /// parsed snapshot (no second disk read). + /// The shared installer compares full managed hook objects, so env + /// passthrough drift is detected together with command and ordering drift. func installState() throws -> ComponentInstallState { let groups: [String: [JSONValue]] do { @@ -35,8 +34,7 @@ nonisolated struct GrokSettingsInstaller { } return try fileInstaller.installState( settingsURL: settingsURL, - hookGroupsByEvent: groups, - additionalOutdatedIfInstalled: GrokHookSettings.managedHooksLackEnvPassthrough(in:) + hookGroupsByEvent: groups ) } diff --git a/supacodeTests/AgentHookSettingsFileInstallerTests.swift b/supacodeTests/AgentHookSettingsFileInstallerTests.swift index b53788f42..91cbbf683 100644 --- a/supacodeTests/AgentHookSettingsFileInstallerTests.swift +++ b/supacodeTests/AgentHookSettingsFileInstallerTests.swift @@ -485,6 +485,54 @@ struct AgentHookSettingsFileInstallerTests { #expect(try installer.installState(settingsURL: url, hookGroupsByEvent: groups) == .installed) } + @Test func installStateIsOutdatedWhenManagedHookTimeoutChanges() throws { + // The command text is unchanged but execution-relevant metadata drifted, + // so the update affordance must appear. + let url = makeTempURL() + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } + + let installer = makeInstaller() + let groups = sampleHookGroups() + try installer.install(settingsURL: url, hookGroupsByEvent: groups) + + var root = try JSONDecoder().decode(JSONValue.self, from: Data(contentsOf: url)) + .objectValue! + var hooks = root["hooks"]!.objectValue! + var stopGroup = hooks["Stop"]!.arrayValue![0].objectValue! + var stopHooks = stopGroup["hooks"]!.arrayValue! + var hookObject = stopHooks[0].objectValue! + hookObject["timeout"] = 99 + stopGroup["hooks"] = .array([.object(hookObject)]) + hooks["Stop"] = .array([.object(stopGroup)]) + root["hooks"] = .object(hooks) + try JSONEncoder().encode(.object(root)).write(to: url) + + #expect(try installer.installState(settingsURL: url, hookGroupsByEvent: groups) == .outdated) + } + + @Test func installStateIsOutdatedWhenManagedHookTypeChanges() throws { + let url = makeTempURL() + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } + + let installer = makeInstaller() + let groups = sampleHookGroups() + try installer.install(settingsURL: url, hookGroupsByEvent: groups) + + var root = try JSONDecoder().decode(JSONValue.self, from: Data(contentsOf: url)) + .objectValue! + var hooks = root["hooks"]!.objectValue! + var stopGroup = hooks["Stop"]!.arrayValue![0].objectValue! + var stopHooks = stopGroup["hooks"]!.arrayValue! + var hookObject = stopHooks[0].objectValue! + hookObject["type"] = "shell" + stopGroup["hooks"] = .array([.object(hookObject)]) + hooks["Stop"] = .array([.object(stopGroup)]) + root["hooks"] = .object(hooks) + try JSONEncoder().encode(.object(root)).write(to: url) + + #expect(try installer.installState(settingsURL: url, hookGroupsByEvent: groups) == .outdated) + } + @Test func containsMatchingHooksLogsInvalidJSONErrors() throws { let url = makeTempURL() let warnings = LockIsolated<[String]>([]) diff --git a/supacodeTests/GrokSettingsInstallerTests.swift b/supacodeTests/GrokSettingsInstallerTests.swift index d9ba405ad..05c8e0d8b 100644 --- a/supacodeTests/GrokSettingsInstallerTests.swift +++ b/supacodeTests/GrokSettingsInstallerTests.swift @@ -86,7 +86,7 @@ struct GrokSettingsInstallerTests { #expect(try installer.installState() == .installed) // An older install carries the full canonical command set but no env - // blocks. The command set still matches, so only the env check can flag it. + // blocks. The managed hook objects now include env, so the diff is flagged. let settingsURL = GrokSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) try rewriteManagedHookEnv(at: settingsURL) { _ in nil } From 0b89866212410a29840bed08caa57bcff49e3607 Mon Sep 17 00:00:00 2001 From: Viorel-Cosmin Miron Date: Sun, 20 Sep 2026 10:42:50 +0200 Subject: [PATCH 5/5] Document Devin hook contract sources and test Stop stdin field The hook payload assumptions are now explicitly sourced from the local Devin CLI documentation: event names, hook format, matcher semantics, per-event stdin fields, Stop's last_assistant_message, and Ctrl+V image paste. Add a test asserting that the Stop notify body is extracted from last_assistant_message, matching the documented contract. --- .../BusinessLogic/DevinHookSettings.swift | 13 +++++++++++-- supacodeTests/DevinHookSettingsTests.swift | 10 ++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/SupacodeSettingsShared/BusinessLogic/DevinHookSettings.swift b/SupacodeSettingsShared/BusinessLogic/DevinHookSettings.swift index 6b8e2240f..325e64d18 100644 --- a/SupacodeSettingsShared/BusinessLogic/DevinHookSettings.swift +++ b/SupacodeSettingsShared/BusinessLogic/DevinHookSettings.swift @@ -19,8 +19,17 @@ nonisolated enum DevinHookSettingsError: Error { // Devin reads `hooks` from `~/.config/devin/config.json` in Claude's events // shape, with two deltas: there is no `Notification` event, and hook matchers -// see snake_case tool names (`ask_user_question`, not `AskUserQuestion`). The -// busy/idle/awaitingInput mapping otherwise mirrors `ClaudeHooksPayload`: +// see snake_case tool names (`ask_user_question`, not `AskUserQuestion`). +// +// Sources verified against local Devin CLI docs (stable release, 2026-06): +// - extensibility/hooks/overview.mdx: event names, hook format, matcher regexes +// (`""` or omitted matches every tool name). +// - extensibility/hooks/lifecycle-hooks.mdx: per-event stdin fields, including +// `tool_name`, `tool_input`, `prompt`, `reason`, `stop_hook_active`. +// - changelog/stable.mdx: Stop hooks receive `last_assistant_message` in stdin. +// - reference/keyboard-shortcuts.mdx: image paste uses `Ctrl+V`. +// +// The busy/idle/awaitingInput mapping mirrors `ClaudeHooksPayload`: // `PermissionRequest` stands in for Claude's permission `Notification`, and // `Stop` carries `last_assistant_message`, so the stdin-sourced notify lands // the turn's final response like Claude's idle branch. `PostCompaction` is diff --git a/supacodeTests/DevinHookSettingsTests.swift b/supacodeTests/DevinHookSettingsTests.swift index 18d8d3ec6..ee50bbfbb 100644 --- a/supacodeTests/DevinHookSettingsTests.swift +++ b/supacodeTests/DevinHookSettingsTests.swift @@ -45,6 +45,16 @@ struct DevinHookSettingsTests { #expect(commands.allSatisfy { ManagedHookCommandVariables.unexpected(in: $0).isEmpty }) } + @Test func stopCommandReadsLastAssistantMessageForNotifyBody() throws { + // Devin's Stop hook stdin includes `last_assistant_message` (per the Devin + // CLI changelog), matching Claude Code. The notify body is extracted from + // that field rather than a fixed string. + let stop = try #require(try DevinHookSettings.hooksByEvent()["Stop"]) + let commands = Self.commandStrings(in: stop) + #expect(!commands.isEmpty) + #expect(commands.allSatisfy { $0.contains("last_assistant_message") }) + } + @Test func postToolUseFiresIdleNotBusy() throws { let postToolUse = try #require(try DevinHookSettings.hooksByEvent()["PostToolUse"]) let commands = Self.commandStrings(in: postToolUse)