native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
31 kB · 711 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712import Foundationimport ProwlCLIShared
nonisolated struct AgentHookResources: Equatable, Sendable { let bundledCLIPath: String let socketPath: String /// Absolute path to the bundled Copilot hook plugin directory. `nil` outside a real app /// bundle, which degrades Copilot's managed hooks without affecting the other runtimes. var copilotPluginPath: String? /// Absolute paths to the bundled extension files relayed by Pi (`-e`), Oh My Pi (`--hook`), /// and OpenCode (an `OPENCODE_CONFIG_CONTENT` plugin). Each degrades only its own runtime. var piExtensionPath: String? var ompExtensionPath: String? var opencodePluginPath: String?}
nonisolated struct AgentHookLaunchRegistration: Equatable, Sendable { let token: String let runtime: AgentNativeHookRuntime let launchCWD: URL let nativeEvents: [String: AgentSignalEvent] let coveredEvents: [AgentSignalEvent] let forwardingRecord: CodexForwardingRecord?}
/// The compiled result of resolving a profile for one launch (docs-ai 053):/// every decision is already made, downstream layers only execute. The same/// plan feeds the settings editor's launch preview and the actual launch.nonisolated struct AgentProfileLaunchPlan: Equatable, Sendable { let profileID: UUID let profileName: String let runtime: AgentProfileRuntime let invocation: AgentInvocation /// Argv index -> owner-controlled surface carrier. This generalizes the /// prompted-start carrier to large native hook settings/config arguments. let argumentCarriers: [Int: String] /// Carriers behind launch-scoped hook environment variables (OpenCode's /// `OPENCODE_CONFIG_CONTENT`): referenced by a `commandEnvironmentTokens` /// assignment and removed from the child like every other carrier. let environmentCarriers: [String] /// Present only on the execution copy immediately before surface creation. let hookRegistration: AgentHookLaunchRegistration? /// `env(1)` assignment tokens typed ahead of the invocation. The whole /// environment patch is launch-scoped (docs-ai 053/006): it exists for the /// launched agent process only — the pane's shell keeps the user's normal /// environment, so a manual `codex`/`claude` after the agent exits runs /// with the user's own account. Home paths are inlined (not secret); /// override values are referenced as `"$PROWL_ENV_<NAME>"` so no user /// value ever appears in the typed command, shell history, or scrollback. let commandEnvironmentTokens: [String] let placement: AgentProfilePlacement let splitDirection: UserCustomSplitDirection /// Carrier variables for the reference tokens, injected at surface spawn: /// `PROWL_ENV_<NAME>` → verbatim value. The namespace is Prowl-reserved, so /// nothing but the launch command reads them, and the real variable names /// never exist in the pane's shell. let surfaceEnvironment: [String: String] /// Validated user overrides retained in-memory for preflight facts such as PATH. let profileEnvironmentOverrides: [String: String] /// Dedicated home to provision before launch; nil for pure presets. let dedicatedHome: URL? /// Runtime-specific session root under the managed home. This is direct for /// most CLIs, nested for runtimes such as Gemini and Cline, and is the only /// rooted location the session resolver may inspect for this surface. let sessionConfigRoot: URL?
init( profileID: UUID, profileName: String, runtime: AgentProfileRuntime, invocation: AgentInvocation, argumentCarriers: [Int: String] = [:], environmentCarriers: [String] = [], hookRegistration: AgentHookLaunchRegistration? = nil, commandEnvironmentTokens: [String], placement: AgentProfilePlacement, splitDirection: UserCustomSplitDirection, surfaceEnvironment: [String: String], profileEnvironmentOverrides: [String: String] = [:], dedicatedHome: URL?, sessionConfigRoot: URL? = nil ) { self.profileID = profileID self.profileName = profileName self.runtime = runtime self.invocation = invocation self.argumentCarriers = argumentCarriers self.environmentCarriers = environmentCarriers self.hookRegistration = hookRegistration self.commandEnvironmentTokens = commandEnvironmentTokens self.placement = placement self.splitDirection = splitDirection self.surfaceEnvironment = surfaceEnvironment self.profileEnvironmentOverrides = profileEnvironmentOverrides self.dedicatedHome = dedicatedHome self.sessionConfigRoot = sessionConfigRoot ?? dedicatedHome }
/// The exact line typed into the new pane; doubles as the launch preview. /// Prompt text rides in the surface environment so canonical PTY limits and /// line-editor key interpretation cannot truncate or mutate it. var terminalInput: String { let promptCarrier = surfaceEnvironment[AgentProfileLaunchPlanner.promptCarrierName] == nil ? nil : AgentProfileLaunchPlanner.promptCarrierName var replacements = argumentCarriers if let promptCarrier, !invocation.arguments.isEmpty { replacements[invocation.arguments.index(before: invocation.arguments.endIndex)] = promptCarrier } let invocationInput = invocation.terminalInput( replacingArgumentsWithEnvironmentVariables: replacements ) var environmentTokens = commandEnvironmentTokens let knownCarriers = [ promptCarrier, surfaceEnvironment[AgentProfileLaunchPlanner.dispatchCarrierName] == nil ? nil : AgentProfileLaunchPlanner.dispatchCarrierName, surfaceEnvironment[AgentProfileLaunchPlanner.hookTokenCarrierName] == nil ? nil : AgentProfileLaunchPlanner.hookTokenCarrierName, surfaceEnvironment[AgentProfileLaunchPlanner.hookSocketCarrierName] == nil ? nil : AgentProfileLaunchPlanner.hookSocketCarrierName, surfaceEnvironment[AgentProfileLaunchPlanner.hookForwardCarrierName] == nil ? nil : AgentProfileLaunchPlanner.hookForwardCarrierName, ].compactMap { $0 } let carrierNames = Set(knownCarriers + Array(argumentCarriers.values) + environmentCarriers).sorted() for carrier in carrierNames.reversed() { environmentTokens.insert(contentsOf: ["-u", carrier], at: 0) } guard !environmentTokens.isEmpty else { return invocationInput } return (["env"] + environmentTokens + [invocationInput]).joined(separator: " ") }
var previewText: String { terminalInput }
func applyingManagedHook( _ prepared: AgentHookPreparedInvocation, resources: AgentHookResources, launchCWD: URL, token: String, nativeEvents: [String: AgentSignalEvent] = [:], coveredEvents: [AgentSignalEvent], forwardingRecord: CodexForwardingRecord? = nil ) -> AgentProfileLaunchPlan { guard let runtime = AgentNativeHookRuntime(rawValue: runtime.rawValue) else { return self } var environment = surfaceEnvironment var carriers: [Int: String] = [:] for (offset, entry) in prepared.argumentValues.sorted(by: { $0.key < $1.key }).enumerated() { let carrier = "PROWL_LAUNCH_HOOK_ARG_\(offset)" carriers[entry.key] = carrier environment[carrier] = entry.value } environment[AgentProfileLaunchPlanner.hookTokenCarrierName] = token environment[AgentProfileLaunchPlanner.hookSocketCarrierName] = resources.socketPath var commandTokens = commandEnvironmentTokens + [ "\(AgentNativeHookInput.tokenEnvironmentKey)=\"$\(AgentProfileLaunchPlanner.hookTokenCarrierName)\"", "\(ProwlSocket.environmentKey)=\"$\(AgentProfileLaunchPlanner.hookSocketCarrierName)\"", ] // Hook environment variables ride the same way as hook argv values: the child sees the real // variable, the typed command only ever names the carrier. var environmentCarriers: [String] = [] for (offset, entry) in prepared.environmentValues.sorted(by: { $0.key < $1.key }).enumerated() { let carrier = "PROWL_LAUNCH_HOOK_ENV_\(offset)" environmentCarriers.append(carrier) environment[carrier] = entry.value commandTokens.append("\(entry.key)=\"$\(carrier)\"") } // Only Codex's bridge reads a private file out of the environment; Droid's settings file // is named directly in its own argv, so its locator must not reach the child process. if let forwardingRecord, runtime == .codex { environment[AgentProfileLaunchPlanner.hookForwardCarrierName] = forwardingRecord.locator.path( percentEncoded: false ) commandTokens.append( "\(AgentNativeHookInput.forwardRecordEnvironmentKey)=\"$\(AgentProfileLaunchPlanner.hookForwardCarrierName)\"" ) } return AgentProfileLaunchPlan( profileID: profileID, profileName: profileName, runtime: self.runtime, invocation: prepared.invocation, argumentCarriers: carriers, environmentCarriers: environmentCarriers, hookRegistration: AgentHookLaunchRegistration( token: token, runtime: runtime, launchCWD: launchCWD.standardizedFileURL, nativeEvents: nativeEvents, coveredEvents: coveredEvents, forwardingRecord: forwardingRecord ), commandEnvironmentTokens: commandTokens, placement: placement, splitDirection: splitDirection, surfaceEnvironment: environment, profileEnvironmentOverrides: profileEnvironmentOverrides, dedicatedHome: dedicatedHome, sessionConfigRoot: sessionConfigRoot ) }
/// A workflow `launch` role (docs-ai 063 B3, decision W6): replaces the placeholder prompt the /// frozen plan was compiled with by the rendered kickoff prompt (its own protocol block, not /// S2's dispatch protocol) and attaches the `PROWL_WORKFLOW_*` values as child-only carriers, /// exactly like `attachingDispatch` carries `PROWL_DISPATCH_ID`. Nothing here reaches the /// typed command or the pane's shell by name. func attachingWorkflow(prompt: String, environment values: [String: String]) throws -> AgentProfileLaunchPlan { guard !invocation.arguments.isEmpty, surfaceEnvironment[AgentProfileLaunchPlanner.promptCarrierName] != nil else { throw AgentProfileLaunchPlanError.dispatchRequiresPrompt } guard !prompt.contains("\0"), !values.contains(where: { $0.key.contains("\0") || $0.value.contains("\0") }) else { throw AgentProfileLaunchPlanError.promptContainsNUL } var arguments = invocation.arguments arguments[arguments.index(before: arguments.endIndex)] = prompt var environment = surfaceEnvironment environment[AgentProfileLaunchPlanner.promptCarrierName] = prompt var commandTokens = commandEnvironmentTokens var carriers = environmentCarriers for (offset, entry) in values.sorted(by: { $0.key < $1.key }).enumerated() { let carrier = "\(AgentProfileLaunchPlanner.workflowCarrierPrefix)\(offset)" carriers.append(carrier) environment[carrier] = entry.value commandTokens.append("\(entry.key)=\"$\(carrier)\"") } return AgentProfileLaunchPlan( profileID: profileID, profileName: profileName, runtime: runtime, invocation: AgentInvocation(executable: invocation.executable, arguments: arguments), argumentCarriers: argumentCarriers, environmentCarriers: carriers, hookRegistration: hookRegistration, commandEnvironmentTokens: commandTokens, placement: placement, splitDirection: splitDirection, surfaceEnvironment: environment, profileEnvironmentOverrides: profileEnvironmentOverrides, dedicatedHome: dedicatedHome, sessionConfigRoot: sessionConfigRoot ) }
func attachingDispatch(id: String, userPrompt: String) throws -> AgentProfileLaunchPlan { guard !id.isEmpty, !id.contains("\0"), !userPrompt.contains("\0"), !invocation.arguments.isEmpty, surfaceEnvironment[AgentProfileLaunchPlanner.promptCarrierName] != nil else { throw AgentProfileLaunchPlanError.dispatchRequiresPrompt } let renderedPrompt = AgentDispatchPrompt.render(userPrompt: userPrompt) var arguments = invocation.arguments arguments[arguments.index(before: arguments.endIndex)] = renderedPrompt var environment = surfaceEnvironment environment[AgentProfileLaunchPlanner.promptCarrierName] = renderedPrompt environment[AgentProfileLaunchPlanner.dispatchCarrierName] = id var commandTokens = commandEnvironmentTokens commandTokens.append( "\(DispatchCompleteInput.environmentKey)=\"$\(AgentProfileLaunchPlanner.dispatchCarrierName)\"" ) return AgentProfileLaunchPlan( profileID: profileID, profileName: profileName, runtime: runtime, invocation: AgentInvocation(executable: invocation.executable, arguments: arguments), argumentCarriers: argumentCarriers, environmentCarriers: environmentCarriers, hookRegistration: hookRegistration, commandEnvironmentTokens: commandTokens, placement: placement, splitDirection: splitDirection, surfaceEnvironment: environment, profileEnvironmentOverrides: profileEnvironmentOverrides, dedicatedHome: dedicatedHome, sessionConfigRoot: sessionConfigRoot ) }}
/// One deterministic profile launch request shared by the CLI, workflow runner,/// and terminal layer. The compiled plan remains the single adapter-rendered/// invocation/environment seam; placement and directory are per-launch choices.nonisolated struct AgentProfileLaunchRequest: Equatable, Sendable { nonisolated enum Placement: Equatable, Sendable { case tab(background: Bool) case split( anchor: UUID?, direction: UserCustomSplitDirection, background: Bool ) }
let plan: AgentProfileLaunchPlan let placement: Placement let workingDirectoryOverride: URL? let inheritanceAnchor: UUID? let title: String?
init( plan: AgentProfileLaunchPlan, placement: Placement, workingDirectoryOverride: URL? = nil, inheritanceAnchor: UUID? = nil, title: String? = nil ) { self.plan = plan self.placement = placement self.workingDirectoryOverride = workingDirectoryOverride self.inheritanceAnchor = inheritanceAnchor self.title = title }}
nonisolated struct FrozenAgentProfileLaunchContext: Equatable, Sendable { let request: AgentProfileLaunchRequest let inheritedCWD: URL let anchorSurfaceID: UUID? let tracksFocusedAnchor: Bool let tracksInheritedCWD: Bool
init( request: AgentProfileLaunchRequest, inheritedCWD: URL, anchorSurfaceID: UUID?, tracksFocusedAnchor: Bool = false, tracksInheritedCWD: Bool = false ) { self.request = request self.inheritedCWD = inheritedCWD self.anchorSurfaceID = anchorSurfaceID self.tracksFocusedAnchor = tracksFocusedAnchor self.tracksInheritedCWD = tracksInheritedCWD }}
nonisolated struct PreparedAgentProfileLaunch: Equatable, Sendable { let context: FrozenAgentProfileLaunchContext let warnings: [LifecycleCommandWarning]}
nonisolated struct LaunchedSurface: Equatable, Sendable { let tabID: TerminalTabID let surfaceID: UUID}
nonisolated enum AgentProfileLaunchError: Error, Equatable, Sendable { case homeProvisioningFailed case splitAnchorUnavailable case splitCreationFailed(SplitCreationError) case tabCreationFailed case launchedSurfaceMissing(TerminalTabID) case hookRegistrationFailed case surfaceCreationFailed case preparationCancelled}
/// Which env variable names a profile override may set, shared by the planner/// (filtering) and the editor (inline row diagnostics) so they can never/// disagree (docs-ai 053/004).nonisolated enum AgentProfileEnvironmentPolicy { enum RowIssue: Equatable, Sendable { case invalidName case reservedName case invalidValue }
/// Account-home variables come from the adapters, not a hardcoded list; the /// `PROWL_` prefix protects the worktree/root/pane facts Prowl injects itself. /// Letting an override set `CODEX_HOME` on an unbound profile would bypass /// home provisioning, deletion protection, and rooted session detection — /// custom homes are a separate capability, not an env-table backdoor. static var reservedNames: Set<String> { Set( AgentProfileRuntime.allCases.compactMap { AgentRuntimeAdapterRegistry.profileAdapter(for: $0)?.accountIsolation?.reservedEnvironmentVariables } .flatMap { $0 } ) }
static func isValidName(_ name: String) -> Bool { guard let first = name.utf8.first else { return false } guard first == UInt8(ascii: "_") || isASCIILetter(first) else { return false } return name.utf8.dropFirst().allSatisfy { $0 == UInt8(ascii: "_") || isASCIILetter($0) || isASCIIDigit($0) } }
/// `HOME` is reserved for the same reason as the account-home variables: /// relocating it moves every runtime's *default* home (`$HOME/.claude`, /// `$HOME/.codex`), bypassing home provisioning, deletion protection, and /// rooted session detection without ever flipping the binding toggle. static func isReserved(_ name: String) -> Bool { name.hasPrefix("PROWL_") || name == "HOME" || reservedNames.contains(name) }
/// A NUL would be silently truncated at the C-string boundary; refuse the /// row instead of launching with a value the user never wrote. static func isValidValue(_ value: String) -> Bool { !value.contains("\0") }
/// nil for a row the planner will apply; blank names are in-progress rows, /// reported as nil issue too — they are ignored without being an error. static func issue(for override: AgentProfileEnvironmentOverride) -> RowIssue? { let name = override.trimmedName guard !name.isEmpty else { return nil } if !isValidName(name) { return .invalidName } if isReserved(name) { return .reservedName } if !isValidValue(override.value) { return .invalidValue } return nil }
/// Carrier variable in the surface environment holding an override's value. /// The `PROWL_` namespace is already reserved, so user rows can't collide /// with a carrier, and the validated POSIX name keeps the reference token /// (`NAME="$PROWL_ENV_NAME"`) free of any user-authored shell text. static func carrierName(for name: String) -> String { "PROWL_ENV_\(name)" }
/// The applied subset: trimmed names, invalid/reserved rows dropped, later /// duplicates win (shell-export semantics). Values stay verbatim — an empty /// value legitimately sets the variable to the empty string. static func effectiveOverrides( _ overrides: [AgentProfileEnvironmentOverride] ) -> [String: String] { var environment: [String: String] = [:] for override in overrides { let name = override.trimmedName guard !name.isEmpty, issue(for: override) == nil else { continue } environment[name] = override.value } return environment }
private static func isASCIILetter(_ byte: UInt8) -> Bool { (UInt8(ascii: "A")...UInt8(ascii: "Z")).contains(byte) || (UInt8(ascii: "a")...UInt8(ascii: "z")).contains(byte) }
private static func isASCIIDigit(_ byte: UInt8) -> Bool { (UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte) }}
/// The editor's conservative execution-mode disclosure. This is not a full/// parser for a runtime's final configuration: an explicit Unrestricted/// picker selection keeps its warning even when later Advanced arguments may/// override generated flags. Those arguments are authoritative user input.////// CLI flag surfaces evolve (`--sandbox danger-full-access`,/// `--ask-for-approval never`, arbitrary `-c` overrides), so any recognition/// list goes stale: instead of chasing it, unrecognized extra arguments/// downgrade the claim to "follows your command line" — the display never/// asserts Standard it cannot prove (docs-ai 053, review round 2).nonisolated enum AgentProfileEffectiveExecutionMode: Equatable, Sendable { case standard case unrestricted case followsExtraArguments}
nonisolated extension AgentProfile { /// Extra arguments are respected as explicit user configuration — never /// blocked or stripped. For a Standard selection, a bypass flag the /// adapter's `observe` recognizes (`--yolo`, `--dangerously-*`, /// `--permission-mode bypassPermissions`) upgrades the disclosure to /// `.unrestricted`; any other extra argument defers it entirely. var effectiveExecutionMode: AgentProfileEffectiveExecutionMode { let selectableModes = AgentRuntimeAdapterRegistry.profileAdapter(for: runtime)?.executionModeOptions ?? [] if executionMode == .unrestricted, selectableModes.contains(.unrestricted) { return .unrestricted } let tokens = ShellWordSplitter.split(extraArguments) guard !tokens.isEmpty else { return .standard } let observed = AgentRuntimeAdapterRegistry.observe(runtime: runtime, arguments: tokens) return observed.executionMode == .unrestricted ? .unrestricted : .followsExtraArguments }}
nonisolated enum AgentProfileLaunchPlanError: Error, Equatable, Sendable { case runtimeUnavailable(AgentProfileRuntime) case accountIsolationUnsupported(AgentProfileRuntime) case promptContainsNUL case promptArgumentUnavailable(AgentProfileRuntime) case dispatchRequiresPrompt case homeEscapesBase(URL) case homeIsSymbolicLink(URL)}
nonisolated enum AgentProfileLaunchPlanner { /// Reserved surface-environment carrier for prompted starts. The prompt is /// expanded as one quoted argv token and never enters Ghostty initial_input. static let promptCarrierName = "PROWL_LAUNCH_PROMPT" /// The surface keeps only this opaque carrier. `env(1)` copies its value to /// PROWL_DISPATCH_ID for the launched runtime and removes the carrier from /// that child, so neither the pane shell nor a later manual runtime receives /// a stale public dispatch context. static let dispatchCarrierName = "PROWL_LAUNCH_DISPATCH" static let hookTokenCarrierName = "PROWL_LAUNCH_HOOK_TOKEN" static let hookSocketCarrierName = "PROWL_LAUNCH_HOOK_SOCKET" static let hookForwardCarrierName = "PROWL_LAUNCH_HOOK_FORWARD" /// `PROWL_LAUNCH_WORKFLOW_<n>`: one carrier per workflow child-environment value (063 B3). static let workflowCarrierPrefix = "PROWL_LAUNCH_WORKFLOW_"
/// Resolves a profile into one launch plan. Pure: no filesystem access — /// home provisioning happens at the launch boundary, not here. static func plan( for profile: AgentProfile, intent: AgentStartIntent = .interactive, homeBaseDirectory: URL, dispatchID: String? = nil ) throws -> AgentProfileLaunchPlan { guard let adapter = AgentRuntimeAdapterRegistry.profileAdapter(for: profile.runtime) else { throw AgentProfileLaunchPlanError.runtimeUnavailable(profile.runtime) } let configuration = AgentLaunchConfiguration( model: profile.model, executionMode: profile.executionMode, reasoningEffort: profile.reasoningEffort, extraArguments: ShellWordSplitter.split(profile.extraArguments) ) // The whole patch renders as launch-scoped `env` tokens (docs-ai // 053/006). The home token leads: it is the launch's identity, and the // reserved-name policy already guarantees no user row can carry the same // name — the account isolation reasoning stays provable. var tokens: [String] = [] var surfaceEnvironment: [String: String] = [:] var dedicatedHome: URL? var sessionConfigRoot: URL? if profile.bindsDedicatedHome { guard let relocation = adapter.accountIsolation else { throw AgentProfileLaunchPlanError.accountIsolationUnsupported(profile.runtime) } let home = dedicatedHomeDirectory(for: profile.id, base: homeBaseDirectory) guard isContained(home, in: homeBaseDirectory) else { throw AgentProfileLaunchPlanError.homeEscapesBase(home) } if let variable = relocation.environmentVariable { // Inlined literal: the UUID-derived path is not a secret, and seeing // it in the preview documents which home the launch binds. tokens.append("\(variable)=\(AgentInvocation.shellQuote(pathString(home)))") } dedicatedHome = home sessionConfigRoot = relocation.sessionConfigRoot(for: home) } let effectiveIntent: AgentStartIntent if let dispatchID { guard case .prompt(let prompt) = intent else { throw AgentProfileLaunchPlanError.dispatchRequiresPrompt } guard !dispatchID.isEmpty, !dispatchID.contains("\0") else { throw AgentProfileLaunchPlanError.dispatchRequiresPrompt } effectiveIntent = .prompt(AgentDispatchPrompt.render(userPrompt: prompt)) } else { effectiveIntent = intent } let invocation = try AgentRuntimeAdapterRegistry.makeStartInvocation( AgentStartRequest( runtime: profile.runtime, intent: effectiveIntent, configuration: configuration, dedicatedHome: dedicatedHome ) ) if let prompt = effectiveIntent.promptText { guard !prompt.contains("\0") else { throw AgentProfileLaunchPlanError.promptContainsNUL } guard invocation.arguments.last == prompt else { throw AgentProfileLaunchPlanError.promptArgumentUnavailable(profile.runtime) } surfaceEnvironment[promptCarrierName] = prompt } if let dispatchID { surfaceEnvironment[dispatchCarrierName] = dispatchID tokens.append("\(DispatchCompleteInput.environmentKey)=\"$\(dispatchCarrierName)\"") } let overrides = AgentProfileEnvironmentPolicy.effectiveOverrides(profile.environmentOverrides) for name in overrides.keys.sorted() { let carrier = AgentProfileEnvironmentPolicy.carrierName(for: name) tokens.append("\(name)=\"$\(carrier)\"") surfaceEnvironment[carrier] = overrides[name] }
return AgentProfileLaunchPlan( profileID: profile.id, profileName: profile.name, runtime: profile.runtime, invocation: invocation, commandEnvironmentTokens: tokens, placement: profile.placement, splitDirection: profile.splitDirection, surfaceEnvironment: surfaceEnvironment, profileEnvironmentOverrides: overrides, dedicatedHome: dedicatedHome, sessionConfigRoot: sessionConfigRoot ) }
/// Homes derive from the UUID alone — never from the display name or any /// user-supplied path (docs-ai 053). static func dedicatedHomeDirectory(for profileID: UUID, base: URL) -> URL { base .appending(path: profileID.uuidString, directoryHint: .isDirectory) .standardizedFileURL }
/// Directory URLs render with a trailing slash in `path()`; environment /// values and comparisons want the bare path. static func pathString(_ url: URL) -> String { let path = url.standardizedFileURL.path(percentEncoded: false) return path.count > 1 && path.hasSuffix("/") ? String(path.dropLast()) : path }
static func isContained(_ url: URL, in base: URL) -> Bool { let baseComponents = base.standardizedFileURL.pathComponents let urlComponents = url.standardizedFileURL.pathComponents guard urlComponents.count > baseComponents.count else { return false } return Array(urlComponents.prefix(baseComponents.count)) == baseComponents }}
nonisolated enum AgentDispatchPrompt { static let protocolVersion = 1 /// Origin marker on text Prowl types into a live agent. static let injectedPrefix = "[Prowl] "
/// The text `agents dispatch` types into an existing agent pane: the same prompt and /// protocol block a prompted launch passes through argv, behind the origin marker so the /// agent (and a person reading the transcript) can tell Prowl authored the line. static func renderInjected(userPrompt: String) -> String { injectedPrefix + render(userPrompt: userPrompt) }
static func render(userPrompt: String) -> String { """ \(userPrompt)
--- Prowl dispatch completion protocol v\(protocolVersion): Before ending this task, choose exactly one terminal outcome and make this command your final tool action: prowl agents dispatch-complete --outcome succeeded|failed --summary "<concise result>" Use succeeded only when the assigned work is complete and verified. Use failed when it cannot be completed. Keep the required summary to a single line with no control characters, and explain the result clearly. Do not omit the command; Prowl supplies its dispatch context automatically. """ }}
/// Creates a dedicated profile home right before launch. The containment/// check is the same hard gate used for deletion: file operations only ever/// touch UUID-derived paths inside the base — never a real agent home.nonisolated enum AgentProfileHomeProvisioner { static func provision(home: URL, base: URL, fileManager: FileManager = .default) throws { guard AgentProfileLaunchPlanner.isContained(home, in: base) else { throw AgentProfileLaunchPlanError.homeEscapesBase(home) } try validatePhysicalContainment(home: home, base: base, fileManager: fileManager) try fileManager.createDirectory( at: home, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700] ) // createDirectory attributes only apply on creation; enforce owner-only // permissions for pre-existing homes as well. try fileManager.setAttributes( [.posixPermissions: 0o700], ofItemAtPath: home.path(percentEncoded: false) ) }
/// Lexical containment is not enough: a `<uuid>` leaf replaced by a symlink /// to a real agent home (`~/.codex`) passes the string check while every /// following file operation lands on the link target. Reject a symlink leaf /// outright, then require the *canonical* home to stay inside the /// *canonical* base — which deliberately keeps a symlinked base itself /// legal (e.g. `~/.prowl` living on a synced volume resolves consistently /// on both sides of the comparison). static func validatePhysicalContainment( home: URL, base: URL, fileManager: FileManager = .default ) throws { let homePath = AgentProfileLaunchPlanner.pathString(home) if let attributes = try? fileManager.attributesOfItem(atPath: homePath), attributes[.type] as? FileAttributeType == .typeSymbolicLink { throw AgentProfileLaunchPlanError.homeIsSymbolicLink(home) } // `resolvingSymlinksInPath()` leaves ancestors unresolved when the leaf // does not exist yet (first provision), so resolve the parent — which // must exist for the comparison to mean anything — and reattach the leaf. // The leaf itself was just proven not to be a symlink. let canonicalHome = home .deletingLastPathComponent() .resolvingSymlinksInPath() .appending(path: home.lastPathComponent, directoryHint: .isDirectory) let canonicalBase = base.resolvingSymlinksInPath() guard AgentProfileLaunchPlanner.isContained(canonicalHome, in: canonicalBase) else { throw AgentProfileLaunchPlanError.homeEscapesBase(home) } }}