native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
33 kB · 935 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936import Foundationimport ProwlCLIShared
nonisolated enum ClaudeSettingsReadResult: Equatable, Sendable { case stable(Data) case changed case oversized case unreadable}
nonisolated enum ClaudeSettingsStableReader { static func read( _ url: URL, maximumBytes: Int, afterRead: () -> Void = {} ) -> ClaudeSettingsReadResult { switch StableOwnerFileReader.read(url, maximumBytes: maximumBytes, afterRead: afterRead) { case .stable(let data): .stable(data) case .changed: .changed case .oversized: .oversized case .unreadable: .unreadable } }}
/// JSON settings hook merging shared by every runtime whose hook configuration is a settings/// object: Claude (`--settings`, last-wins, inline or path), Qoder (`--settings`, first-wins,/// inline or path), and Droid (`--settings`, last-wins, path only).nonisolated enum ManagedHookSettings { static let maximumBytes = 256 * 1_024
/// Decode an inline-JSON-or-path settings source into an object, or fail closed. static func object( from source: String, launchDirectory: URL, allowInline: Bool = true, readFile: (URL, Int) -> ClaudeSettingsReadResult ) -> [String: Any]? { let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) let data: Data if allowInline, trimmed.hasPrefix("{") { data = Data(trimmed.utf8) guard data.count <= maximumBytes else { return nil } } else { // A path-only source (Droid) is trimmed and tilde-expanded the way the runtime resolves it, // so `~/settings.json` or a value with stray whitespace names the same file Droid would read. let pathValue = allowInline ? source : (trimmed as NSString).expandingTildeInPath let sourceURL = URL(filePath: pathValue, relativeTo: launchDirectory).standardizedFileURL switch readFile(sourceURL, maximumBytes) { case .stable(let value): data = value case .changed, .oversized, .unreadable: return nil } } return try? JSONSerialization.jsonObject(with: data) as? [String: Any] }
/// Append Prowl's command hooks while preserving unknown fields and every existing handler. /// Returns `nil` when the object's `hooks` shape is not one this merge understands. static func merged(_ object: [String: Any], hookCommands: [String: String]) -> [String: Any]? { var object = object var hooks: [String: Any] if let existing = object["hooks"] { guard let existing = existing as? [String: Any] else { return nil } hooks = existing } else { hooks = [:] }
for event in hookCommands.keys.sorted() { guard let command = hookCommands[event] else { continue } var matchers: [[String: Any]] if let existing = hooks[event] { guard let existing = existing as? [[String: Any]] else { return nil } matchers = existing } else { matchers = [] } if !containsCommand(command, in: matchers) { matchers.append(["hooks": [["command": command, "type": "command"]]]) } hooks[event] = matchers } object["hooks"] = hooks return object }
static func serializedData(_ object: [String: Any]) -> Data? { guard JSONSerialization.isValidJSONObject(object), let data = try? JSONSerialization.data( withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes] ), data.count <= maximumBytes else { return nil } return data }
static func serialized(_ object: [String: Any]) -> String? { serializedData(object).flatMap { String(data: $0, encoding: .utf8) } }
/// Managed options are inserted before the runtime's prompt argument and before any /// end-of-options sentinel, so they are always parsed as options. static func insertionIndex(promptArgumentIndex: Int?, arguments: [String]) -> Int { let promptIndex = promptArgumentIndex.flatMap { arguments.indices.contains($0) ? $0 : nil } ?? arguments.endIndex let sentinelIndex = arguments.firstIndex(of: "--") ?? arguments.endIndex return min(promptIndex, sentinelIndex) }
static func degraded( _ invocation: AgentInvocation, runtime: AgentNativeHookRuntime, message: String ) -> AgentHookPreparationOutcome { AgentHookPreparationOutcome( originalInvocation: invocation, prepared: nil, warning: LifecycleCommandWarning( code: .managedHookDegraded, runtime: runtime.rawValue, message: message ) ) }
private static func containsCommand(_ command: String, in matchers: [[String: Any]]) -> Bool { matchers.contains { matcher in guard let handlers = matcher["hooks"] as? [[String: Any]] else { return false } return handlers.contains { ($0["type"] as? String) == "command" && ($0["command"] as? String) == command } } }}
nonisolated extension ManagedHookSettings { /// Which repeated occurrence of a settings option the runtime actually honors. enum Precedence: Sendable { case firstWins case lastWins }
enum SettingsScan: Equatable { case none case source(String) case malformed }
/// Find the settings source the runtime will honor. `--option value` and `--option=value` /// are both recognized; the prompt argument is skipped so it can never be read as a value. static func scanSettings( arguments: [String], optionName: String, precedence: Precedence, promptArgumentIndex: Int? ) -> SettingsScan { let joinedPrefix = "\(optionName)=" var found: String? var index = 0 while index < arguments.count { if index == promptArgumentIndex { index += 1 continue } let argument = arguments[index] if argument == "--" { break } if argument == optionName { guard arguments.indices.contains(index + 1), index + 1 != promptArgumentIndex else { return .malformed } if found == nil || precedence == .lastWins { found = arguments[index + 1] } index += 2 continue } if argument.hasPrefix(joinedPrefix) { let value = String(argument.dropFirst(joinedPrefix.count)) if found == nil || precedence == .lastWins { found = value } } index += 1 } return found.map(SettingsScan.source) ?? .none }
enum EffectiveSettings { /// The object the runtime would load: empty when the user supplied no source. case object([String: Any]) /// A source exists but cannot be used. Injecting anyway would drop the user's config, /// so the launch must degrade instead. case unusable }
/// Every settings-based runtime uses the same option name; only precedence differs. static let settingsOptionName = "--settings"
static func effectiveObject( arguments: [String], precedence: Precedence, promptArgumentIndex: Int?, launchDirectory: URL, allowInline: Bool = true, readFile: (URL, Int) -> ClaudeSettingsReadResult ) -> EffectiveSettings { switch scanSettings( arguments: arguments, optionName: settingsOptionName, precedence: precedence, promptArgumentIndex: promptArgumentIndex ) { case .none: return .object([:]) case .malformed: return .unusable case .source(let value): guard let object = object( from: value, launchDirectory: launchDirectory, allowInline: allowInline, readFile: readFile) else { return .unusable } return .object(object) } }}
/// The working directory a runtime will actually report in its hook payloads. Copilot (`-C`,/// last wins), Droid (`--cwd`, last wins), and Qoder (`-w`/`--cwd`, first wins) all change/// directory before their hooks run, while a relative `--settings` path is still resolved/// against the directory they were launched from — both measured, so the two must stay apart.nonisolated enum ManagedHookWorkingDirectory { enum Scan: Equatable, Sendable { case inherited case changed(String) case malformed }
/// `--option value` and `--option=value` are recognized for long options, `-X value` and /// `-Xvalue` for single-letter ones. The prompt argument is never read as a value. static func scan( arguments: [String], optionNames: [String], precedence: ManagedHookSettings.Precedence, promptArgumentIndex: Int? ) -> Scan { var found: String? var index = 0 func record(_ value: String) { if found == nil || precedence == .lastWins { found = value } } while index < arguments.count { if index == promptArgumentIndex { index += 1 continue } let argument = arguments[index] if argument == "--" { break } if optionNames.contains(argument) { guard arguments.indices.contains(index + 1), index + 1 != promptArgumentIndex else { return .malformed } record(arguments[index + 1]) index += 2 continue } for name in optionNames { let isLong = name.hasPrefix("--") let joinedPrefix = isLong ? "\(name)=" : name guard argument.hasPrefix(joinedPrefix), argument.count > joinedPrefix.count else { continue } record(String(argument.dropFirst(joinedPrefix.count))) break } index += 1 } return found.map(Scan.changed) ?? .inherited }
/// Resolve a scan against the launch directory; `nil` means the option could not be read, /// so the launch must degrade rather than register a directory the hooks will never report. static func effective(inherited: URL, scan: Scan) -> URL? { switch scan { case .inherited: return inherited case .changed(let value): return URL(filePath: value, directoryHint: .isDirectory, relativeTo: inherited).standardizedFileURL case .malformed: return nil } }}
/// Copilot loads every `--plugin-dir` additively, so Prowl appends its own bundled plugin and/// never merges with, replaces, or inspects the user's plugins or `~/.copilot/hooks/*.json`.nonisolated enum CopilotHookPluginRenderer { /// Copilot passes a prompted start as the value of `--interactive` / `--prompt`, so managed /// options must land before that option rather than between the option and its value. static func prepare( invocation: AgentInvocation, pluginDirectory: URL, promptArgumentIndex: Int? ) -> AgentHookPreparedInvocation { var arguments = invocation.arguments let insertionIndex = ManagedHookSettings.insertionIndex( promptArgumentIndex: promptArgumentIndex.map { max(0, $0 - 1) }, arguments: arguments ) arguments.insert(contentsOf: ["--plugin-dir", ""], at: insertionIndex) return AgentHookPreparedInvocation( invocation: AgentInvocation(executable: invocation.executable, arguments: arguments), argumentValues: [insertionIndex + 1: pluginDirectory.path(percentEncoded: false)] ) }}
/// Qoder honors the *first* `--settings`, so a merged object is inserted ahead of the user's./// Inserting after would leave Prowl's hooks dead; inserting an unmerged object ahead would/// silently disable the user's settings.nonisolated enum QoderHookSettingsPreparer { static func prepare( invocation: AgentInvocation, launchDirectory: URL, promptArgumentIndex: Int?, hookCommands: [String: String], readFile: (URL, Int) -> ClaudeSettingsReadResult ) -> AgentHookPreparationOutcome { // `--setting-sources` drops flag-supplied hooks entirely, so injecting would only create // a channel that never fires. if containsSettingSources(invocation.arguments, promptArgumentIndex: promptArgumentIndex) { return ManagedHookSettings.degraded( invocation, runtime: .qoder, message: "Managed Qoder hooks are unavailable when --setting-sources is set; launching unchanged." ) }
guard case .object(let base) = ManagedHookSettings.effectiveObject( arguments: invocation.arguments, precedence: .firstWins, promptArgumentIndex: promptArgumentIndex, launchDirectory: launchDirectory, readFile: readFile ), let merged = ManagedHookSettings.merged(base, hookCommands: hookCommands), let json = ManagedHookSettings.serialized(merged) else { return ManagedHookSettings.degraded( invocation, runtime: .qoder, message: "Managed Qoder hooks could not be prepared; launching with the original settings." ) }
var arguments = invocation.arguments arguments.insert(contentsOf: ["--settings", ""], at: 0) return AgentHookPreparationOutcome( originalInvocation: invocation, prepared: AgentHookPreparedInvocation( invocation: AgentInvocation(executable: invocation.executable, arguments: arguments), argumentValues: [1: json] ), warning: nil ) }
private static func containsSettingSources( _ arguments: [String], promptArgumentIndex: Int? ) -> Bool { for (index, argument) in arguments.enumerated() { if index == promptArgumentIndex { continue } if argument == "--" { return false } if argument == "--setting-sources" || argument.hasPrefix("--setting-sources=") { return true } } return false }}
/// Droid's `--settings` accepts a path only, so the merged object must be written to an/// owner-only file: a user's settings can carry secrets such as `customModels[].apiKey`./// Repeated flags are last-wins, so Prowl's path is appended after the user's.nonisolated enum DroidHookSettingsPreparer { struct MergedSettings: Equatable, Sendable { let data: Data? let warning: LifecycleCommandWarning? }
/// Phase 1, pure: resolve the effective user settings and merge Prowl's handlers in. /// Writing is the caller's job because the owner-only store is main-actor isolated. static func mergedSettings( invocation: AgentInvocation, launchDirectory: URL, promptArgumentIndex: Int?, hookCommands: [String: String], environmentSettingsPath: String? = nil, environmentResolutionFailed: Bool = false, readFile: (URL, Int) -> ClaudeSettingsReadResult ) -> MergedSettings { func degraded() -> MergedSettings { MergedSettings( data: nil, warning: LifecycleCommandWarning( code: .managedHookDegraded, runtime: AgentNativeHookRuntime.droid.rawValue, message: "Managed Droid hooks could not be prepared; launching with the original settings." ) ) }
// The `--settings` flag wins over `FACTORY_RUNTIME_SETTINGS_PATH` (measured), so resolve the // base the runtime would load: the flag if present, otherwise the env-pointed file. Injecting // the managed flag without merging the env file would silently drop the user's config, so an // unreadable env source degrades rather than overrides. Droid's `--settings` is path-only, so // a `{`-prefixed value is a missing path, never inline JSON to synthesize. let base: [String: Any] switch ManagedHookSettings.effectiveObject( arguments: invocation.arguments, precedence: .lastWins, promptArgumentIndex: promptArgumentIndex, launchDirectory: launchDirectory, allowInline: false, readFile: readFile ) { case .unusable: return degraded() case .object(let flagBase) where !flagBase.isEmpty: base = flagBase case .object: if ManagedHookSettings.scanSettings( arguments: invocation.arguments, optionName: ManagedHookSettings.settingsOptionName, precedence: .lastWins, promptArgumentIndex: promptArgumentIndex ) != .none { base = [:] // an explicit empty `{}` flag object: honor it as-is } else if let environmentSettingsPath { guard let object = ManagedHookSettings.object( from: environmentSettingsPath, launchDirectory: launchDirectory, allowInline: false, readFile: readFile ) else { return degraded() } base = object } else if environmentResolutionFailed { // The shell could not be consulted, so `FACTORY_RUNTIME_SETTINGS_PATH` might point at a // config the injected flag would override. Degrade rather than risk dropping it. return degraded() } else { base = [:] } }
guard let merged = ManagedHookSettings.merged(base, hookCommands: hookCommands), let data = ManagedHookSettings.serializedData(merged), // The merged object is written to the owner-only private-file store, whose cap is smaller // than the generic merge cap. Degrade here, with the Droid reason, rather than serialize // successfully and fail opaquely at write time. data.count <= CodexForwardingRecordReader.maximumRecordBytes else { return degraded() } return MergedSettings(data: data, warning: nil) }
/// Phase 2: point Droid at the written file. Repeated flags are last-wins, so Prowl's path /// is appended after any the user supplied. static func applying( settingsPath: URL, invocation: AgentInvocation, promptArgumentIndex: Int? ) -> AgentHookPreparedInvocation { var arguments = invocation.arguments let insertionIndex = ManagedHookSettings.insertionIndex( promptArgumentIndex: promptArgumentIndex, arguments: arguments ) arguments.insert(contentsOf: ["--settings", ""], at: insertionIndex) return AgentHookPreparedInvocation( invocation: AgentInvocation(executable: invocation.executable, arguments: arguments), argumentValues: [insertionIndex + 1: settingsPath.path(percentEncoded: false)] ) }}
nonisolated struct AgentHookPreparedInvocation: Equatable, Sendable { let invocation: AgentInvocation /// Actual argv values carried through owner-controlled surface environment. /// Keys are indexes in `invocation.arguments`. let argumentValues: [Int: String] /// Launch-scoped environment variables (name -> value) that ride owner-controlled carriers /// exactly like `argumentValues`: the typed command names the carrier, never the value. let environmentValues: [String: String]
init( invocation: AgentInvocation, argumentValues: [Int: String], environmentValues: [String: String] = [:] ) { self.invocation = invocation self.argumentValues = argumentValues self.environmentValues = environmentValues }}
nonisolated struct AgentHookPreparationOutcome: Equatable, Sendable { let originalInvocation: AgentInvocation let prepared: AgentHookPreparedInvocation? let warning: LifecycleCommandWarning?}
nonisolated enum ClaudeHookSettingsPreparer { static let maximumSettingsBytes = 256 * 1_024
static func prepare( invocation: AgentInvocation, launchDirectory: URL, promptArgumentIndex: Int? = nil, hookCommands: [String: String], readFile: (URL, Int) -> ClaudeSettingsReadResult ) -> AgentHookPreparationOutcome { let source: SettingsSource? switch finalSettingsSource( in: invocation.arguments, promptArgumentIndex: promptArgumentIndex ) { case .none: source = nil case .source(let value): source = value case .malformed: return degraded(invocation) } let baseObject: [String: Any] if let source { switch settingsObject( source.value, launchDirectory: launchDirectory, readFile: readFile ) { case .success(let object): baseObject = object case .failure: return degraded(invocation) } } else { baseObject = [:] }
guard let merged = mergedObject(baseObject, hookCommands: hookCommands), JSONSerialization.isValidJSONObject(merged), let data = try? JSONSerialization.data( withJSONObject: merged, options: [.sortedKeys, .withoutEscapingSlashes] ), data.count <= maximumSettingsBytes, let json = String(data: data, encoding: .utf8) else { return degraded(invocation) }
var arguments = invocation.arguments let carrierIndex: Int if let source { switch source.form { case .separate: carrierIndex = source.argumentIndex case .joined: arguments[source.argumentIndex] = "--settings" arguments.insert("{}", at: source.argumentIndex + 1) carrierIndex = source.argumentIndex + 1 } } else { let insertionIndex = managedOptionInsertionIndex( promptArgumentIndex: promptArgumentIndex, arguments: arguments ) arguments.insert(contentsOf: ["--settings", "{}"], at: insertionIndex) carrierIndex = insertionIndex + 1 }
return AgentHookPreparationOutcome( originalInvocation: invocation, prepared: AgentHookPreparedInvocation( invocation: AgentInvocation(executable: invocation.executable, arguments: arguments), argumentValues: [carrierIndex: json] ), warning: nil ) }
private enum SettingsForm { case separate case joined }
private enum SettingsScan { case none case source(SettingsSource) case malformed }
private struct SettingsSource { let form: SettingsForm let argumentIndex: Int let value: String }
private enum SettingsObjectResult { case success([String: Any]) case failure }
private static func finalSettingsSource( in arguments: [String], promptArgumentIndex: Int? ) -> SettingsScan { var result: SettingsSource? var index = 0 while index < arguments.count { if index == promptArgumentIndex { index += 1 continue } let argument = arguments[index] if argument == "--" { break } if argument == "--settings" { guard arguments.indices.contains(index + 1), index + 1 != promptArgumentIndex else { return .malformed } result = SettingsSource(form: .separate, argumentIndex: index + 1, value: arguments[index + 1]) index += 2 continue } if argument.hasPrefix("--settings=") { result = SettingsSource( form: .joined, argumentIndex: index, value: String(argument.dropFirst("--settings=".count)) ) } index += 1 } return result.map(SettingsScan.source) ?? .none }
private static func settingsObject( _ source: String, launchDirectory: URL, readFile: (URL, Int) -> ClaudeSettingsReadResult ) -> SettingsObjectResult { let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) let data: Data if trimmed.hasPrefix("{") { data = Data(trimmed.utf8) guard data.count <= maximumSettingsBytes else { return .failure } } else { let sourceURL = URL(filePath: source, relativeTo: launchDirectory).standardizedFileURL switch readFile(sourceURL, maximumSettingsBytes) { case .stable(let value): data = value case .changed, .oversized, .unreadable: return .failure } } guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return .failure } return .success(object) }
private static func mergedObject( _ object: [String: Any], hookCommands: [String: String] ) -> [String: Any]? { var object = object var hooks: [String: Any] if let existing = object["hooks"] { guard let existing = existing as? [String: Any] else { return nil } hooks = existing } else { hooks = [:] }
for event in hookCommands.keys.sorted() { guard let command = hookCommands[event] else { continue } var matchers: [[String: Any]] if let existing = hooks[event] { guard let existing = existing as? [[String: Any]] else { return nil } matchers = existing } else { matchers = [] } if !containsCommand(command, in: matchers) { matchers.append([ "hooks": [ [ "command": command, "type": "command", ] ] ]) } hooks[event] = matchers } object["hooks"] = hooks return object }
private static func containsCommand(_ command: String, in matchers: [[String: Any]]) -> Bool { matchers.contains { matcher in guard let handlers = matcher["hooks"] as? [[String: Any]] else { return false } return handlers.contains { ($0["type"] as? String) == "command" && ($0["command"] as? String) == command } } }
private static func managedOptionInsertionIndex( promptArgumentIndex: Int?, arguments: [String] ) -> Int { let promptIndex = promptArgumentIndex.flatMap { arguments.indices.contains($0) ? $0 : nil } ?? arguments.endIndex let sentinelIndex = arguments.firstIndex(of: "--") ?? arguments.endIndex return min(promptIndex, sentinelIndex) }
private static func degraded(_ invocation: AgentInvocation) -> AgentHookPreparationOutcome { AgentHookPreparationOutcome( originalInvocation: invocation, prepared: nil, warning: LifecycleCommandWarning( code: .managedHookDegraded, runtime: AgentNativeHookRuntime.claude.rawValue, message: "Managed Claude hooks could not be prepared; launching with the original settings." ) ) }}
nonisolated enum CodexManagedNotifyRenderer { static func prepare( invocation: AgentInvocation, bundledCLIPath: String, promptArgumentIndex: Int? = nil ) -> AgentHookPreparedInvocation { let notifyArgv = [ bundledCLIPath, "agents", "_hook", AgentNativeHookRuntime.codex.rawValue, "agent-turn-complete", ] let notifyData = try? JSONSerialization.data( withJSONObject: notifyArgv, options: [.withoutEscapingSlashes] ) let notifyJSON = notifyData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]" var arguments = invocation.arguments let promptIndex = promptArgumentIndex.flatMap { arguments.indices.contains($0) ? $0 : nil } ?? arguments.endIndex let sentinelIndex = arguments.firstIndex(of: "--") ?? arguments.endIndex let insertionIndex = min(promptIndex, sentinelIndex) arguments.insert(contentsOf: ["-c", "notify=[]"], at: insertionIndex) return AgentHookPreparedInvocation( invocation: AgentInvocation(executable: invocation.executable, arguments: arguments), argumentValues: [insertionIndex + 1: "notify=\(notifyJSON)"] ) }}
/// Pi (`-e`) and Oh My Pi (`--hook`) load every extension flag additively and keep explicit/// paths even under `--no-extensions`, so Prowl appends its bundled extension file ahead of the/// prompt positional and never inspects discovered or user-supplied extensions.nonisolated enum ExtensionFlagHookRenderer { static func prepare( invocation: AgentInvocation, option: String, extensionPath: String, promptArgumentIndex: Int? ) -> AgentHookPreparedInvocation { var arguments = invocation.arguments let insertionIndex = ManagedHookSettings.insertionIndex( promptArgumentIndex: promptArgumentIndex, arguments: arguments ) arguments.insert(contentsOf: [option, ""], at: insertionIndex) return AgentHookPreparedInvocation( invocation: AgentInvocation(executable: invocation.executable, arguments: arguments), argumentValues: [insertionIndex + 1: extensionPath] ) }}
/// OpenCode loads plugins from `OPENCODE_CONFIG_CONTENT`, one more config layer whose/// `plugin[]` concatenates with the global and project lists (measured on 1.18.23), so Prowl/// only appends its bundled plugin to whatever content the launch already carries and never/// touches a config file. `--pure` and `OPENCODE_PURE` drop every external plugin, so those/// launches degrade instead of registering a channel that can never fire.nonisolated enum OpenCodeHookPluginPreparer { static let contentVariableName = "OPENCODE_CONFIG_CONTENT" static let pureVariableName = "OPENCODE_PURE" static let environmentVariableNames = [contentVariableName, pureVariableName]
/// OpenCode treats the variable as set unless it is `0` or `false`; an empty value still /// counts (measured on 1.18.23). static func isPure(environmentValue: String?) -> Bool { guard let environmentValue else { return false } let normalized = environmentValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return !["0", "false"].contains(normalized) }
static func containsPureFlag(_ arguments: [String], promptArgumentIndex: Int?) -> Bool { for (index, argument) in arguments.enumerated() where index != promptArgumentIndex { if argument == "--" { return false } if argument == "--pure" || argument == "--pure=true" { return true } } return false }
static func containsAutoFlag(_ arguments: [String], promptArgumentIndex: Int?) -> Bool { for (index, argument) in arguments.enumerated() where index != promptArgumentIndex { if argument == "--" { return false } if argument == "--auto" || argument == "--auto=true" { return true } } return false }
static func prepare( invocation: AgentInvocation, pluginPath: String, existingContent: String?, promptArgumentIndex: Int? ) -> AgentHookPreparationOutcome { if containsPureFlag(invocation.arguments, promptArgumentIndex: promptArgumentIndex) { return ManagedHookSettings.degraded( invocation, runtime: .opencode, message: "Managed OpenCode hooks are unavailable when --pure is set; launching unchanged." ) } var object: [String: Any] = [:] // OpenCode ignores an empty variable, so only a non-blank value is an existing layer. if let existingContent, !existingContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let data = Data(existingContent.utf8) guard data.count <= ManagedHookSettings.maximumBytes, let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return ManagedHookSettings.degraded( invocation, runtime: .opencode, message: "The existing OPENCODE_CONFIG_CONTENT is not a JSON object; launching unchanged." ) } object = parsed } var plugins: [Any] = [] if let existing = object["plugin"] { guard let list = existing as? [Any] else { return ManagedHookSettings.degraded( invocation, runtime: .opencode, message: "The existing OPENCODE_CONFIG_CONTENT plugin list is not an array; launching unchanged." ) } plugins = list } let pluginURL = URL(filePath: pluginPath, directoryHint: .notDirectory).absoluteString if !plugins.contains(where: { ($0 as? String) == pluginURL }) { plugins.append(pluginURL) } object["plugin"] = plugins guard let content = ManagedHookSettings.serialized(object) else { return ManagedHookSettings.degraded( invocation, runtime: .opencode, message: "The merged OPENCODE_CONFIG_CONTENT could not be serialized; launching unchanged." ) } return AgentHookPreparationOutcome( originalInvocation: invocation, prepared: AgentHookPreparedInvocation( invocation: invocation, argumentValues: [:], environmentValues: [contentVariableName: content] ), warning: nil ) }}
/// OpenCode's working directory is a positional `[project]` for the TUI and `--dir` for/// `opencode run` (a repeated `--dir` crashes the runtime, so the last one is taken as its/// intent); neither form has an environment equivalent.nonisolated enum OpenCodeLaunchDirectory { /// Options that consume the next argument (OpenCode 1.18.23 `--help`, TUI and `run`), so a /// value is never mistaken for the project. The preparer additionally requires a project /// positional to be an existing directory, which catches a value option added later. private static let valueOptions: Set<String> = [ "-m", "--model", "--agent", "--prompt", "--variant", "-s", "--session", "--port", "--hostname", "--mdns-domain", "--cors", "--log-level", "--replay-limit", "--dir", "--command", "-f", "--file", "--title", "--attach", "-p", "--password", "-u", "--username", "--format", ]
static func scan(arguments: [String], promptArgumentIndex: Int?) -> ManagedHookWorkingDirectory.Scan { if arguments.first == "run" { return ManagedHookWorkingDirectory.scan( arguments: arguments, optionNames: ["--dir"], precedence: .lastWins, promptArgumentIndex: promptArgumentIndex ) } var project: String? var index = 0 while index < arguments.count { let current = index index += 1 if current == promptArgumentIndex { continue } let argument = arguments[current] if argument == "--" { break } if argument.hasPrefix("-") { if valueOptions.contains(argument) { index += 1 } continue } guard project == nil else { return .malformed } project = argument } return project.map(ManagedHookWorkingDirectory.Scan.changed) ?? .inherited }}