Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,85 +23,106 @@ 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
/// - `.notInstalled` — no Supacode-managed commands at all
/// - `.outdated` — some present, but the set differs (extras, missing,
/// or stale variants from older Supacode versions)
/// 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`)
///
/// `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.
/// The comparison is the ordered sequence of managed groups per event
/// (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. 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)
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 }
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)")
throw error
}
}

/// All Supacode-marked `command` strings under the `hooks` map. Filters
/// via `AgentHookCommandOwnership` so user-authored hooks are never
/// treated as "ours."
/// The managed content of one hook group, in execution order: the group's
/// `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 hooks: [JSONValue]
}

/// Supacode-managed groups under the `hooks` map, kept in array order per
/// 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]
) -> Set<String> {
) -> [String: [ManagedGroupOccurrence]] {
guard let hooksValue = settingsObject["hooks"],
let hooksObject = hooksValue.objectValue
else { return [] }
var commands = Set<String>()
for (_, value) in hooksObject {
else { return [:] }
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 managedHooks = hooks.compactMap { hook -> JSONValue? in
guard let hookObject = hook.objectValue,
let command = hookObject["command"]?.stringValue,
AgentHookCommandOwnership.isSupacodeManagedCommand(command)
else { continue }
commands.insert(command)
else { return nil }
return hook
}
guard !managedHooks.isEmpty else { return nil }
return ManagedGroupOccurrence(matcher: groupObject["matcher"], hooks: managedHooks)
}
if !managed.isEmpty {
occurrences[event] = managed
}
}
return commands
return occurrences
}

private static func commands(from hookGroupsByEvent: [String: [JSONValue]]) -> Set<String> {
var commands = Set<String>()
for (_, groups) in hookGroupsByEvent {
for group in groups {
private static func expectedCommandOccurrences(
from hookGroupsByEvent: [String: [JSONValue]]
) -> [String: [ManagedGroupOccurrence]] {
var occurrences: [String: [ManagedGroupOccurrence]] = [:]
for (event, groups) in hookGroupsByEvent {
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 }
commands.insert(command)
}
else { return nil }
return ManagedGroupOccurrence(matcher: groupObject["matcher"], hooks: hooks)
}
if !managed.isEmpty {
occurrences[event] = managed
}
}
return commands
return occurrences
}

/// Removes every Supacode-managed command (current and legacy) from the
Expand Down
17 changes: 17 additions & 0 deletions SupacodeSettingsShared/BusinessLogic/AgentIntegrationFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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]
{
Expand Down
88 changes: 88 additions & 0 deletions SupacodeSettingsShared/BusinessLogic/DevinHookSettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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`).
//
// 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
// 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)])
],
]
}
97 changes: 97 additions & 0 deletions SupacodeSettingsShared/BusinessLogic/DevinSettingsInstaller.swift
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
Loading
Loading