From 171e6a9206dd0354b60cfc975a8578cbf1dfeefa Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 1 Aug 2026 16:34:19 +0900 Subject: [PATCH] Expand Agent Profile runtime adapters --- .../AgentRuntimeAvailabilityProbe.swift | 2 +- .../Domain/AgentDetection/DetectedAgent.swift | 2 +- .../Domain/AgentProfile/AgentProfile.swift | 72 +- .../AgentProfileAvailability.swift | 2 +- .../AgentProfile/AgentProfileLaunchPlan.swift | 62 +- .../AgentRuntime/AgentRuntimeAdapter.swift | 727 +++++++++++++++--- .../Reducer/AppFeature+AgentProfiles.swift | 2 +- .../Reducer/CommandPaletteFeature.swift | 2 +- .../Reducer/HandoffHudFeature.swift | 5 +- .../Views/WorktreeDetailView.swift | 2 +- .../Reducer/AgentProfileEditorFeature.swift | 3 + .../Reducer/AgentProfilesFeature.swift | 4 +- .../Views/AgentProfileEditorView.swift | 57 +- .../Views/AgentProfileIconImage.swift | 2 +- .../Views/AgentProfilesSettingsView.swift | 4 +- .../Models/WorktreeTerminalState.swift | 23 +- .../AgentDetection/AgentSessionProfile.swift | 106 ++- .../AgentDetection/AgentSessionResolver.swift | 7 +- .../AgentProfileEditorFeatureTests.swift | 14 + supacodeTests/AgentProfileTests.swift | 101 +++ supacodeTests/AgentProfilesFeatureTests.swift | 10 + supacodeTests/AgentRuntimeAdapterTests.swift | 162 ++++ .../AgentRuntimeAvailabilityProbeTests.swift | 2 +- supacodeTests/AgentSessionProfileTests.swift | 45 ++ supacodeTests/HandoffHudFeatureTests.swift | 2 +- 25 files changed, 1243 insertions(+), 177 deletions(-) diff --git a/supacode/Clients/AgentProfile/AgentRuntimeAvailabilityProbe.swift b/supacode/Clients/AgentProfile/AgentRuntimeAvailabilityProbe.swift index db382344..692ada20 100644 --- a/supacode/Clients/AgentProfile/AgentRuntimeAvailabilityProbe.swift +++ b/supacode/Clients/AgentProfile/AgentRuntimeAvailabilityProbe.swift @@ -51,7 +51,7 @@ enum AgentRuntimeAvailabilityProbe { private static func probeAvailability(of runtime: AgentProfileRuntime) async -> Bool? { guard let executable = try? AgentRuntimeAdapterRegistry.makeStartInvocation( - AgentStartRequest(agent: runtime.agent, intent: .interactive) + AgentStartRequest(runtime: runtime, intent: .interactive) ).executable else { return nil } @Dependency(ShellClient.self) var shell diff --git a/supacode/Domain/AgentDetection/DetectedAgent.swift b/supacode/Domain/AgentDetection/DetectedAgent.swift index 30fb05b9..69d83891 100644 --- a/supacode/Domain/AgentDetection/DetectedAgent.swift +++ b/supacode/Domain/AgentDetection/DetectedAgent.swift @@ -28,7 +28,7 @@ enum DetectedAgent: String, CaseIterable, Equatable, Identifiable, Sendable { } } - var iconLookupToken: String { + nonisolated var iconLookupToken: String { switch self { case .claude: return "claude" diff --git a/supacode/Domain/AgentProfile/AgentProfile.swift b/supacode/Domain/AgentProfile/AgentProfile.swift index 2051917a..a9b40044 100644 --- a/supacode/Domain/AgentProfile/AgentProfile.swift +++ b/supacode/Domain/AgentProfile/AgentProfile.swift @@ -1,11 +1,26 @@ import Foundation -/// The runtimes a profile may launch. Deliberately narrower than -/// `DetectedAgent`: only runtimes with a verified interactive launch adapter -/// and a verified account-isolation mechanism are eligible (docs-ai 053). +/// The executable runtimes an Agent Profile may launch. This is intentionally +/// distinct from `DetectedAgent`: Pi and OMP share one detection family, but +/// they are separate launch products with different executable and option +/// contracts (docs-ai 055). nonisolated enum AgentProfileRuntime: String, Codable, CaseIterable, Identifiable, Sendable { case claude case codex + case gemini + case cursor = "cursor-agent" + case cline + case opencode + case copilot + case kimi + case droid + case amp + case qoder = "qodercli" + case qwen + case grok + // swiftlint:disable:next identifier_name + case pi + case omp var id: String { rawValue } @@ -13,6 +28,44 @@ nonisolated enum AgentProfileRuntime: String, Codable, CaseIterable, Identifiabl switch self { case .claude: .claude case .codex: .codex + case .gemini: .gemini + case .cursor: .cursor + case .cline: .cline + case .opencode: .opencode + case .copilot: .copilot + case .kimi: .kimi + case .droid: .droid + case .amp: .amp + case .qoder: .qoder + case .qwen: .qwen + case .grok: .grok + case .pi, .omp: .pi + } + } + + init(agent: DetectedAgent) { + switch agent { + case .claude: self = .claude + case .codex: self = .codex + case .gemini: self = .gemini + case .cursor: self = .cursor + case .cline: self = .cline + case .opencode: self = .opencode + case .copilot: self = .copilot + case .kimi: self = .kimi + case .droid: self = .droid + case .amp: self = .amp + case .qoder: self = .qoder + case .qwen: self = .qwen + case .grok: self = .grok + case .pi: self = .pi + } + } + + var iconLookupToken: String { + switch self { + case .omp: "omp" + default: agent.iconLookupToken } } @@ -23,6 +76,19 @@ nonisolated enum AgentProfileRuntime: String, Codable, CaseIterable, Identifiabl switch self { case .claude: ".claude" case .codex: ".codex" + case .gemini: ".gemini" + case .cursor: ".cursor" + case .cline: ".cline" + case .opencode: ".local/share/opencode" + case .copilot: ".copilot" + case .kimi: ".kimi" + case .droid: ".factory" + case .amp: ".config/amp" + case .qoder: ".qoder" + case .qwen: ".qwen" + case .grok: ".grok" + case .pi: ".pi/agent" + case .omp: ".omp/agent" } } } diff --git a/supacode/Domain/AgentProfile/AgentProfileAvailability.swift b/supacode/Domain/AgentProfile/AgentProfileAvailability.swift index ef576cd5..673d02f0 100644 --- a/supacode/Domain/AgentProfile/AgentProfileAvailability.swift +++ b/supacode/Domain/AgentProfile/AgentProfileAvailability.swift @@ -18,7 +18,7 @@ nonisolated enum AgentProfileAvailability { probedAvailable: Bool?, isRuntimeInstalled: (AgentProfileRuntime) -> Bool = isRuntimeInstalled ) -> String? { - let name = AgentRuntimeAdapterRegistry.displayName(for: profile.runtime.agent) + let name = AgentRuntimeAdapterRegistry.displayName(for: profile.runtime) switch probedAvailable { case true?: return nil diff --git a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift index fab618f7..ac8136ab 100644 --- a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift +++ b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift @@ -25,6 +25,34 @@ nonisolated struct AgentProfileLaunchPlan: Equatable, Sendable { let surfaceEnvironment: [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, + commandEnvironmentTokens: [String], + placement: AgentProfilePlacement, + splitDirection: UserCustomSplitDirection, + surfaceEnvironment: [String: String], + dedicatedHome: URL?, + sessionConfigRoot: URL? = nil + ) { + self.profileID = profileID + self.profileName = profileName + self.runtime = runtime + self.invocation = invocation + self.commandEnvironmentTokens = commandEnvironmentTokens + self.placement = placement + self.splitDirection = splitDirection + self.surfaceEnvironment = surfaceEnvironment + self.dedicatedHome = dedicatedHome + self.sessionConfigRoot = sessionConfigRoot ?? dedicatedHome + } /// The exact line typed into the new pane; doubles as the launch preview. var terminalInput: String { @@ -53,8 +81,9 @@ nonisolated enum AgentProfileEnvironmentPolicy { static var reservedNames: Set { Set( AgentProfileRuntime.allCases.compactMap { - AgentRuntimeAdapterRegistry.adapter(for: $0.agent)?.accountHomeEnvironmentVariable + AgentRuntimeAdapterRegistry.profileAdapter(for: $0)?.accountIsolation?.reservedEnvironmentVariables } + .flatMap { $0 } ) } @@ -146,7 +175,7 @@ nonisolated extension AgentProfile { if executionMode == .unrestricted { return .unrestricted } let tokens = ShellWordSplitter.split(extraArguments) guard !tokens.isEmpty else { return .standard } - let observed = AgentRuntimeAdapterRegistry.observe(agent: runtime.agent, arguments: tokens) + let observed = AgentRuntimeAdapterRegistry.observe(runtime: runtime, arguments: tokens) return observed.executionMode == .unrestricted ? .unrestricted : .followsExtraArguments } } @@ -165,7 +194,7 @@ nonisolated enum AgentProfileLaunchPlanner { for profile: AgentProfile, homeBaseDirectory: URL ) throws -> AgentProfileLaunchPlan { - guard let adapter = AgentRuntimeAdapterRegistry.adapter(for: profile.runtime.agent) else { + guard let adapter = AgentRuntimeAdapterRegistry.profileAdapter(for: profile.runtime) else { throw AgentProfileLaunchPlanError.runtimeUnavailable(profile.runtime) } let configuration = AgentLaunchConfiguration( @@ -174,10 +203,6 @@ nonisolated enum AgentProfileLaunchPlanner { reasoningEffort: profile.reasoningEffort, extraArguments: ShellWordSplitter.split(profile.extraArguments) ) - let invocation = try AgentRuntimeAdapterRegistry.makeStartInvocation( - AgentStartRequest(agent: profile.runtime.agent, intent: .interactive, configuration: configuration) - ) - // 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 @@ -185,19 +210,31 @@ nonisolated enum AgentProfileLaunchPlanner { var tokens: [String] = [] var surfaceEnvironment: [String: String] = [:] var dedicatedHome: URL? + var sessionConfigRoot: URL? if profile.bindsDedicatedHome { - guard let variable = adapter.accountHomeEnvironmentVariable else { + 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) } - // 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)))") + 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 invocation = try AgentRuntimeAdapterRegistry.makeStartInvocation( + AgentStartRequest( + runtime: profile.runtime, + intent: .interactive, + configuration: configuration, + dedicatedHome: dedicatedHome + ) + ) let overrides = AgentProfileEnvironmentPolicy.effectiveOverrides(profile.environmentOverrides) for name in overrides.keys.sorted() { let carrier = AgentProfileEnvironmentPolicy.carrierName(for: name) @@ -214,7 +251,8 @@ nonisolated enum AgentProfileLaunchPlanner { placement: profile.placement, splitDirection: profile.splitDirection, surfaceEnvironment: surfaceEnvironment, - dedicatedHome: dedicatedHome + dedicatedHome: dedicatedHome, + sessionConfigRoot: sessionConfigRoot ) } diff --git a/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift b/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift index be3f01e7..0298d93c 100644 --- a/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift +++ b/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift @@ -1,35 +1,94 @@ import Foundation +/// Interactive launch is deliberately independent from native-session resume. +/// Every recognized runtime can launch, while only adapters with a proven +/// side-effect-free fork/resume contract participate in handoff preparation +/// (docs-ai 055). nonisolated protocol AgentRuntimeAdapter: Sendable { - var agent: DetectedAgent { get } - /// Human-readable product name for UI entry points ("Claude Code"). + var runtime: AgentProfileRuntime { get } var displayName: String { get } - /// Environment variable that relocates the runtime's entire home for - /// per-profile account isolation, or nil when the runtime has no verified - /// mechanism. Relocation is all-or-nothing: skills, instruction files, and - /// session history follow the home (docs-ai 053, #617). - var accountHomeEnvironmentVariable: String? { get } - /// Known reasoning-effort values offered as editor suggestions. Any - /// free-form value stays accepted as a literal argument; an unknown value - /// fails at CLI startup, visibly in the new surface. + var supportsModelSelection: Bool { get } + var supportsReasoningEffort: Bool { get } + var supportsUnrestrictedExecution: Bool { get } + var accountIsolation: AgentProfileHomeRelocation? { get } var reasoningEffortSuggestions: [String] { get } - /// Known model values offered as editor suggestions. Custom values still pass - /// through as literal model arguments, so provider-specific deployments work. var modelSuggestions: [String] { get } func observe(arguments: [String]) -> AgentLaunchObservation func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation - /// Resume is side-effect-free by design: the invocation never renders - /// execution-mode flags, and it must not mutate the source session's recorded - /// state (fork/ephemeral variants only). The resumed agent replies with - /// content; Prowl persists any artifact. `replyFile`, when supported, asks the - /// CLI to write its final message there. - func makeResumeInvocation(_ request: AgentResumeRequest, replyFile: URL?) throws -> AgentInvocation } nonisolated extension AgentRuntimeAdapter { - var supportsAccountIsolation: Bool { accountHomeEnvironmentVariable != nil } + var supportsAccountIsolation: Bool { accountIsolation != nil } + var accountHomeEnvironmentVariable: String? { accountIsolation?.environmentVariable } + var supportsModelSelection: Bool { false } + var supportsReasoningEffort: Bool { false } + var supportsUnrestrictedExecution: Bool { false } + var accountIsolation: AgentProfileHomeRelocation? { nil } + var reasoningEffortSuggestions: [String] { [] } var modelSuggestions: [String] { [] } + + /// User arguments remain last-wins for ordinary options. A managed home is + /// appended after them because account binding is an identity invariant: + /// extra arguments must not silently redirect state outside the UUID home. + func finalizedOptions(_ generated: [String], request: AgentStartRequest) -> [String] { + var options = generated + request.configuration.extraArguments + if let home = request.dedicatedHome, let accountIsolation { + options += accountIsolation.arguments(for: home) + } + return options + } +} + +nonisolated protocol AgentRuntimeResumeAdapter: Sendable { + var agent: DetectedAgent { get } + func makeResumeInvocation(_ request: AgentResumeRequest, replyFile: URL?) throws -> AgentInvocation +} + +/// One path-valued CLI option in a managed-home relocation contract. +nonisolated struct AgentProfileHomePathArgument: Equatable, Sendable { + let option: String + /// Empty means the managed home itself; otherwise a child path. + let relativePath: String +} + +/// Verified full-state relocation for a runtime. Environment and CLI-argument +/// mechanisms share one model so the planner, editor, cleanup, and session +/// resolver agree on the same managed root (docs-ai 055). +nonisolated struct AgentProfileHomeRelocation: Equatable, Sendable { + let environmentVariable: String? + let pathArguments: [AgentProfileHomePathArgument] + let sessionRootRelativePath: String + let reservedEnvironmentVariables: Set + + init( + environmentVariable: String? = nil, + pathArguments: [AgentProfileHomePathArgument] = [], + sessionRootRelativePath: String = "", + reservedEnvironmentVariables: Set = [] + ) { + self.environmentVariable = environmentVariable + self.pathArguments = pathArguments + self.sessionRootRelativePath = sessionRootRelativePath + self.reservedEnvironmentVariables = reservedEnvironmentVariables.union( + environmentVariable.map { [$0] } ?? [] + ) + } + + func arguments(for home: URL) -> [String] { + pathArguments.flatMap { argument in + let url = + argument.relativePath.isEmpty + ? home + : home.appending(path: argument.relativePath, directoryHint: .isDirectory) + return [argument.option, AgentProfileLaunchPlanner.pathString(url)] + } + } + + func sessionConfigRoot(for home: URL) -> URL { + guard !sessionRootRelativePath.isEmpty else { return home } + return home.appending(path: sessionRootRelativePath, directoryHint: .isDirectory) + } } nonisolated enum AgentExecutionMode: String, Codable, Equatable, Sendable { @@ -41,15 +100,10 @@ nonisolated enum AgentExecutionMode: String, Codable, Equatable, Sendable { /// construction: interactive-with-no-prompt and prompted starts are distinct /// CLI semantics (docs-ai 053). nonisolated enum AgentStartIntent: Equatable, Sendable { - /// Launch the interactive TUI with no initial prompt. case interactive - /// Launch the interactive TUI seeded with an initial prompt. case prompt(String) - /// One-shot non-interactive execution (print/exec mode). No UI consumer in - /// V1; the case keeps the intent space complete for CLI/handoff waves. case headless(String) - /// The prompt payload for prompted or headless starts; nil for interactive. var promptText: String? { switch self { case .interactive: nil @@ -63,7 +117,7 @@ nonisolated struct AgentLaunchConfiguration: Codable, Equatable, Sendable { var executionMode: AgentExecutionMode var reasoningEffort: String? /// Literal argv tokens appended after adapter-generated options (last-wins) - /// and before any positional prompt. Never shell-interpreted. + /// and before managed-home arguments and any positional prompt. var extraArguments: [String] init( @@ -92,8 +146,8 @@ nonisolated struct AgentLaunchConfiguration: Codable, Equatable, Sendable { } } -/// Options observed from a live agent process. Nil denotes an unknown effective setting, -/// never an inferred safe default from the absence of an argv flag. +/// Options observed from a live agent process. Nil denotes an unknown effective +/// setting, never an inferred safe default from the absence of an argv flag. nonisolated struct AgentLaunchObservation: Equatable, Sendable { let model: String? let executionMode: AgentExecutionMode? @@ -105,14 +159,33 @@ nonisolated struct AgentLaunchObservation: Equatable, Sendable { } nonisolated struct AgentStartRequest: Equatable, Sendable { - let agent: DetectedAgent + let runtime: AgentProfileRuntime let intent: AgentStartIntent let configuration: AgentLaunchConfiguration + /// Set only by Agent Profile planning. Handoff and ordinary starts are pure + /// invocations and therefore leave this nil. + let dedicatedHome: URL? - init(agent: DetectedAgent, intent: AgentStartIntent, configuration: AgentLaunchConfiguration = .init()) { - self.agent = agent + var agent: DetectedAgent { runtime.agent } + + init( + runtime: AgentProfileRuntime, + intent: AgentStartIntent, + configuration: AgentLaunchConfiguration = .init(), + dedicatedHome: URL? = nil + ) { + self.runtime = runtime self.intent = intent self.configuration = configuration + self.dedicatedHome = dedicatedHome + } + + init( + agent: DetectedAgent, + intent: AgentStartIntent, + configuration: AgentLaunchConfiguration = .init() + ) { + self.init(runtime: AgentProfileRuntime(agent: agent), intent: intent, configuration: configuration) } } @@ -125,12 +198,7 @@ nonisolated struct AgentResumeRequest: Equatable, Sendable { let prompt: String let model: String? - init( - agent: DetectedAgent, - session: AgentSession, - prompt: String, - model: String? = nil - ) { + init(agent: DetectedAgent, session: AgentSession, prompt: String, model: String? = nil) { self.agent = agent self.session = session self.prompt = prompt @@ -147,13 +215,10 @@ nonisolated struct AgentInvocation: Equatable, Sendable { self.arguments = arguments } - /// One reviewed POSIX-shell rendering path for a command injected into a terminal surface. var terminalInput: String { ([executable] + arguments).map(Self.shellQuote).joined(separator: " ") } - /// The single reviewed quoting rule for anything typed into a pane's shell; - /// the profile launch planner reuses it for inlined `env` assignments. static func shellQuote(_ argument: String) -> String { "'" + argument.replacing("'", with: "'\"'\"'") + "'" } @@ -161,25 +226,47 @@ nonisolated struct AgentInvocation: Equatable, Sendable { nonisolated enum AgentRuntimeError: Error, Equatable, Sendable { case unsupportedAgent(DetectedAgent) + case unsupportedStartIntent(AgentProfileRuntime, AgentStartIntent) case unsafeSessionConfidence(AgentSession.Confidence) case resumeTimedOut } nonisolated enum AgentRuntimeAdapterRegistry { - static func adapter(for agent: DetectedAgent) -> (any AgentRuntimeAdapter)? { - switch agent { + static func profileAdapter(for runtime: AgentProfileRuntime) -> (any AgentRuntimeAdapter)? { + switch runtime { case .claude: ClaudeCodeRuntimeAdapter() case .codex: CodexRuntimeAdapter() - default: nil + case .gemini: GeminiRuntimeAdapter() + case .cursor: CursorRuntimeAdapter() + case .cline: ClineRuntimeAdapter() + case .opencode: OpenCodeRuntimeAdapter() + case .copilot: CopilotRuntimeAdapter() + case .kimi: KimiRuntimeAdapter() + case .droid: DroidRuntimeAdapter() + case .amp: AmpRuntimeAdapter() + case .qoder: QoderRuntimeAdapter() + case .qwen: QwenRuntimeAdapter() + case .grok: GrokRuntimeAdapter() + case .pi: PiRuntimeAdapter() + case .omp: OMPRuntimeAdapter() } } - /// Agents with a verified interactive launch adapter, in catalog order. - /// UI entry points derive their handoff targets from this list. + /// Canonical launch adapter for a detected family. OMP is selected only by + /// an explicit profile runtime because detection intentionally classifies it + /// as Pi. + static func adapter(for agent: DetectedAgent) -> (any AgentRuntimeAdapter)? { + profileAdapter(for: AgentProfileRuntime(agent: agent)) + } + static var launchableAgents: [DetectedAgent] { DetectedAgent.allCases.filter { adapter(for: $0) != nil } } + static func displayName(for runtime: AgentProfileRuntime) -> String { + profileAdapter(for: runtime)?.displayName ?? runtime.rawValue + } + static func displayName(for agent: DetectedAgent) -> String { adapter(for: agent)?.displayName ?? agent.displayName } @@ -189,11 +276,18 @@ nonisolated enum AgentRuntimeAdapterRegistry { } static func canResume(_ agent: DetectedAgent) -> Bool { - adapter(for: agent) != nil + resumeAdapter(for: agent) != nil + } + + static func observe(runtime: AgentProfileRuntime, arguments: [String]) -> AgentLaunchObservation { + profileAdapter(for: runtime)?.observe(arguments: arguments) ?? .init() } static func observe(agent: DetectedAgent, arguments: [String]) -> AgentLaunchObservation { - adapter(for: agent)?.observe(arguments: arguments) ?? .init() + if agent == .pi, arguments.first?.lastPathComponent == "omp" { + return observe(runtime: .omp, arguments: arguments) + } + return adapter(for: agent)?.observe(arguments: arguments) ?? .init() } static func inheritedConfiguration( @@ -208,143 +302,522 @@ nonisolated enum AgentRuntimeAdapterRegistry { } static func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation { - guard let adapter = adapter(for: request.agent) else { + guard let adapter = profileAdapter(for: request.runtime) else { throw AgentRuntimeError.unsupportedAgent(request.agent) } return try adapter.makeStartInvocation(request) } - static func makeResumeInvocation(_ request: AgentResumeRequest, replyFile: URL? = nil) throws -> AgentInvocation { + static func makeResumeInvocation( + _ request: AgentResumeRequest, + replyFile: URL? = nil + ) throws -> AgentInvocation { guard request.session.confidence == .exact || request.session.confidence == .high else { throw AgentRuntimeError.unsafeSessionConfidence(request.session.confidence) } - guard let adapter = adapter(for: request.agent) else { + guard let adapter = resumeAdapter(for: request.agent) else { throw AgentRuntimeError.unsupportedAgent(request.agent) } return try adapter.makeResumeInvocation(request, replyFile: replyFile) } + + private static func resumeAdapter(for agent: DetectedAgent) -> (any AgentRuntimeResumeAdapter)? { + switch agent { + case .claude: ClaudeCodeResumeAdapter() + case .codex: CodexResumeAdapter() + default: nil + } + } } +// MARK: - Launch adapters + nonisolated private struct CodexRuntimeAdapter: AgentRuntimeAdapter { - let agent: DetectedAgent = .codex + let runtime: AgentProfileRuntime = .codex let displayName = "Codex" - let accountHomeEnvironmentVariable: String? = "CODEX_HOME" + let supportsModelSelection = true + let supportsReasoningEffort = true + let supportsUnrestrictedExecution = true + let accountIsolation: AgentProfileHomeRelocation? = AgentProfileHomeRelocation(environmentVariable: "CODEX_HOME") let reasoningEffortSuggestions = ["low", "medium", "high", "xhigh", "max"] let modelSuggestions = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] func observe(arguments: [String]) -> AgentLaunchObservation { - let explicitlyBypassesSandbox = - arguments.contains("--dangerously-bypass-approvals-and-sandbox") - || arguments.contains("--yolo") + AgentLaunchObservation( + model: arguments.optionValue(long: "--model", short: "-m"), + executionMode: arguments.containsAny("--dangerously-bypass-approvals-and-sandbox", "--yolo") + ? .unrestricted : nil + ) + } + + func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation { + var generated: [String] = [] + if let model = request.configuration.model { generated += ["--model", model] } + if let effort = request.configuration.reasoningEffort { + generated += ["-c", "model_reasoning_effort=\(effort)"] + } + if request.configuration.executionMode == .unrestricted { + generated.append("--dangerously-bypass-approvals-and-sandbox") + } + let options = finalizedOptions(generated, request: request) + return switch request.intent { + case .interactive: AgentInvocation(executable: "codex", arguments: options) + case .prompt(let prompt): AgentInvocation(executable: "codex", arguments: options + [prompt]) + case .headless(let prompt): AgentInvocation(executable: "codex", arguments: ["exec"] + options + [prompt]) + } + } +} + +nonisolated private struct ClaudeCodeRuntimeAdapter: AgentRuntimeAdapter { + let runtime: AgentProfileRuntime = .claude + let displayName = "Claude Code" + let supportsModelSelection = true + let supportsReasoningEffort = true + let supportsUnrestrictedExecution = true + let accountIsolation: AgentProfileHomeRelocation? = AgentProfileHomeRelocation( + environmentVariable: "CLAUDE_CONFIG_DIR" + ) + let reasoningEffortSuggestions = ["low", "medium", "high", "xhigh", "max"] + let modelSuggestions = [ + "claude-fable-5", + "claude-opus-5", + "claude-sonnet-5", + "claude-haiku-4-5-20251001", + ] + + func observe(arguments: [String]) -> AgentLaunchObservation { + let bypasses = + arguments.contains("--dangerously-skip-permissions") + || arguments.optionValue(long: "--permission-mode") == "bypassPermissions" return AgentLaunchObservation( model: arguments.optionValue(long: "--model", short: "-m"), - executionMode: explicitlyBypassesSandbox ? .unrestricted : nil + executionMode: bypasses ? .unrestricted : nil ) } func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation { - let options = options(for: request.configuration) + var generated: [String] = [] + if let model = request.configuration.model { generated += ["--model", model] } + if let effort = request.configuration.reasoningEffort { generated += ["--effort", effort] } + if request.configuration.executionMode == .unrestricted { + generated.append("--dangerously-skip-permissions") + } + let options = finalizedOptions(generated, request: request) return switch request.intent { - case .interactive: - AgentInvocation(executable: "codex", arguments: options) + case .interactive: AgentInvocation(executable: "claude", arguments: options) + case .prompt(let prompt): AgentInvocation(executable: "claude", arguments: options + [prompt]) + case .headless(let prompt): AgentInvocation(executable: "claude", arguments: ["-p"] + options + [prompt]) + } + } +} + +nonisolated private struct GeminiRuntimeAdapter: AgentRuntimeAdapter { + let runtime: AgentProfileRuntime = .gemini + let displayName = "Gemini CLI" + let supportsModelSelection = true + let supportsUnrestrictedExecution = true + let accountIsolation: AgentProfileHomeRelocation? = AgentProfileHomeRelocation( + environmentVariable: "GEMINI_CLI_HOME", + sessionRootRelativePath: ".gemini" + ) + + func observe(arguments: [String]) -> AgentLaunchObservation { + let approves = arguments.contains("--yolo") || arguments.optionValue(long: "--approval-mode") == "yolo" + return AgentLaunchObservation( + model: arguments.optionValue(long: "--model", short: "-m"), + executionMode: approves && arguments.booleanOptionIsFalse("--sandbox") ? .unrestricted : nil + ) + } + + func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation { + var generated: [String] = [] + if let model = request.configuration.model { generated += ["--model", model] } + if request.configuration.executionMode == .unrestricted { + generated += ["--approval-mode", "yolo", "--sandbox=false"] + } + let options = finalizedOptions(generated, request: request) + return switch request.intent { + case .interactive: AgentInvocation(executable: "gemini", arguments: options) case .prompt(let prompt): - AgentInvocation(executable: "codex", arguments: options + [prompt]) + AgentInvocation(executable: "gemini", arguments: options + ["--prompt-interactive", prompt]) case .headless(let prompt): - AgentInvocation(executable: "codex", arguments: ["exec"] + options + [prompt]) + AgentInvocation(executable: "gemini", arguments: options + ["--prompt", prompt]) } } +} - func makeResumeInvocation(_ request: AgentResumeRequest, replyFile: URL?) throws -> AgentInvocation { - // `--ephemeral` keeps the preparation turn out of `~/.codex/sessions`, so a - // resume never mutates the recorded state of the live source session. - var arguments = ["exec", "resume", "--ephemeral"] - if let model = request.model { - arguments += ["--model", model] +nonisolated private struct CursorRuntimeAdapter: AgentRuntimeAdapter { + let runtime: AgentProfileRuntime = .cursor + let displayName = "Cursor Agent" + let supportsModelSelection = true + let supportsUnrestrictedExecution = true + + func observe(arguments: [String]) -> AgentLaunchObservation { + let approves = arguments.containsAny("--force", "--yolo") + return AgentLaunchObservation( + model: arguments.optionValue(long: "--model"), + executionMode: approves && arguments.optionValue(long: "--sandbox") == "disabled" ? .unrestricted : nil + ) + } + + func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation { + var generated: [String] = [] + if let model = request.configuration.model { generated += ["--model", model] } + if request.configuration.executionMode == .unrestricted { + generated += ["--yolo", "--sandbox", "disabled"] + } + let options = finalizedOptions(generated, request: request) + return switch request.intent { + case .interactive: AgentInvocation(executable: "cursor-agent", arguments: options) + case .prompt(let prompt): AgentInvocation(executable: "cursor-agent", arguments: options + [prompt]) + case .headless(let prompt): + AgentInvocation(executable: "cursor-agent", arguments: options + ["--print", prompt]) + } + } +} + +nonisolated private struct ClineRuntimeAdapter: AgentRuntimeAdapter { + let runtime: AgentProfileRuntime = .cline + let displayName = "Cline" + let supportsModelSelection = true + let supportsReasoningEffort = true + let accountIsolation: AgentProfileHomeRelocation? = AgentProfileHomeRelocation( + pathArguments: [ + AgentProfileHomePathArgument(option: "--config", relativePath: "config"), + AgentProfileHomePathArgument(option: "--data-dir", relativePath: "data"), + AgentProfileHomePathArgument(option: "--hooks-dir", relativePath: "hooks"), + ], + sessionRootRelativePath: "data", + reservedEnvironmentVariables: ["CLINE_DATA_DIR"] + ) + let reasoningEffortSuggestions = ["none", "low", "medium", "high", "xhigh"] + + func observe(arguments: [String]) -> AgentLaunchObservation { + AgentLaunchObservation(model: arguments.optionValue(long: "--model", short: "-m")) + } + + func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation { + var generated: [String] = [] + if let model = request.configuration.model { generated += ["--model", model] } + if let effort = request.configuration.reasoningEffort { generated += ["--thinking", effort] } + let options = finalizedOptions(generated, request: request) + return switch request.intent { + case .interactive: AgentInvocation(executable: "cline", arguments: options + ["--tui"]) + case .prompt(let prompt): AgentInvocation(executable: "cline", arguments: options + ["--tui", prompt]) + case .headless(let prompt): AgentInvocation(executable: "cline", arguments: options + [prompt]) + } + } +} + +nonisolated private struct OpenCodeRuntimeAdapter: AgentRuntimeAdapter { + let runtime: AgentProfileRuntime = .opencode + let displayName = "OpenCode" + let supportsModelSelection = true + let supportsReasoningEffort = true + let supportsUnrestrictedExecution = true + + func observe(arguments: [String]) -> AgentLaunchObservation { + AgentLaunchObservation( + model: arguments.optionValue(long: "--model", short: "-m"), + executionMode: arguments.contains("--auto") ? .unrestricted : nil + ) + } + + func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation { + var generated: [String] = [] + if let model = request.configuration.model { generated += ["--model", model] } + if let effort = request.configuration.reasoningEffort { generated += ["--variant", effort] } + if request.configuration.executionMode == .unrestricted { generated.append("--auto") } + let options = finalizedOptions(generated, request: request) + return switch request.intent { + case .interactive: AgentInvocation(executable: "opencode", arguments: options) + case .prompt(let prompt): AgentInvocation(executable: "opencode", arguments: options + ["--prompt", prompt]) + case .headless(let prompt): AgentInvocation(executable: "opencode", arguments: ["run"] + options + [prompt]) } - if let replyFile { - arguments += ["--output-last-message", replyFile.path(percentEncoded: false)] + } +} + +nonisolated private struct CopilotRuntimeAdapter: AgentRuntimeAdapter { + let runtime: AgentProfileRuntime = .copilot + let displayName = "GitHub Copilot" + let supportsModelSelection = true + let supportsReasoningEffort = true + let supportsUnrestrictedExecution = true + let accountIsolation: AgentProfileHomeRelocation? = AgentProfileHomeRelocation( + environmentVariable: "COPILOT_HOME" + ) + let reasoningEffortSuggestions = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] + + func observe(arguments: [String]) -> AgentLaunchObservation { + AgentLaunchObservation( + model: arguments.optionValue(long: "--model"), + executionMode: arguments.contains("--allow-all") ? .unrestricted : nil + ) + } + + func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation { + var generated: [String] = [] + if let model = request.configuration.model { generated += ["--model", model] } + if let effort = request.configuration.reasoningEffort { generated += ["--reasoning-effort", effort] } + if request.configuration.executionMode == .unrestricted { generated.append("--allow-all") } + let options = finalizedOptions(generated, request: request) + return switch request.intent { + case .interactive: AgentInvocation(executable: "copilot", arguments: options) + case .prompt(let prompt): AgentInvocation(executable: "copilot", arguments: options + ["--interactive", prompt]) + case .headless(let prompt): AgentInvocation(executable: "copilot", arguments: options + ["--prompt", prompt]) } - return AgentInvocation( - executable: "codex", - arguments: arguments + [request.session.id, request.prompt] + } +} + +nonisolated private struct KimiRuntimeAdapter: AgentRuntimeAdapter { + let runtime: AgentProfileRuntime = .kimi + let displayName = "Kimi CLI" + let supportsModelSelection = true + let supportsUnrestrictedExecution = true + + func observe(arguments: [String]) -> AgentLaunchObservation { + AgentLaunchObservation( + model: arguments.optionValue(long: "--model", short: "-m"), + executionMode: arguments.containsAny("--yolo", "--yes") ? .unrestricted : nil ) } - private func options(for configuration: AgentLaunchConfiguration) -> [String] { - var options: [String] = [] - if let model = configuration.model { - options += ["--model", model] + func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation { + var generated: [String] = [] + if let model = request.configuration.model { generated += ["--model", model] } + if request.configuration.executionMode == .unrestricted { generated.append("--yolo") } + let options = finalizedOptions(generated, request: request) + return switch request.intent { + case .interactive: AgentInvocation(executable: "kimi", arguments: options) + case .prompt(let prompt): AgentInvocation(executable: "kimi", arguments: options + ["--prompt", prompt]) + case .headless(let prompt): + AgentInvocation(executable: "kimi", arguments: options + ["--print", "--prompt", prompt]) } - if let effort = configuration.reasoningEffort { - // Codex parses `-c` values as TOML and falls back to a string literal, - // so the effort value needs no extra quoting here. - options += ["-c", "model_reasoning_effort=\(effort)"] + } +} + +nonisolated private struct DroidRuntimeAdapter: AgentRuntimeAdapter { + let runtime: AgentProfileRuntime = .droid + let displayName = "Droid" + + func observe(arguments _: [String]) -> AgentLaunchObservation { .init() } + + func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation { + let options = finalizedOptions([], request: request) + return switch request.intent { + case .interactive: AgentInvocation(executable: "droid", arguments: options) + case .prompt(let prompt): AgentInvocation(executable: "droid", arguments: options + [prompt]) + case .headless(let prompt): AgentInvocation(executable: "droid", arguments: ["exec"] + options + [prompt]) } - if configuration.executionMode == .unrestricted { - options.append("--dangerously-bypass-approvals-and-sandbox") + } +} + +nonisolated private struct AmpRuntimeAdapter: AgentRuntimeAdapter { + let runtime: AgentProfileRuntime = .amp + let displayName = "Amp" + let supportsReasoningEffort = true + let reasoningEffortSuggestions = ["low", "medium", "high"] + + func observe(arguments _: [String]) -> AgentLaunchObservation { .init() } + + func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation { + var generated: [String] = [] + if let effort = request.configuration.reasoningEffort { generated += ["--effort", effort] } + let options = finalizedOptions(generated, request: request) + return switch request.intent { + case .interactive: AgentInvocation(executable: "amp", arguments: options) + case .prompt: + throw AgentRuntimeError.unsupportedStartIntent(.amp, request.intent) + case .headless(let prompt): + AgentInvocation(executable: "amp", arguments: options + ["--execute", prompt]) } - return options + configuration.extraArguments } } -nonisolated private struct ClaudeCodeRuntimeAdapter: AgentRuntimeAdapter { - let agent: DetectedAgent = .claude - let displayName = "Claude Code" - let accountHomeEnvironmentVariable: String? = "CLAUDE_CONFIG_DIR" - let reasoningEffortSuggestions = ["low", "medium", "high", "xhigh", "max"] - let modelSuggestions = [ - "claude-fable-5", - "claude-opus-5", - "claude-sonnet-5", - "claude-haiku-4-5-20251001", - ] +nonisolated private struct QoderRuntimeAdapter: AgentRuntimeAdapter { + let runtime: AgentProfileRuntime = .qoder + let displayName = "Qoder CLI" + let supportsModelSelection = true + let supportsReasoningEffort = true + let supportsUnrestrictedExecution = true + let accountIsolation: AgentProfileHomeRelocation? = AgentProfileHomeRelocation( + pathArguments: [AgentProfileHomePathArgument(option: "--config-dir", relativePath: "")] + ) + let reasoningEffortSuggestions = ["low", "medium", "high"] func observe(arguments: [String]) -> AgentLaunchObservation { - let explicitlyBypassesPermissions = + let bypasses = arguments.contains("--dangerously-skip-permissions") - || arguments.optionValue(long: "--permission-mode") == "bypassPermissions" + || arguments.optionValue(long: "--permission-mode") == "bypass_permissions" return AgentLaunchObservation( model: arguments.optionValue(long: "--model", short: "-m"), - executionMode: explicitlyBypassesPermissions ? .unrestricted : nil + executionMode: bypasses ? .unrestricted : nil ) } func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation { - let options = options(for: request.configuration) + var generated: [String] = [] + if let model = request.configuration.model { generated += ["--model", model] } + if let effort = request.configuration.reasoningEffort { generated += ["--reasoning-effort", effort] } + if request.configuration.executionMode == .unrestricted { + generated.append("--dangerously-skip-permissions") + } + let options = finalizedOptions(generated, request: request) return switch request.intent { - case .interactive: - AgentInvocation(executable: "claude", arguments: options) + case .interactive: AgentInvocation(executable: "qodercli", arguments: options) case .prompt(let prompt): - AgentInvocation(executable: "claude", arguments: options + [prompt]) - case .headless(let prompt): - AgentInvocation(executable: "claude", arguments: ["-p"] + options + [prompt]) + AgentInvocation(executable: "qodercli", arguments: options + ["--prompt-interactive", prompt]) + case .headless(let prompt): AgentInvocation(executable: "qodercli", arguments: options + ["--print", prompt]) } } +} - func makeResumeInvocation(_ request: AgentResumeRequest, replyFile: URL?) throws -> AgentInvocation { - // `claude -p` prints only the final reply on stdout, so no reply file is needed. - // `--fork-session` is load-bearing: without it `--resume` continues the same - // session ID and appends the preparation turn to the transcript of a session - // that is usually still live in the pane (dual-writer on one JSONL). - var arguments = ["-p", "--fork-session", "--resume", request.session.id] - if let model = request.model { - arguments += ["--model", model] +nonisolated private struct QwenRuntimeAdapter: AgentRuntimeAdapter { + let runtime: AgentProfileRuntime = .qwen + let displayName = "Qwen Code" + let supportsModelSelection = true + let supportsReasoningEffort = true + let supportsUnrestrictedExecution = true + let accountIsolation: AgentProfileHomeRelocation? = AgentProfileHomeRelocation(environmentVariable: "QWEN_HOME") + let reasoningEffortSuggestions = ["low", "medium", "high", "xhigh"] + + func observe(arguments: [String]) -> AgentLaunchObservation { + let approves = arguments.contains("--yolo") || arguments.optionValue(long: "--approval-mode") == "yolo" + return AgentLaunchObservation( + model: arguments.optionValue(long: "--model", short: "-m"), + executionMode: approves && arguments.booleanOptionIsFalse("--sandbox") ? .unrestricted : nil + ) + } + + func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation { + var generated: [String] = [] + if let model = request.configuration.model { generated += ["--model", model] } + if let effort = request.configuration.reasoningEffort { generated += ["--reasoning-effort", effort] } + if request.configuration.executionMode == .unrestricted { + generated += ["--approval-mode", "yolo", "--sandbox=false"] } - return AgentInvocation(executable: "claude", arguments: arguments + [request.prompt]) + let options = finalizedOptions(generated, request: request) + return switch request.intent { + case .interactive: AgentInvocation(executable: "qwen", arguments: options) + case .prompt(let prompt): + AgentInvocation(executable: "qwen", arguments: options + ["--prompt-interactive", prompt]) + case .headless(let prompt): AgentInvocation(executable: "qwen", arguments: options + ["--prompt", prompt]) + } + } +} + +nonisolated private struct GrokRuntimeAdapter: AgentRuntimeAdapter { + let runtime: AgentProfileRuntime = .grok + let displayName = "Grok Build" + let supportsModelSelection = true + let supportsReasoningEffort = true + let reasoningEffortSuggestions = ["low", "medium", "high"] + + func observe(arguments: [String]) -> AgentLaunchObservation { + AgentLaunchObservation(model: arguments.optionValue(long: "--model", short: "-m")) } - private func options(for configuration: AgentLaunchConfiguration) -> [String] { - var options: [String] = [] - if let model = configuration.model { - options += ["--model", model] + func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation { + var generated: [String] = [] + if let model = request.configuration.model { generated += ["--model", model] } + if let effort = request.configuration.reasoningEffort { generated += ["--reasoning-effort", effort] } + let options = finalizedOptions(generated, request: request) + return switch request.intent { + case .interactive: AgentInvocation(executable: "grok", arguments: options) + case .prompt(let prompt): AgentInvocation(executable: "grok", arguments: options + [prompt]) + case .headless(let prompt): AgentInvocation(executable: "grok", arguments: options + ["--single", prompt]) } - if let effort = configuration.reasoningEffort { - options += ["--effort", effort] + } +} + +nonisolated private struct PiRuntimeAdapter: AgentRuntimeAdapter { + let runtime: AgentProfileRuntime = .pi + let displayName = "Pi" + let supportsModelSelection = true + let supportsReasoningEffort = true + let accountIsolation: AgentProfileHomeRelocation? = AgentProfileHomeRelocation( + environmentVariable: "PI_CODING_AGENT_DIR" + ) + let reasoningEffortSuggestions = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] + + func observe(arguments: [String]) -> AgentLaunchObservation { + AgentLaunchObservation(model: arguments.optionValue(long: "--model")) + } + + func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation { + let options = piOptions(request) + return switch request.intent { + case .interactive: AgentInvocation(executable: "pi", arguments: options) + case .prompt(let prompt): AgentInvocation(executable: "pi", arguments: options + [prompt]) + case .headless(let prompt): AgentInvocation(executable: "pi", arguments: options + ["--print", prompt]) } - if configuration.executionMode == .unrestricted { - options.append("--dangerously-skip-permissions") + } + + private func piOptions(_ request: AgentStartRequest) -> [String] { + var generated: [String] = [] + if let model = request.configuration.model { generated += ["--model", model] } + if let effort = request.configuration.reasoningEffort { generated += ["--thinking", effort] } + return finalizedOptions(generated, request: request) + } +} + +nonisolated private struct OMPRuntimeAdapter: AgentRuntimeAdapter { + let runtime: AgentProfileRuntime = .omp + let displayName = "Oh My Pi" + let supportsModelSelection = true + let supportsReasoningEffort = true + let supportsUnrestrictedExecution = true + let accountIsolation: AgentProfileHomeRelocation? = AgentProfileHomeRelocation( + environmentVariable: "PI_CODING_AGENT_DIR" + ) + let reasoningEffortSuggestions = ["off", "minimal", "low", "medium", "high", "xhigh", "max", "auto"] + + func observe(arguments: [String]) -> AgentLaunchObservation { + let bypasses = + arguments.contains("--auto-approve") + || arguments.optionValue(long: "--approval-mode") == "yolo" + return AgentLaunchObservation( + model: arguments.optionValue(long: "--model"), + executionMode: bypasses ? .unrestricted : nil + ) + } + + func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation { + var generated: [String] = [] + if let model = request.configuration.model { generated += ["--model", model] } + if let effort = request.configuration.reasoningEffort { generated += ["--thinking", effort] } + if request.configuration.executionMode == .unrestricted { + generated += ["--approval-mode", "yolo"] + } + let options = finalizedOptions(generated, request: request) + return switch request.intent { + case .interactive: AgentInvocation(executable: "omp", arguments: options) + case .prompt(let prompt): AgentInvocation(executable: "omp", arguments: options + [prompt]) + case .headless(let prompt): AgentInvocation(executable: "omp", arguments: options + ["--print", prompt]) } - return options + configuration.extraArguments + } +} + +// MARK: - Resume adapters + +nonisolated private struct CodexResumeAdapter: AgentRuntimeResumeAdapter { + let agent: DetectedAgent = .codex + + func makeResumeInvocation(_ request: AgentResumeRequest, replyFile: URL?) throws -> AgentInvocation { + var arguments = ["exec", "resume", "--ephemeral"] + if let model = request.model { arguments += ["--model", model] } + if let replyFile { arguments += ["--output-last-message", replyFile.path(percentEncoded: false)] } + return AgentInvocation(executable: "codex", arguments: arguments + [request.session.id, request.prompt]) + } +} + +nonisolated private struct ClaudeCodeResumeAdapter: AgentRuntimeResumeAdapter { + let agent: DetectedAgent = .claude + + func makeResumeInvocation(_ request: AgentResumeRequest, replyFile _: URL?) throws -> AgentInvocation { + var arguments = ["-p", "--fork-session", "--resume", request.session.id] + if let model = request.model { arguments += ["--model", model] } + return AgentInvocation(executable: "claude", arguments: arguments + [request.prompt]) } } @@ -362,4 +835,18 @@ nonisolated extension [String] { } return nil } + + fileprivate func containsAny(_ values: String...) -> Bool { + values.contains(where: contains) + } + + fileprivate func booleanOptionIsFalse(_ option: String) -> Bool { + contains("\(option)=false") || optionValue(long: option) == "false" + } +} + +nonisolated extension String { + fileprivate var lastPathComponent: String { + URL(fileURLWithPath: self).lastPathComponent + } } diff --git a/supacode/Features/App/Reducer/AppFeature+AgentProfiles.swift b/supacode/Features/App/Reducer/AppFeature+AgentProfiles.swift index dc79266c..f8eef399 100644 --- a/supacode/Features/App/Reducer/AppFeature+AgentProfiles.swift +++ b/supacode/Features/App/Reducer/AppFeature+AgentProfiles.swift @@ -46,7 +46,7 @@ enum AgentProfileSeeder { guard !settings.didSeedAgentProfiles else { return } let seeded = AgentProfileRuntime.allCases.filter(isRuntimeInstalled).map { runtime in AgentProfile( - name: AgentRuntimeAdapterRegistry.displayName(for: runtime.agent), + name: AgentRuntimeAdapterRegistry.displayName(for: runtime), runtime: runtime ) } diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 7d0286f4..2c082225 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -567,7 +567,7 @@ func agentProfileLaunchItems( (lhs.id == recommendedID ? 0 : 1) < (rhs.id == recommendedID ? 0 : 1) } return ordered.map { profile in - let runtimeName = AgentRuntimeAdapterRegistry.displayName(for: profile.runtime.agent) + let runtimeName = AgentRuntimeAdapterRegistry.displayName(for: profile.runtime) let placement = profile.id == recommendedID ? "Recommended · " : "" // Same soft availability judgment as the Agents popover — surfaced in the // subtitle, never blocking activation (docs-ai 053/005). diff --git a/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift b/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift index 25d4a3e7..c9d06a6b 100644 --- a/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift +++ b/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift @@ -112,7 +112,10 @@ struct HandoffHudFeature { session: source?.session, observation: source?.observation ) - var targets = AgentRuntimeAdapterRegistry.launchableAgents.map { agent in + // Handoff destination policy is independent from generic Profile launch + // support. Expanding the runtime catalog must not expose a receiver until + // its kickoff semantics and transition contract are explicitly verified. + var targets = HandoffAgentSupport.launchableAgents.compactMap(DetectedAgent.init(rawValue:)).map { agent in HandoffTargetOption( kind: .agent(agent), title: AgentRuntimeAdapterRegistry.displayName(for: agent), diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 87c4e40b..c55979f0 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -443,7 +443,7 @@ struct WorktreeDetailView: View { AgentsLauncherItem( id: profile.id, name: profile.name, - runtimeName: AgentRuntimeAdapterRegistry.displayName(for: profile.runtime.agent), + runtimeName: AgentRuntimeAdapterRegistry.displayName(for: profile.runtime), iconSource: profile.iconSource, isRecommended: profile.id == recommendedID, availabilityWarning: AgentProfileAvailability.launchWarning(for: profile) diff --git a/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift b/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift index 4e9ca7cd..25f3c32b 100644 --- a/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift +++ b/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift @@ -74,6 +74,9 @@ struct AgentProfileEditorFeature { state.profile.extraArguments = "" state.profile.environmentOverrides = [] state.profile.executionMode = .standard + if AgentRuntimeAdapterRegistry.profileAdapter(for: runtime)?.supportsAccountIsolation != true { + state.profile.bindsDedicatedHome = false + } refreshHomeStatus(&state) return .send(.delegate(.profileEdited(state.profile))) diff --git a/supacode/Features/Settings/Reducer/AgentProfilesFeature.swift b/supacode/Features/Settings/Reducer/AgentProfilesFeature.swift index 8ced6380..39dc2c5a 100644 --- a/supacode/Features/Settings/Reducer/AgentProfilesFeature.swift +++ b/supacode/Features/Settings/Reducer/AgentProfilesFeature.swift @@ -56,7 +56,7 @@ struct AgentProfilesFeature { case .addProfile(let runtime): let profile = AgentProfile( id: uuid(), - name: AgentRuntimeAdapterRegistry.displayName(for: runtime.agent), + name: AgentRuntimeAdapterRegistry.displayName(for: runtime), runtime: runtime ) state.settings.agentProfiles.append(profile) @@ -130,7 +130,7 @@ struct AgentProfilesFeature { if profile.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { profile.name = persisted.agentProfiles.first { $0.id == profile.id }?.name - ?? AgentRuntimeAdapterRegistry.displayName(for: profile.runtime.agent) + ?? AgentRuntimeAdapterRegistry.displayName(for: profile.runtime) } return profile } diff --git a/supacode/Features/Settings/Views/AgentProfileEditorView.swift b/supacode/Features/Settings/Views/AgentProfileEditorView.swift index 48e707e5..59347969 100644 --- a/supacode/Features/Settings/Views/AgentProfileEditorView.swift +++ b/supacode/Features/Settings/Views/AgentProfileEditorView.swift @@ -47,7 +47,7 @@ struct AgentProfileEditorView: View { ) ) { ForEach(AgentProfileRuntime.allCases) { runtime in - Text(AgentRuntimeAdapterRegistry.displayName(for: runtime.agent)).tag(runtime) + Text(AgentRuntimeAdapterRegistry.displayName(for: runtime)).tag(runtime) } } iconRow @@ -56,21 +56,27 @@ struct AgentProfileEditorView: View { private var detailsSection: some View { Section("Details") { - suggestedTextRow( - title: "Model", - prompt: "Runtime default", - text: $store.profile.model, - suggestions: modelSuggestions - ) - suggestedTextRow( - title: "Reasoning Effort", - prompt: "Runtime default", - text: $store.profile.reasoningEffort, - suggestions: effortSuggestions - ) - Picker("Execution Mode", selection: $store.profile.executionMode) { - Text("Standard").tag(AgentExecutionMode.standard) - Text("Unrestricted").tag(AgentExecutionMode.unrestricted) + if runtimeAdapter?.supportsModelSelection == true { + suggestedTextRow( + title: "Model", + prompt: "Runtime default", + text: $store.profile.model, + suggestions: modelSuggestions + ) + } + if runtimeAdapter?.supportsReasoningEffort == true { + suggestedTextRow( + title: "Reasoning Effort", + prompt: "Runtime default", + text: $store.profile.reasoningEffort, + suggestions: effortSuggestions + ) + } + if runtimeAdapter?.supportsUnrestrictedExecution == true { + Picker("Execution Mode", selection: $store.profile.executionMode) { + Text("Standard").tag(AgentExecutionMode.standard) + Text("Unrestricted").tag(AgentExecutionMode.unrestricted) + } } switch store.profile.effectiveExecutionMode { case .standard: @@ -119,9 +125,11 @@ struct AgentProfileEditorView: View { id: override.id ) } - Toggle("Use Dedicated Home", isOn: $store.profile.bindsDedicatedHome) - .help("Keep a separate login, usage, and configuration for this profile") - if store.profile.bindsDedicatedHome { + if runtimeAdapter?.supportsAccountIsolation == true { + Toggle("Use Dedicated Home", isOn: $store.profile.bindsDedicatedHome) + .help("Keep a separate login, usage, and configuration for this profile") + } + if runtimeAdapter?.supportsAccountIsolation == true, store.profile.bindsDedicatedHome { Text( "This profile gets its own runtime home: separate login and usage, " + "but also separate skills, global instructions, and session history. " @@ -246,7 +254,7 @@ struct AgentProfileEditorView: View { .font(.headline) Text( store.profile.icon == nil - ? "\(AgentRuntimeAdapterRegistry.displayName(for: store.profile.runtime.agent)) brand icon" + ? "\(AgentRuntimeAdapterRegistry.displayName(for: store.profile.runtime)) brand icon" : "Custom SF Symbol" ) .font(.caption) @@ -360,11 +368,14 @@ struct AgentProfileEditorView: View { } private var effortSuggestions: [String] { - AgentRuntimeAdapterRegistry.adapter(for: store.profile.runtime.agent)?.reasoningEffortSuggestions - ?? [] + runtimeAdapter?.reasoningEffortSuggestions ?? [] } private var modelSuggestions: [String] { - AgentRuntimeAdapterRegistry.adapter(for: store.profile.runtime.agent)?.modelSuggestions ?? [] + runtimeAdapter?.modelSuggestions ?? [] + } + + private var runtimeAdapter: (any AgentRuntimeAdapter)? { + AgentRuntimeAdapterRegistry.profileAdapter(for: store.profile.runtime) } } diff --git a/supacode/Features/Settings/Views/AgentProfileIconImage.swift b/supacode/Features/Settings/Views/AgentProfileIconImage.swift index 70d03097..4fee4d76 100644 --- a/supacode/Features/Settings/Views/AgentProfileIconImage.swift +++ b/supacode/Features/Settings/Views/AgentProfileIconImage.swift @@ -25,7 +25,7 @@ enum AgentProfileIconResolver { return TabIconSource(systemSymbol: overrideSymbol) } - return CommandIconMap.iconForFirstToken(iconSource.runtime.agent.iconLookupToken) + return CommandIconMap.iconForFirstToken(iconSource.runtime.iconLookupToken) ?? TabIconSource(systemSymbol: "sparkles") } } diff --git a/supacode/Features/Settings/Views/AgentProfilesSettingsView.swift b/supacode/Features/Settings/Views/AgentProfilesSettingsView.swift index 62b15a55..357e5d20 100644 --- a/supacode/Features/Settings/Views/AgentProfilesSettingsView.swift +++ b/supacode/Features/Settings/Views/AgentProfilesSettingsView.swift @@ -34,7 +34,7 @@ struct AgentProfilesSettingsView: View { HStack(spacing: 8) { Menu { ForEach(AgentProfileRuntime.allCases) { runtime in - Button(AgentRuntimeAdapterRegistry.displayName(for: runtime.agent)) { + Button(AgentRuntimeAdapterRegistry.displayName(for: runtime)) { store.send(.addProfile(runtime)) } } @@ -94,7 +94,7 @@ struct AgentProfilesSettingsView: View { .help("Uses a dedicated home with its own account") } Spacer() - Text(AgentRuntimeAdapterRegistry.displayName(for: profile.runtime.agent)) + Text(AgentRuntimeAdapterRegistry.displayName(for: profile.runtime)) .foregroundStyle(.secondary) .help("Edit this profile") } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index ea133b34..9e3b59df 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -85,9 +85,27 @@ final class WorktreeTerminalState { /// resolver uses it as the config root for this surface. Nil for pure /// presets (default home layout). let dedicatedHome: URL? + /// The native session layout may be nested below the provisioned home + /// (Gemini: `.gemini`, Cline: `data`). Keep provisioning and attribution + /// roots distinct instead of teaching the resolver launch semantics. + let sessionConfigRoot: URL? + + init( + profileID: UUID, + name: String, + runtime: AgentProfileRuntime, + dedicatedHome: URL?, + sessionConfigRoot: URL? = nil + ) { + self.profileID = profileID + self.name = name + self.runtime = runtime + self.dedicatedHome = dedicatedHome + self.sessionConfigRoot = sessionConfigRoot ?? dedicatedHome + } func configRoot(forDetected agent: DetectedAgent) -> URL? { - runtime.agent == agent ? dedicatedHome : nil + runtime.agent == agent ? sessionConfigRoot : nil } } @@ -430,7 +448,8 @@ final class WorktreeTerminalState { profileID: plan.profileID, name: plan.profileName, runtime: plan.runtime, - dedicatedHome: plan.dedicatedHome + dedicatedHome: plan.dedicatedHome, + sessionConfigRoot: plan.sessionConfigRoot ) if plan.placement == .split, let surfaceID = createSplitOnFocusedSurface( diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift index 1915ef58..5921186b 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift @@ -44,6 +44,10 @@ nonisolated struct AgentSessionProfile: Sendable { (@Sendable (_ configRoot: URL, _ cwd: URL?, _ processStartedAt: Date, _ now: Date) -> [URL])? var rootedFallbackRoots: (@Sendable (_ configRoot: URL, _ cwd: URL?) -> [URL])? var rootedParsePath: (@Sendable (_ path: String, _ configRoot: URL) -> AgentSession?)? + /// Exact pid lookup under a relocated native config root. Kept separate from + /// `pidKeyedSession` because default layouts prepend `.copilot`/`.qwen`, + /// while their relocation variables already point at those directories. + var rootedPIDKeyedSession: (@Sendable (_ configRoot: URL, _ pid: pid_t, _ processStartedAt: Date) -> AgentSession?)? static func profile(for agent: DetectedAgent) -> AgentSessionProfile { switch agent { @@ -126,6 +130,13 @@ nonisolated extension AgentSessionProfile { candidateRoots: { home, cwd, _, _ in guard let cwd else { return [] } return [home.appending(path: ".pi/agent/sessions/-\(slashDashed(cwd.path))--")] + }, + rootedCandidateRoots: { configRoot, cwd, _, _ in + guard let cwd else { return [] } + return [configRoot.appending(path: "sessions/-\(slashDashed(cwd.path))--")] + }, + rootedParsePath: { path, configRoot in + uuidJSONL(path: path, underRoot: configRoot.appending(path: "sessions")) } ) @@ -157,6 +168,22 @@ nonisolated extension AgentSessionProfile { }, fallbackRoots: { home, _ in [home.appending(path: ".gemini/tmp")] + }, + rootedCandidateRoots: { configRoot, cwd, _, _ in + guard let cwd else { return [configRoot.appending(path: "tmp")] } + let tmp = configRoot.appending(path: "tmp") + var roots: [URL] = [] + if let slug = geminiProjectSlug(configRoot: configRoot, cwd: cwd) { + roots.append(tmp.appending(path: "\(slug)/chats")) + } + roots.append(tmp.appending(path: "\(sha256Hex(cwd.path))/chats")) + return roots + }, + rootedFallbackRoots: { configRoot, _ in + [configRoot.appending(path: "tmp")] + }, + rootedParsePath: { path, configRoot in + geminiSession(path: path, underRoot: configRoot.appending(path: "tmp")) } ) @@ -177,6 +204,12 @@ nonisolated extension AgentSessionProfile { parsePath: { markedComponent(path: $0, marker: "/.cline/data/tasks/", component: "tasks") }, candidateRoots: { home, _, _, _ in [home.appending(path: ".cline/data/tasks")] + }, + rootedCandidateRoots: { configRoot, _, _, _ in + [configRoot.appending(path: "tasks")] + }, + rootedParsePath: { path, configRoot in + firstComponent(path: path, underRoot: configRoot.appending(path: "tasks")) } ) @@ -196,6 +229,23 @@ nonisolated extension AgentSessionProfile { pid: pid, processStartedAt: processStartedAt ) + }, + rootedCandidateRoots: { configRoot, _, _, _ in + [configRoot.appending(path: "session-state")] + }, + rootedParsePath: { path, configRoot in + firstComponent( + path: path, + underRoot: configRoot.appending(path: "session-state") + ) + }, + rootedPIDKeyedSession: { configRoot, pid, processStartedAt in + CopilotProcessLog.session( + logsDirectory: configRoot.appending(path: "logs"), + sessionStateDirectory: configRoot.appending(path: "session-state"), + pid: pid, + processStartedAt: processStartedAt + ) } ) @@ -239,6 +289,13 @@ nonisolated extension AgentSessionProfile { candidateRoots: { home, cwd, _, _ in guard let cwd else { return [] } return [home.appending(path: ".qoder/projects/\(alphanumericDashed(cwd.path))")] + }, + rootedCandidateRoots: { configRoot, cwd, _, _ in + guard let cwd else { return [] } + return [configRoot.appending(path: "projects/\(alphanumericDashed(cwd.path))")] + }, + rootedParsePath: { path, configRoot in + uuidJSONL(path: path, underRoot: configRoot.appending(path: "projects")) } ) @@ -296,6 +353,23 @@ nonisolated extension AgentSessionProfile { pid: pid, processStartedAt: processStartedAt ) + }, + rootedCandidateRoots: { configRoot, cwd, _, _ in + guard let cwd else { return [configRoot.appending(path: "projects")] } + return [configRoot.appending(path: "projects/\(alphanumericDashed(cwd.path))/chats")] + }, + rootedFallbackRoots: { configRoot, _ in + [configRoot.appending(path: "projects")] + }, + rootedParsePath: { path, configRoot in + uuidJSONL(path: path, underRoot: configRoot.appending(path: "projects")) + }, + rootedPIDKeyedSession: { configRoot, pid, processStartedAt in + QwenRuntimeStatus.session( + projectsRoot: configRoot.appending(path: "projects"), + pid: pid, + processStartedAt: processStartedAt + ) } ) @@ -398,6 +472,32 @@ nonisolated extension AgentSessionProfile { ) } + fileprivate static func firstComponent(path: String, underRoot root: URL) -> AgentSession? { + let rootPath = root.standardizedFileURL.path(percentEncoded: false) + let standardizedPath = URL(fileURLWithPath: path).standardizedFileURL.path(percentEncoded: false) + let prefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/" + guard standardizedPath.hasPrefix(prefix), + let id = standardizedPath.dropFirst(prefix.count).split(separator: "/").first + else { return nil } + return AgentSession( + id: String(id), + transcriptPath: URL(fileURLWithPath: standardizedPath), + source: .recentFile + ) + } + + fileprivate static func geminiSession(path: String, underRoot root: URL) -> AgentSession? { + let rootPath = root.standardizedFileURL.path(percentEncoded: false) + let url = URL(fileURLWithPath: path) + guard path.hasPrefix(rootPath.hasSuffix("/") ? rootPath : rootPath + "/"), path.contains("/chats/"), + url.pathExtension == "jsonl", url.lastPathComponent.hasPrefix("session-") + else { return nil } + guard let id = url.deletingPathExtension().lastPathComponent.split(separator: "-").last.map(String.init), + !id.isEmpty + else { return nil } + return AgentSession(id: id, transcriptPath: url, source: .recentFile) + } + fileprivate static func uuid(in value: String) -> String? { let pattern = #"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"# guard let range = value.range(of: pattern, options: .regularExpression) else { return nil } @@ -470,7 +570,11 @@ nonisolated extension AgentSessionProfile { } fileprivate static func geminiProjectSlug(home: URL, cwd: URL) -> String? { - let url = home.appending(path: ".gemini/projects.json") + geminiProjectSlug(configRoot: home.appending(path: ".gemini"), cwd: cwd) + } + + fileprivate static func geminiProjectSlug(configRoot: URL, cwd: URL) -> String? { + let url = configRoot.appending(path: "projects.json") guard let data = try? Data(contentsOf: url), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let projects = object["projects"] as? [String: Any] diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift index c5908658..722a70e0 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift @@ -259,8 +259,11 @@ actor AgentSessionResolver { return (resolved, false) } - // pid-keyed artifacts live in the default home; a relocated config root - // has no equivalent (no bound-capable runtime defines one). + if let configRoot, + let session = profile.rootedPIDKeyedSession?(configRoot, identified.process.pid, processStartedAt) + { + return (session, false) + } if configRoot == nil, let session = profile.pidKeyedSession?(homeDirectory, identified.process.pid, processStartedAt) { diff --git a/supacodeTests/AgentProfileEditorFeatureTests.swift b/supacodeTests/AgentProfileEditorFeatureTests.swift index de497b5e..717fc5e1 100644 --- a/supacodeTests/AgentProfileEditorFeatureTests.swift +++ b/supacodeTests/AgentProfileEditorFeatureTests.swift @@ -54,6 +54,20 @@ struct AgentProfileEditorFeatureTests { await store.receive(\.delegate.profileEdited) } + @Test(.dependencies) func changingToRuntimeWithoutIsolationClearsDedicatedHomeBinding() async { + var profile = AgentProfile(name: "Codex", runtime: .codex) + profile.bindsDedicatedHome = true + let store = TestStore(initialState: AgentProfileEditorFeature.State(profile: profile)) { + AgentProfileEditorFeature() + } + + await store.send(.runtimeChanged(.cursor)) { + $0.profile.runtime = .cursor + $0.profile.bindsDedicatedHome = false + } + await store.receive(\.delegate.profileEdited) + } + @Test func suggestionSelectionDistinguishesCustomValuesFromRuntimeDefault() { let suggestions = ["low", "medium", "high"] diff --git a/supacodeTests/AgentProfileTests.swift b/supacodeTests/AgentProfileTests.swift index d69fc5a1..98cbf52b 100644 --- a/supacodeTests/AgentProfileTests.swift +++ b/supacodeTests/AgentProfileTests.swift @@ -4,6 +4,13 @@ import Testing @testable import supacode struct AgentProfileTests { + private struct HomeRelocationExpectation { + let runtime: AgentProfileRuntime + let environmentName: String? + let relativeArguments: [String] + let sessionRootComponent: String + } + private func profile( id: UUID = UUID(), name: String = "Codex · Work", @@ -80,6 +87,20 @@ struct AgentProfileTests { let fallback = AgentProfile(name: "Claude Code", runtime: .claude) let expected = try #require(CommandIconMap.iconForFirstToken(fallback.runtime.agent.iconLookupToken)) #expect(AgentProfileIconResolver.source(for: fallback.iconSource) == expected) + + let omp = AgentProfile(name: "Oh My Pi", runtime: .omp) + let ompExpected = try #require(CommandIconMap.iconForFirstToken("omp")) + #expect(AgentProfileIconResolver.source(for: omp.iconSource) == ompExpected) + } + + @Test func everyRuntimeRoundTripsWithoutCollapsingPiAndOMP() throws { + for runtime in AgentProfileRuntime.allCases { + let encoded = try JSONEncoder().encode(AgentProfile(name: runtime.rawValue, runtime: runtime)) + let decoded = try JSONDecoder().decode(AgentProfile.self, from: encoded) + #expect(decoded.runtime == runtime) + } + #expect(AgentProfileRuntime.pi.agent == .pi) + #expect(AgentProfileRuntime.omp.agent == .pi) } // MARK: - Recommendation @@ -158,6 +179,86 @@ struct AgentProfileTests { // MARK: - Launch plan + @Test func environmentAndArgumentRelocationsCompileToManagedHomes() throws { + let base = URL(fileURLWithPath: "/base/agent-profiles", isDirectory: true) + let expected = [ + HomeRelocationExpectation( + runtime: .claude, environmentName: "CLAUDE_CONFIG_DIR", relativeArguments: [], sessionRootComponent: ""), + HomeRelocationExpectation( + runtime: .codex, environmentName: "CODEX_HOME", relativeArguments: [], sessionRootComponent: ""), + HomeRelocationExpectation( + runtime: .gemini, environmentName: "GEMINI_CLI_HOME", relativeArguments: [], sessionRootComponent: ".gemini"), + HomeRelocationExpectation( + runtime: .cline, environmentName: nil, + relativeArguments: ["--config", "config", "--data-dir", "data", "--hooks-dir", "hooks"], + sessionRootComponent: "data"), + HomeRelocationExpectation( + runtime: .copilot, environmentName: "COPILOT_HOME", relativeArguments: [], sessionRootComponent: ""), + HomeRelocationExpectation( + runtime: .qoder, environmentName: nil, relativeArguments: ["--config-dir", ""], sessionRootComponent: ""), + HomeRelocationExpectation( + runtime: .qwen, environmentName: "QWEN_HOME", relativeArguments: [], sessionRootComponent: ""), + HomeRelocationExpectation( + runtime: .pi, environmentName: "PI_CODING_AGENT_DIR", relativeArguments: [], sessionRootComponent: ""), + HomeRelocationExpectation( + runtime: .omp, environmentName: "PI_CODING_AGENT_DIR", relativeArguments: [], sessionRootComponent: ""), + ] + + for expectation in expected { + var bound = AgentProfile( + name: expectation.runtime.rawValue, + runtime: expectation.runtime, + bindsDedicatedHome: true + ) + bound.extraArguments = "--config-dir /tmp/ignored" + let plan = try AgentProfileLaunchPlanner.plan(for: bound, homeBaseDirectory: base) + let home = try #require(plan.dedicatedHome) + let sessionRoot = try #require(plan.sessionConfigRoot) + + if let environmentName = expectation.environmentName { + #expect( + plan.commandEnvironmentTokens.first + == "\(environmentName)=\(AgentInvocation.shellQuote(AgentProfileLaunchPlanner.pathString(home)))" + ) + } else { + #expect(plan.commandEnvironmentTokens.isEmpty) + } + let expectedRoot = + expectation.sessionRootComponent.isEmpty + ? home + : home.appending(path: expectation.sessionRootComponent) + #expect( + AgentProfileLaunchPlanner.pathString(sessionRoot) + == AgentProfileLaunchPlanner.pathString(expectedRoot) + ) + + if !expectation.relativeArguments.isEmpty { + let managedArguments = expectation.relativeArguments.map { component in + component.hasPrefix("--") || component.isEmpty + ? component + : AgentProfileLaunchPlanner.pathString(home.appending(path: component)) + } + let suffix = managedArguments.map { $0.isEmpty ? AgentProfileLaunchPlanner.pathString(home) : $0 } + let tuiCount = expectation.runtime == .cline ? 1 : 0 + #expect(plan.invocation.arguments.dropLast(tuiCount).suffix(suffix.count) == suffix[...]) + } + } + } + + @Test func unsupportedDedicatedHomesAreRejectedWithoutAFalseIsolationClaim() { + let unsupported: [AgentProfileRuntime] = [.cursor, .opencode, .kimi, .droid, .amp, .grok] + + for runtime in unsupported { + let bound = AgentProfile(name: runtime.rawValue, runtime: runtime, bindsDedicatedHome: true) + #expect(throws: AgentProfileLaunchPlanError.accountIsolationUnsupported(runtime)) { + try AgentProfileLaunchPlanner.plan( + for: bound, + homeBaseDirectory: URL(fileURLWithPath: "/base", isDirectory: true) + ) + } + } + } + @Test func purePresetPlanHasNoEnvironmentAndSplitsExtraArguments() throws { var preset = profile(name: "Codex · Deep") preset.model = "gpt-5.4" diff --git a/supacodeTests/AgentProfilesFeatureTests.swift b/supacodeTests/AgentProfilesFeatureTests.swift index ca67d63c..80cbf102 100644 --- a/supacodeTests/AgentProfilesFeatureTests.swift +++ b/supacodeTests/AgentProfilesFeatureTests.swift @@ -223,6 +223,16 @@ struct AgentProfilesFeatureTests { // A different agent started manually in the same pane uses its default // home; handing it the profile home would break session attribution. #expect(identity.configRoot(forDetected: .claude) == nil) + + let geminiRoot = home.appending(path: ".gemini", directoryHint: .isDirectory) + let nestedIdentity = WorktreeTerminalState.SurfaceLaunchProfile( + profileID: UUID(), + name: "Gemini · Work", + runtime: .gemini, + dedicatedHome: home, + sessionConfigRoot: geminiRoot + ) + #expect(nestedIdentity.configRoot(forDetected: .gemini) == geminiRoot) } @Test(.dependencies) func moveReordersFallbackPriority() async { diff --git a/supacodeTests/AgentRuntimeAdapterTests.swift b/supacodeTests/AgentRuntimeAdapterTests.swift index 44192349..9537453d 100644 --- a/supacodeTests/AgentRuntimeAdapterTests.swift +++ b/supacodeTests/AgentRuntimeAdapterTests.swift @@ -6,6 +6,168 @@ import Testing @testable import supacode struct AgentRuntimeAdapterTests { + private struct CatalogExpectation { + let runtime: AgentProfileRuntime + let agent: DetectedAgent + let executable: String + let arguments: [String] + } + + private struct IntentExpectation { + let runtime: AgentProfileRuntime + let promptedArguments: [String] + let headlessArguments: [String] + } + + @Test func profileRuntimeCatalogCoversEveryLaunchableCLI() throws { + let expected = [ + CatalogExpectation(runtime: .claude, agent: .claude, executable: "claude", arguments: []), + CatalogExpectation(runtime: .codex, agent: .codex, executable: "codex", arguments: []), + CatalogExpectation(runtime: .gemini, agent: .gemini, executable: "gemini", arguments: []), + CatalogExpectation(runtime: .cursor, agent: .cursor, executable: "cursor-agent", arguments: []), + CatalogExpectation(runtime: .cline, agent: .cline, executable: "cline", arguments: ["--tui"]), + CatalogExpectation(runtime: .opencode, agent: .opencode, executable: "opencode", arguments: []), + CatalogExpectation(runtime: .copilot, agent: .copilot, executable: "copilot", arguments: []), + CatalogExpectation(runtime: .kimi, agent: .kimi, executable: "kimi", arguments: []), + CatalogExpectation(runtime: .droid, agent: .droid, executable: "droid", arguments: []), + CatalogExpectation(runtime: .amp, agent: .amp, executable: "amp", arguments: []), + CatalogExpectation(runtime: .qoder, agent: .qoder, executable: "qodercli", arguments: []), + CatalogExpectation(runtime: .qwen, agent: .qwen, executable: "qwen", arguments: []), + CatalogExpectation(runtime: .grok, agent: .grok, executable: "grok", arguments: []), + CatalogExpectation(runtime: .pi, agent: .pi, executable: "pi", arguments: []), + CatalogExpectation(runtime: .omp, agent: .pi, executable: "omp", arguments: []), + ] + + #expect(AgentProfileRuntime.allCases.count == expected.count) + for expectation in expected { + let invocation = try AgentRuntimeAdapterRegistry.makeStartInvocation( + AgentStartRequest(runtime: expectation.runtime, intent: .interactive) + ) + #expect(expectation.runtime.agent == expectation.agent) + #expect(invocation.executable == expectation.executable) + #expect(invocation.arguments == expectation.arguments) + } + #expect(AgentRuntimeAdapterRegistry.launchableAgents == DetectedAgent.allCases) + } + + @Test func promptedAndHeadlessStartsUseRuntimeSpecificCLISemantics() throws { + let expected = [ + IntentExpectation( + runtime: .claude, promptedArguments: ["Review this."], headlessArguments: ["-p", "Review this."]), + IntentExpectation( + runtime: .codex, promptedArguments: ["Review this."], headlessArguments: ["exec", "Review this."]), + IntentExpectation( + runtime: .gemini, promptedArguments: ["--prompt-interactive", "Review this."], + headlessArguments: ["--prompt", "Review this."]), + IntentExpectation( + runtime: .cursor, promptedArguments: ["Review this."], headlessArguments: ["--print", "Review this."]), + IntentExpectation( + runtime: .cline, promptedArguments: ["--tui", "Review this."], headlessArguments: ["Review this."]), + IntentExpectation( + runtime: .opencode, promptedArguments: ["--prompt", "Review this."], + headlessArguments: ["run", "Review this."]), + IntentExpectation( + runtime: .copilot, promptedArguments: ["--interactive", "Review this."], + headlessArguments: ["--prompt", "Review this."]), + IntentExpectation( + runtime: .kimi, promptedArguments: ["--prompt", "Review this."], + headlessArguments: ["--print", "--prompt", "Review this."]), + IntentExpectation( + runtime: .droid, promptedArguments: ["Review this."], headlessArguments: ["exec", "Review this."]), + IntentExpectation( + runtime: .qoder, promptedArguments: ["--prompt-interactive", "Review this."], + headlessArguments: ["--print", "Review this."]), + IntentExpectation( + runtime: .qwen, promptedArguments: ["--prompt-interactive", "Review this."], + headlessArguments: ["--prompt", "Review this."]), + IntentExpectation( + runtime: .grok, promptedArguments: ["Review this."], headlessArguments: ["--single", "Review this."]), + IntentExpectation( + runtime: .pi, promptedArguments: ["Review this."], headlessArguments: ["--print", "Review this."]), + IntentExpectation( + runtime: .omp, promptedArguments: ["Review this."], headlessArguments: ["--print", "Review this."]), + ] + + for expectation in expected { + let prompted = try AgentRuntimeAdapterRegistry.makeStartInvocation( + AgentStartRequest(runtime: expectation.runtime, intent: .prompt("Review this.")) + ) + let headless = try AgentRuntimeAdapterRegistry.makeStartInvocation( + AgentStartRequest(runtime: expectation.runtime, intent: .headless("Review this.")) + ) + #expect(prompted.arguments == expectation.promptedArguments) + #expect(headless.arguments == expectation.headlessArguments) + } + + #expect( + throws: AgentRuntimeError.unsupportedStartIntent(.amp, .prompt("Review this.")) + ) { + try AgentRuntimeAdapterRegistry.makeStartInvocation( + AgentStartRequest(runtime: .amp, intent: .prompt("Review this.")) + ) + } + let ampHeadless = try AgentRuntimeAdapterRegistry.makeStartInvocation( + AgentStartRequest(runtime: .amp, intent: .headless("Review this.")) + ) + #expect(ampHeadless.arguments == ["--execute", "Review this."]) + } + + @Test func profileOptionsAreCapabilityGated() throws { + let noModel: Set = [.droid, .amp] + let noReasoning: Set = [.gemini, .cursor, .kimi, .droid] + let unrestricted: Set = [ + .claude, .codex, .gemini, .cursor, .opencode, .copilot, .kimi, .qoder, .qwen, .omp, + ] + let isolated: Set = [ + .claude, .codex, .gemini, .cline, .copilot, .qoder, .qwen, .pi, .omp, + ] + + for runtime in AgentProfileRuntime.allCases { + let adapter = try #require(AgentRuntimeAdapterRegistry.profileAdapter(for: runtime)) + #expect(adapter.supportsModelSelection == !noModel.contains(runtime)) + #expect(adapter.supportsReasoningEffort == !noReasoning.contains(runtime)) + #expect(adapter.supportsUnrestrictedExecution == unrestricted.contains(runtime)) + #expect(adapter.supportsAccountIsolation == isolated.contains(runtime)) + } + } + + @Test func expandedRuntimeOptionsRenderOnlyVerifiedFields() throws { + let configuration = AgentLaunchConfiguration( + model: "model-x", + executionMode: .unrestricted, + reasoningEffort: "high" + ) + let expected: [(AgentProfileRuntime, [String])] = [ + (.gemini, ["--model", "model-x", "--approval-mode", "yolo", "--sandbox=false"]), + (.cursor, ["--model", "model-x", "--yolo", "--sandbox", "disabled"]), + (.cline, ["--model", "model-x", "--thinking", "high", "--tui"]), + (.opencode, ["--model", "model-x", "--variant", "high", "--auto"]), + (.copilot, ["--model", "model-x", "--reasoning-effort", "high", "--allow-all"]), + (.kimi, ["--model", "model-x", "--yolo"]), + (.droid, []), + (.amp, ["--effort", "high"]), + (.qoder, ["--model", "model-x", "--reasoning-effort", "high", "--dangerously-skip-permissions"]), + (.qwen, ["--model", "model-x", "--reasoning-effort", "high", "--approval-mode", "yolo", "--sandbox=false"]), + (.grok, ["--model", "model-x", "--reasoning-effort", "high"]), + (.pi, ["--model", "model-x", "--thinking", "high"]), + (.omp, ["--model", "model-x", "--thinking", "high", "--approval-mode", "yolo"]), + ] + + for (runtime, arguments) in expected { + let invocation = try AgentRuntimeAdapterRegistry.makeStartInvocation( + AgentStartRequest(runtime: runtime, intent: .interactive, configuration: configuration) + ) + #expect(invocation.arguments == arguments) + } + } + + @Test func startAndResumeCapabilitiesAreIndependent() { + for agent in DetectedAgent.allCases { + #expect(AgentRuntimeAdapterRegistry.canStart(agent)) + #expect(AgentRuntimeAdapterRegistry.canResume(agent) == (agent == .claude || agent == .codex)) + } + } + @Test func codexStartBuildsUnrestrictedInvocation() throws { let invocation = try AgentRuntimeAdapterRegistry.makeStartInvocation( AgentStartRequest( diff --git a/supacodeTests/AgentRuntimeAvailabilityProbeTests.swift b/supacodeTests/AgentRuntimeAvailabilityProbeTests.swift index be9886ba..a3f61e1e 100644 --- a/supacodeTests/AgentRuntimeAvailabilityProbeTests.swift +++ b/supacodeTests/AgentRuntimeAvailabilityProbeTests.swift @@ -50,7 +50,7 @@ struct AgentRuntimeAvailabilityProbeTests { await AgentRuntimeAvailabilityProbe.refresh() - #expect(probeCalls.value == 1) + #expect(probeCalls.value == AgentProfileRuntime.allCases.count - 1) #expect(probed[.codex] == true) #expect(probed[.claude] == nil) } diff --git a/supacodeTests/AgentSessionProfileTests.swift b/supacodeTests/AgentSessionProfileTests.swift index 4ec6b741..84e91cce 100644 --- a/supacodeTests/AgentSessionProfileTests.swift +++ b/supacodeTests/AgentSessionProfileTests.swift @@ -53,6 +53,51 @@ struct AgentSessionProfileTests { ) } + @Test func additionalAccountBoundRuntimesDefineRootedSessionLayouts() throws { + let root = URL(fileURLWithPath: "/Users/me/.prowl/agent-profiles/ABC", isDirectory: true) + let cwd = URL(fileURLWithPath: "/tmp/repo", isDirectory: true) + let uuid = "3f19a50a-2bd5-49b0-a071-1f59970ebcdf" + + let gemini = AgentSessionProfile.profile(for: .gemini) + let geminiRoots = try #require(gemini.rootedCandidateRoots?(root, cwd, now, now)) + #expect( + geminiRoots.last?.path + == "\(root.path)/tmp/b6fe87a9b936bea650481980f473639b9dd1934ddab7f48221cd6b9ab5ffc1f4/chats") + #expect( + gemini.rootedParsePath?("\(root.path)/tmp/project/chats/session-2026-08-01-3f19a50a.jsonl", root)?.id + == "3f19a50a" + ) + + let cline = AgentSessionProfile.profile(for: .cline) + #expect(cline.rootedCandidateRoots?(root, cwd, now, now).map(\.path) == ["\(root.path)/tasks"]) + #expect(cline.rootedParsePath?("\(root.path)/tasks/1783800000000/events.jsonl", root)?.id == "1783800000000") + let clineRootWithMarkerAncestor = URL(fileURLWithPath: "/Users/tasks/agent-profile/data", isDirectory: true) + #expect( + cline.rootedParsePath?( + "\(clineRootWithMarkerAncestor.path)/tasks/1783800000001/events.jsonl", + clineRootWithMarkerAncestor + )?.id == "1783800000001" + ) + + let piProfile = AgentSessionProfile.profile(for: .pi) + #expect(piProfile.rootedCandidateRoots?(root, cwd, now, now).map(\.path) == ["\(root.path)/sessions/--tmp-repo--"]) + #expect(piProfile.rootedParsePath?("\(root.path)/sessions/--tmp-repo--/\(uuid).jsonl", root)?.id == uuid) + + let copilot = AgentSessionProfile.profile(for: .copilot) + #expect(copilot.rootedCandidateRoots?(root, cwd, now, now).map(\.path) == ["\(root.path)/session-state"]) + #expect(copilot.rootedParsePath?("\(root.path)/session-state/\(uuid)/events.jsonl", root)?.id == uuid) + #expect(copilot.rootedPIDKeyedSession != nil) + + let qoder = AgentSessionProfile.profile(for: .qoder) + #expect(qoder.rootedCandidateRoots?(root, cwd, now, now).map(\.path) == ["\(root.path)/projects/-tmp-repo"]) + #expect(qoder.rootedParsePath?("\(root.path)/projects/-tmp-repo/\(uuid).jsonl", root)?.id == uuid) + + let qwen = AgentSessionProfile.profile(for: .qwen) + #expect(qwen.rootedCandidateRoots?(root, cwd, now, now).map(\.path) == ["\(root.path)/projects/-tmp-repo/chats"]) + #expect(qwen.rootedParsePath?("\(root.path)/projects/-tmp-repo/chats/\(uuid).jsonl", root)?.id == uuid) + #expect(qwen.rootedPIDKeyedSession != nil) + } + // MARK: - Working-directory encoders @Test func claudeRootSanitizesEveryNonAlphanumericCharacter() { diff --git a/supacodeTests/HandoffHudFeatureTests.swift b/supacodeTests/HandoffHudFeatureTests.swift index bc09a147..e37b66b0 100644 --- a/supacodeTests/HandoffHudFeatureTests.swift +++ b/supacodeTests/HandoffHudFeatureTests.swift @@ -131,7 +131,7 @@ struct HandoffHudFeatureTests { ) let agentKinds = state.targets.compactMap(\.agent) - #expect(agentKinds == AgentRuntimeAdapterRegistry.launchableAgents) + #expect(agentKinds == [.claude, .codex]) #expect(state.targets.last?.kind == .briefOnly) let codexTarget = try #require(state.targets.first { $0.agent == .codex }) #expect(codexTarget.isCurrentAgent) -- 2.51.2