diff --git a/ProwlCLI/Commands/CreateCommand.swift b/ProwlCLI/Commands/CreateCommand.swift index 778de65a..71ab83e7 100644 --- a/ProwlCLI/Commands/CreateCommand.swift +++ b/ProwlCLI/Commands/CreateCommand.swift @@ -15,6 +15,51 @@ struct CreateCommand: ParsableCommand { ) } +struct CreateLaunchOptions: ParsableArguments { + @Option(name: .long, help: "Agent Profile name or UUID to launch in the new resource.") + var profile: String? + + @Option(name: .long, help: "Kickoff prompt source; the only supported value is '-' for stdin.") + var prompt: String? + + @Flag(name: .long, help: "Create the profile launch without changing the current selection or focus.") + var background = false + + func resolve() throws -> (launch: CreateLaunchInput?, background: Bool) { + guard let profile else { + if prompt != nil || background { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "--prompt and --background require --profile." + ) + } + return (nil, false) + } + guard !profile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ExitError(code: CLIErrorCode.invalidArgument, message: "--profile must not be empty.") + } + guard let prompt else { + return (CreateLaunchInput(profile: profile), background) + } + guard prompt == "-" else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "--prompt accepts only '-' to read the kickoff prompt from stdin." + ) + } + guard + let data = try? FileHandle.standardInput.readToEnd(), + let text = String(data: data, encoding: .utf8) + else { + throw ExitError(code: CLIErrorCode.emptyInput, message: "Failed to read the kickoff prompt from stdin.") + } + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ExitError(code: CLIErrorCode.emptyInput, message: "The kickoff prompt is empty.") + } + return (CreateLaunchInput(profile: profile, prompt: text), background) + } +} + struct CreateTabCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "tab", @@ -25,6 +70,7 @@ struct CreateTabCommand: ParsableCommand { var worktree: String? @OptionGroup var selector: LifecycleSelectorOptions + @OptionGroup var launchOptions: CreateLaunchOptions @OptionGroup var options: GlobalOptions @Option(name: .long, help: "Working directory for the new tab.") @@ -41,10 +87,13 @@ struct CreateTabCommand: ParsableCommand { } func makeInput() throws -> CreateInput { - CreateInput( + let resolvedLaunch = try launchOptions.resolve() + return CreateInput( resource: .tab, selector: try selector.resolveWorktree(positionalTarget: worktree), - path: normalizedPath() + path: normalizedPath(), + launch: resolvedLaunch.launch, + background: resolvedLaunch.background ) } @@ -83,6 +132,7 @@ struct CreatePaneCommand: ParsableCommand { var anchor: String? @OptionGroup var selector: LifecycleSelectorOptions + @OptionGroup var launchOptions: CreateLaunchOptions @OptionGroup var options: GlobalOptions @Option(name: .long, help: "Split direction: right, left, up, or down.") @@ -99,10 +149,13 @@ struct CreatePaneCommand: ParsableCommand { } func makeInput() throws -> CreateInput { - CreateInput( + let resolvedLaunch = try launchOptions.resolve() + return CreateInput( resource: .pane, selector: try selector.resolvePane(positionalTarget: anchor), - direction: direction.value + direction: direction.value, + launch: resolvedLaunch.launch, + background: resolvedLaunch.background ) } } diff --git a/ProwlCLI/Commands/ProfilesCommand.swift b/ProwlCLI/Commands/ProfilesCommand.swift new file mode 100644 index 00000000..25214d2b --- /dev/null +++ b/ProwlCLI/Commands/ProfilesCommand.swift @@ -0,0 +1,29 @@ +import ArgumentParser +import ProwlCLIShared + +struct ProfilesCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "profiles", + abstract: "Inspect configured Agent Profiles.", + subcommands: [ProfilesListCommand.self] + ) +} + +struct ProfilesListCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List enabled and disabled Agent Profiles with runtime availability." + ) + + @OptionGroup var options: GlobalOptions + + mutating func run() throws { + try CLIExecution.run(command: "profiles", output: options.outputMode, colorEnabled: options.colorEnabled) { + let envelope = CommandEnvelope( + output: options.outputMode, + command: .profiles(ProfilesInput()) + ) + try CLIRunner.execute(envelope) + } + } +} diff --git a/ProwlCLI/Commands/ProwlCommand.swift b/ProwlCLI/Commands/ProwlCommand.swift index eab5ad4a..3c7c838a 100644 --- a/ProwlCLI/Commands/ProwlCommand.swift +++ b/ProwlCLI/Commands/ProwlCommand.swift @@ -14,6 +14,7 @@ struct ProwlCommand: ParsableCommand { OpenCommand.self, ListCommand.self, AgentsCommand.self, + ProfilesCommand.self, FocusCommand.self, SendCommand.self, KeyCommand.self, diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift index aa702470..15d06830 100644 --- a/ProwlCLI/Output/OutputRenderer.swift +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -76,6 +76,14 @@ enum OutputRenderer { return } + if response.command == "profiles", + let data = response.data, + let payload = try? data.decode(as: ProfilesCommandPayload.self) + { + print(renderProfiles(payload)) + return + } + if response.command == "focus", let data = response.data, let payload = try? data.decode(as: FocusCommandPayload.self) @@ -267,6 +275,24 @@ enum OutputRenderer { }.joined(separator: "\n") } + private static func renderProfiles(_ payload: ProfilesCommandPayload) -> String { + guard !payload.profiles.isEmpty else { return "No Agent Profiles found." } + return payload.profiles.map { profile in + let enabled = profile.enabled ? "enabled".green : "disabled".dim + let availability: String + switch profile.availability.status { + case .available: + availability = "available".green + case .unavailable: + availability = "unavailable".yellow + case .unknown: + availability = "unknown".dim + } + let reason = profile.availability.reason.map { " \($0.dim)" } ?? "" + return "\(profile.name.bold) \(profile.runtime) \(enabled) \(availability) \(profile.id.dim)\(reason)" + }.joined(separator: "\n") + } + private static func renderAgentsRead(_ payload: AgentReadCommandPayload) { if let data = agentReadResultOnlyData(payload) { FileHandle.standardOutput.write(data) @@ -355,8 +381,15 @@ enum OutputRenderer { switch payload.resource { case .tab: - return "\(verb) tab \(projectName.cyan.bold)\(":".dim)\(wt.name) → \(tab.title.yellow)" - + " \(tab.id.dim)\n \("pane:".dim) \(pane.title.green) \(pane.id.dim)" + var lines = [ + "\(verb) tab \(projectName.cyan.bold)\(":".dim)\(wt.name) → \(tab.title.yellow)" + + " \(tab.id.dim)", + " \("pane:".dim) \(pane.title.green) \(pane.id.dim)", + ] + if let launch = payload.launch { + lines.append(" \("profile:".dim) \(launch.profileName) \("agent:".dim) \(launch.agent)") + } + return lines.joined(separator: "\n") case .pane: var lines = [ "\(verb) pane \(projectName.cyan.bold)\(":".dim)\(wt.name) → \(pane.title.green)" @@ -365,6 +398,9 @@ enum OutputRenderer { if let anchor = payload.anchor, let direction = payload.direction { lines.append(" \("anchor:".dim) \(anchor.pane.id.dim) \("direction:".dim) \(direction.rawValue)") } + if let launch = payload.launch { + lines.append(" \("profile:".dim) \(launch.profileName) \("agent:".dim) \(launch.agent)") + } return lines.joined(separator: "\n") } } diff --git a/ProwlCLIContracts/Resources/cli-output-schema.json b/ProwlCLIContracts/Resources/cli-output-schema.json index a2f9177c..12e971c8 100644 --- a/ProwlCLIContracts/Resources/cli-output-schema.json +++ b/ProwlCLIContracts/Resources/cli-output-schema.json @@ -16,6 +16,9 @@ { "$ref": "#/$defs/agentsReadResponse" }, + { + "$ref": "#/$defs/profilesResponse" + }, { "$ref": "#/$defs/focusResponse" }, @@ -173,6 +176,29 @@ } } }, + "lifecycleLaunch": { + "type": "object", + "additionalProperties": false, + "required": [ + "profile_id", + "profile_name", + "agent" + ], + "properties": { + "profile_id": { + "type": "string", + "format": "uuid" + }, + "profile_name": { + "type": "string", + "minLength": 1 + }, + "agent": { + "type": "string", + "minLength": 1 + } + } + }, "createData": { "oneOf": [ { @@ -186,6 +212,9 @@ "resource": { "const": "tab" }, + "launch": { + "$ref": "#/$defs/lifecycleLaunch" + }, "target": { "$ref": "#/$defs/target" } @@ -215,6 +244,9 @@ "down" ] }, + "launch": { + "$ref": "#/$defs/lifecycleLaunch" + }, "target": { "$ref": "#/$defs/target" } @@ -591,6 +623,77 @@ } } }, + "profileAvailability": { + "type": "object", + "additionalProperties": false, + "required": [ + "status" + ], + "properties": { + "status": { + "enum": [ + "available", + "unavailable", + "unknown" + ] + }, + "reason": { + "type": "string", + "minLength": 1 + } + } + }, + "profile": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "enabled", + "runtime", + "availability" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "enabled": { + "type": "boolean" + }, + "runtime": { + "type": "string", + "minLength": 1 + }, + "availability": { + "$ref": "#/$defs/profileAvailability" + } + } + }, + "profilesData": { + "type": "object", + "additionalProperties": false, + "required": [ + "count", + "profiles" + ], + "properties": { + "count": { + "type": "integer", + "minimum": 0 + }, + "profiles": { + "type": "array", + "items": { + "$ref": "#/$defs/profile" + } + } + } + }, "agentReadSession": { "type": "object", "additionalProperties": false, @@ -1557,6 +1660,58 @@ } ] }, + "profilesResponse": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "data" + ], + "properties": { + "ok": { + "const": true + }, + "command": { + "const": "profiles" + }, + "schema_version": { + "const": "prowl.cli.profiles.v1" + }, + "data": { + "$ref": "#/$defs/profilesData" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "error" + ], + "properties": { + "ok": { + "const": false + }, + "command": { + "const": "profiles" + }, + "schema_version": { + "const": "prowl.cli.profiles.v1" + }, + "error": { + "$ref": "#/$defs/error" + } + } + } + ] + }, "agentsReadResponse": { "oneOf": [ { diff --git a/ProwlCLITests/CreateCommandParsingTests.swift b/ProwlCLITests/CreateCommandParsingTests.swift index 6ce1e4e7..c8e666fe 100644 --- a/ProwlCLITests/CreateCommandParsingTests.swift +++ b/ProwlCLITests/CreateCommandParsingTests.swift @@ -39,4 +39,36 @@ final class CreateCommandParsingTests: XCTestCase { XCTAssertThrowsError(try mixed.makeInput()) XCTAssertThrowsError(try bareNumber.makeInput()) } + + func testTabParsesProfileLaunchAndBackground() throws { + let command = try CreateTabCommand.parse(["App", "--profile", "Reviewer", "--background"]) + let input = try command.makeInput() + + XCTAssertEqual(input.launch, CreateLaunchInput(profile: "Reviewer")) + XCTAssertTrue(input.background) + } + + func testPaneParsesProfileLaunchAndBackground() throws { + let command = try CreatePaneCommand.parse([ + "p12", "--direction", "right", "--profile", "Reviewer", "--background", + ]) + let input = try command.makeInput() + + XCTAssertEqual(input.launch, CreateLaunchInput(profile: "Reviewer")) + XCTAssertTrue(input.background) + } + + func testPromptAcceptsOnlyTheStdinSentinel() throws { + let inline = try CreateTabCommand.parse(["App", "--profile", "Reviewer", "--prompt", "inline"]) + + XCTAssertThrowsError(try inline.makeInput()) + } + + func testPromptAndBackgroundRequireAProfile() throws { + let prompt = try CreateTabCommand.parse(["App", "--prompt", "-"]) + let background = try CreatePaneCommand.parse(["p12", "--direction", "right", "--background"]) + + XCTAssertThrowsError(try prompt.makeInput()) + XCTAssertThrowsError(try background.makeInput()) + } } diff --git a/ProwlCLITests/ProfilesCommandParsingTests.swift b/ProwlCLITests/ProfilesCommandParsingTests.swift new file mode 100644 index 00000000..60dfc1fb --- /dev/null +++ b/ProwlCLITests/ProfilesCommandParsingTests.swift @@ -0,0 +1,15 @@ +import XCTest + +@testable import prowl + +final class ProfilesCommandParsingTests: XCTestCase { + func testListParsesAsTheProfilesLeaf() throws { + _ = try ProfilesListCommand.parse(["--json"]) + } + + func testRootRoutesProfilesListToTheListLeaf() throws { + let command = try ProwlCommand.parseAsRoot(["profiles", "list"]) + + XCTAssertTrue(command is ProfilesListCommand) + } +} diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index cc8b1738..9b991480 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -426,6 +426,146 @@ final class ProwlCLIIntegrationTests: XCTestCase { } } + func testCreateTabProfileRoundTripsOverSocket() throws { + let socketPath = temporarySocketPath(suffix: "create-tab-profile") + let launch = LifecycleCommandLaunch( + profileID: UUID().uuidString, + profileName: "Reviewer", + agent: "claude" + ) + let response = try CommandResponse( + ok: true, + command: "create", + schemaVersion: "prowl.cli.create.v1", + data: RawJSON(encoding: makeLifecyclePayload(resource: .tab, launch: launch)) + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["create", "tab", "App", "--profile", launch.profileID, "--background", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .create(let input) = envelope.command { + XCTAssertEqual(input.launch, CreateLaunchInput(profile: launch.profileID)) + XCTAssertTrue(input.background) + } else { + XCTFail("Expected create command envelope") + } + } + + func testCreatePaneProfilePromptRoundTripsOverSocket() throws { + let socketPath = temporarySocketPath(suffix: "create-pane-profile") + let launch = LifecycleCommandLaunch( + profileID: UUID().uuidString, + profileName: "Reviewer", + agent: "claude" + ) + let response = try CommandResponse( + ok: true, + command: "create", + schemaVersion: "prowl.cli.create.v1", + data: RawJSON( + encoding: makeLifecyclePayload( + resource: .pane, + anchor: makeTabTarget(paneID: "anchor-pane"), + direction: .right, + launch: launch + ) + ) + ) + let prompt = "Review the current diff and report only actionable findings.\n" + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: [ + "create", "pane", "p12", "--direction", "right", "--profile", "Reviewer", + "--prompt", "-", "--background", "--json", + ], + stdinData: Data(prompt.utf8) + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .create(let input) = envelope.command { + XCTAssertEqual(input.launch, CreateLaunchInput(profile: "Reviewer", prompt: prompt)) + XCTAssertTrue(input.background) + } else { + XCTFail("Expected create command envelope") + } + let output = try jsonObject(from: result.stdout) + let data = try XCTUnwrap(output["data"] as? [String: Any]) + let outputLaunch = try XCTUnwrap(data["launch"] as? [String: Any]) + XCTAssertEqual(outputLaunch["profile_name"] as? String, "Reviewer") + XCTAssertEqual(outputLaunch["agent"] as? String, "claude") + } + + func testProfilesListRoundTripsOverSocket() throws { + let socketPath = temporarySocketPath(suffix: "profiles-list") + let profile = ProfilesCommandProfile( + id: UUID().uuidString, + name: "Reviewer", + enabled: true, + runtime: "claude", + availability: ProfilesCommandAvailability(status: .available) + ) + let response = try CommandResponse( + ok: true, + command: "profiles", + schemaVersion: "prowl.cli.profiles.v1", + data: RawJSON(encoding: ProfilesCommandPayload(count: 1, profiles: [profile])) + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["profiles", "list", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .profiles = envelope.command { + // Expected read-only profile snapshot command. + } else { + XCTFail("Expected profiles command envelope") + } + let output = try jsonObject(from: result.stdout) + XCTAssertEqual(output["schema_version"] as? String, "prowl.cli.profiles.v1") + } + + func testProfilesListRendersAvailability() throws { + let socketPath = temporarySocketPath(suffix: "profiles-list-human") + let profile = ProfilesCommandProfile( + id: UUID().uuidString, + name: "Reviewer", + enabled: false, + runtime: "claude", + availability: ProfilesCommandAvailability( + status: .unknown, + reason: "Availability check has not completed" + ) + ) + let response = try CommandResponse( + ok: true, + command: "profiles", + schemaVersion: "prowl.cli.profiles.v1", + data: RawJSON(encoding: ProfilesCommandPayload(count: 1, profiles: [profile])) + ) + + let (_, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["profiles", "list", "--no-color"] + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertTrue(result.stdout.contains("Reviewer claude disabled unknown"), result.stdout) + XCTAssertTrue(result.stdout.contains("Availability check has not completed"), result.stdout) + } + func testCreatePaneCommandRendersAnchorAndDirection() throws { let socketPath = temporarySocketPath(suffix: "create-pane-human") let response = try CommandResponse( @@ -2220,18 +2360,25 @@ final class ProwlCLIIntegrationTests: XCTestCase { private func runWithMockServer( socketPath: String, response: CommandResponse, - args: [String] + args: [String], + stdinData: Data? = nil ) throws -> (Data, CommandResult) { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] let responseData = try encoder.encode(response) - return try runWithMockServer(socketPath: socketPath, responseData: responseData, args: args) + return try runWithMockServer( + socketPath: socketPath, + responseData: responseData, + args: args, + stdinData: stdinData + ) } private func runWithMockServer( socketPath: String, responseData: Data, - args: [String] + args: [String], + stdinData: Data? = nil ) throws -> (Data, CommandResult) { try assertResponseMatchesSchema(responseData) let server = try MockSocketServer(socketPath: socketPath, responseData: responseData) @@ -2240,7 +2387,8 @@ final class ProwlCLIIntegrationTests: XCTestCase { let result = try runProwl( args: args, - environment: [ProwlSocket.environmentKey: socketPath] + environment: [ProwlSocket.environmentKey: socketPath], + stdinData: stdinData ) let requestData = try XCTUnwrap(server.waitForRequest(timeout: 2.0), "No request received by mock server") @@ -2264,12 +2412,14 @@ final class ProwlCLIIntegrationTests: XCTestCase { private func makeLifecyclePayload( resource: LifecycleResource, anchor: TabTarget? = nil, - direction: CreatePaneDirection? = nil + direction: CreatePaneDirection? = nil, + launch: LifecycleCommandLaunch? = nil ) -> LifecycleCommandPayload { LifecycleCommandPayload( resource: resource, anchor: anchor, direction: direction, + launch: launch, target: makeTabTarget() ) } diff --git a/docs-ai/013-prowl-cli/contracts/create.md b/docs-ai/013-prowl-cli/contracts/create.md index aecdbe88..32b8bb85 100644 --- a/docs-ai/013-prowl-cli/contracts/create.md +++ b/docs-ai/013-prowl-cli/contracts/create.md @@ -9,10 +9,10 @@ Current version: `prowl.cli.create.v1`. ## Input ```bash -prowl create tab [--path ] [--json] -prowl create tab --worktree [--path ] [--json] -prowl create pane --direction [--json] -prowl create pane --pane --direction [--json] +prowl create tab [--path ] [--profile [--prompt -] [--background]] [--json] +prowl create tab --worktree [--path ] [--profile [--prompt -] [--background]] [--json] +prowl create pane --direction [--profile [--prompt -] [--background]] [--json] +prowl create pane --pane --direction [--profile [--prompt -] [--background]] [--json] ``` `create tab` requires exactly one positional worktree reference or `--worktree`. `--pane`, @@ -26,11 +26,22 @@ bare numeric handles, `--path`, and positional-plus-flag targeting. Public `up` terminal layer's internal top direction. The operation resolves the anchor directly; it never focuses a different pane as an intermediate targeting step. -The new pane inherits the anchor's working directory and split surface configuration. On -success it becomes the focused pane in the anchor tab, and Prowl selects the anchor's -worktree and tab exactly as `create tab` selects the target worktree — an anchor in another -tab or worktree therefore brings that tab into view. A background placement is not part of -V1; it belongs to the profile launch placement work (063-A2). +Without `--profile`, the new pane inherits the anchor's working directory and split surface +configuration, becomes focused, and Prowl selects the anchor's worktree and tab exactly as +`create tab` selects the target worktree. + +`--profile` launches one enabled Agent Profile in the new resource. Lookup tries a matching +UUID first, then an exact name among enabled profiles; duplicate enabled names fail with +`PROFILE_NOT_UNIQUE`, while missing or disabled profiles fail with `PROFILE_NOT_FOUND`. +Runtime availability is advisory and never blocks launch. `--prompt` accepts only `-` and +reads a non-empty kickoff prompt from stdin; `--prompt` and `--background` require +`--profile`. + +Foreground profile launches select the destination worktree/tab and focus the returned pane. +A background tab is created without changing the selected worktree, tab, or pane. A +background split is inserted beside the resolved anchor without focusing it and without +selecting a hidden anchor's worktree/tab. Split creation always targets the pre-resolved +anchor directly and inherits its working directory; `--path` remains tab-only. ## Success: tab @@ -80,8 +91,22 @@ before the split: its `focused` / `selected` flags describe the pre-split state `true` alongside the same flags on `target`. UUID fields are the automation-safe output of this command. +A successful Profile launch adds the following field to either tab or pane data; ordinary +shell creation omits it: + +```json +{ + "launch": { + "profile_id": "…", + "profile_name": "Reviewer", + "agent": "claude" + } +} +``` + ## Errors -`INVALID_ARGUMENT`, `TARGET_NOT_FOUND`, `TARGET_NOT_UNIQUE`, `PATH_NOT_ALLOWED`, and -`CREATE_FAILED` use the common error envelope in +`INVALID_ARGUMENT`, `EMPTY_INPUT`, `TARGET_NOT_FOUND`, `TARGET_NOT_UNIQUE`, +`PROFILE_NOT_FOUND`, `PROFILE_NOT_UNIQUE`, `PATH_NOT_ALLOWED`, and `CREATE_FAILED` use the +common error envelope in [`cli-output-schema.json`](../../../ProwlCLIContracts/Resources/cli-output-schema.json). diff --git a/docs-ai/013-prowl-cli/contracts/input.md b/docs-ai/013-prowl-cli/contracts/input.md index 20f60680..ee60ddf6 100644 --- a/docs-ai/013-prowl-cli/contracts/input.md +++ b/docs-ai/013-prowl-cli/contracts/input.md @@ -9,7 +9,7 @@ and the executable [schema bundle](schema.md). ```text prowl [path] prowl open [path] -prowl list | agents | focus | read | send | key | handoff | create | close +prowl list | agents | profiles | focus | read | send | key | handoff | create | close ``` Bare path forms (`/`, `./`, `../`, `~/`, `file://`, `.`, `..`) enter `open`. @@ -36,10 +36,11 @@ succeeds. ## Lifecycle grammar ```bash -prowl create tab [--path ] -prowl create tab --worktree [--path ] -prowl create pane --direction -prowl create pane --pane --direction +prowl create tab [--path ] [--profile [--prompt -] [--background]] +prowl create tab --worktree [--path ] [--profile [--prompt -] [--background]] +prowl create pane --direction [--profile [--prompt -] [--background]] +prowl create pane --pane --direction [--profile [--prompt -] [--background]] +prowl profiles list prowl close [--force] prowl close --pane [--force] prowl close --tab [--force] @@ -47,7 +48,9 @@ prowl close --tab [--force] `create tab` requires a worktree-only target. `create pane` requires a pane-only anchor and explicit direction; it rejects `--target`, `--worktree`, `--tab`, bare -numbers, and focus fallback. `close` requires a pane-or-tab-only target and rejects +numbers, and focus fallback. `--prompt` accepts only `-`, reads non-empty UTF-8 stdin, +and requires `--profile`; `--background` also requires `--profile`. `profiles list` +is a read-only global snapshot and accepts no target. `close` requires a pane-or-tab-only target and rejects `--target`, `--worktree`, bare-number positions, and focus fallback. See [create.md](create.md) and [close.md](close.md). @@ -60,7 +63,7 @@ stderr warning; new automation must use the lifecycle grammar above. - `agents read ` is a pane-only semantic snapshot, no selectors or focus fallback. - `handoff` defaults to the calling pane, not UI focus. -- `list` and `agents` are global discovery commands with no target selector. +- `list`, `agents`, and `profiles list` are global discovery commands with no target selector. - `open` consumes a path rather than a target. ## Transport request model diff --git a/docs-ai/013-prowl-cli/contracts/profiles.md b/docs-ai/013-prowl-cli/contracts/profiles.md new file mode 100644 index 00000000..d96156a4 --- /dev/null +++ b/docs-ai/013-prowl-cli/contracts/profiles.md @@ -0,0 +1,67 @@ +# `prowl profiles` Contract + +## Status + +Current version: `prowl.cli.profiles.v1`. + +`profiles list` is the read-only discovery surface for CLI-driven Profile launches. + +## Input + +```bash +prowl profiles list [--json] +``` + +The command accepts no target. It returns every persisted Agent Profile, including disabled +profiles, in Settings list order. It does not trigger or wait for an availability refresh. + +## Success + +```json +{ + "ok": true, + "command": "profiles", + "schema_version": "prowl.cli.profiles.v1", + "data": { + "count": 2, + "profiles": [ + { + "id": "…", + "name": "Reviewer", + "enabled": true, + "runtime": "claude", + "availability": { + "status": "available" + } + }, + { + "id": "…", + "name": "Offline", + "enabled": false, + "runtime": "codex", + "availability": { + "status": "unavailable", + "reason": "Codex is not on your shell's PATH" + } + } + ] + } +} +``` + +- `id` is the canonical selector for `create tab|pane --profile`. +- `enabled` is launch admission. Disabled profiles remain discoverable but cannot launch. +- `runtime` is the stable `AgentProfileRuntime` machine token. +- `availability.status` is `available`, `unavailable`, or `unknown`. It reflects only the + current login-shell executable probe: positive and negative answers are authoritative; + `unknown` means the asynchronous probe has not completed. Runtime-home heuristics used by + the UI and one-time Profile seeding are deliberately not part of this contract. +- `availability.reason` is optional human-readable context. Automation must branch on + `status`, not parse this message. + +Availability is advisory and never controls launch admission. + +## Errors + +`PROFILES_FAILED` uses the common error envelope in +[`cli-output-schema.json`](../../../ProwlCLIContracts/Resources/cli-output-schema.json). diff --git a/docs-ai/013-prowl-cli/contracts/schema.md b/docs-ai/013-prowl-cli/contracts/schema.md index 3296016a..7ce6051e 100644 --- a/docs-ai/013-prowl-cli/contracts/schema.md +++ b/docs-ai/013-prowl-cli/contracts/schema.md @@ -15,6 +15,7 @@ The bundle has one versioned success-or-error response schema for every wire com | `list` | `#/$defs/listResponse` | | `agents` | `#/$defs/agentsResponse` | | `agents.read` | `#/$defs/agentsReadResponse` | +| `profiles` | `#/$defs/profilesResponse` | | `focus` | `#/$defs/focusResponse` | | `send` | `#/$defs/sendResponse` | | `key` | `#/$defs/keyResponse` | diff --git a/docs-ai/013-prowl-cli/contracts/targeting.md b/docs-ai/013-prowl-cli/contracts/targeting.md index a357a2f3..0616f85d 100644 --- a/docs-ai/013-prowl-cli/contracts/targeting.md +++ b/docs-ai/013-prowl-cli/contracts/targeting.md @@ -58,12 +58,16 @@ not rely on a focus fallback. Lifecycle commands intentionally do not use generic worktree projection: ```text -create tab | --worktree -create pane | --pane +create tab [--profile [--prompt -] [--background]] +create tab --worktree [--profile [--prompt -] [--background]] +create pane --direction [--profile [--prompt -] [--background]] +create pane --pane --direction [--profile [--prompt -] [--background]] close | --pane | --tab ``` `create pane` requires `--direction right|left|up|down`; it resolves its anchor directly and rejects `--target`, `--worktree`, `--tab`, bare-number anchors, and -mixed positional/flag targeting. `close` rejects `--target`, `--worktree`, +mixed positional/flag targeting. Profile launch does not change selector resolution: +foreground placement selects only after the explicit target is resolved and creation +succeeds; `--background` preserves current selection. `close` rejects `--target`, `--worktree`, bare-number positionals, UI-focus fallback, and mixed positional/flag targeting. diff --git a/docs-ai/063-agent-workflows/000-plan.md b/docs-ai/063-agent-workflows/000-plan.md index 2d2e21e3..fd89b033 100644 --- a/docs-ai/063-agent-workflows/000-plan.md +++ b/docs-ai/063-agent-workflows/000-plan.md @@ -582,11 +582,9 @@ attaches hooks through A2's launch boundary. - GUI workflow editor (V2): Yams does not preserve comments/formatting on re-serialization; two-way sync needs either a comment-preserving writer or a "managed file" policy. -- Exact Swift interface shapes for the split primitive, the launch boundary, and the - CLI additions of A1/A2, plus the test strategy — final design round. - ## Amendments - Updated 2026-08-22: Shipped C0 with the Agents sidebar group, Profiles page, and Command Line Tool page; Workflows remains deferred to D1 — see [002-settings-agents-group.md](002-settings-agents-group.md). - Updated 2026-08-22: Implemented A1 with the direct anchored split primitive and schema-governed `prowl create pane` command — see [003-cli-create-pane.md](003-cli-create-pane.md). - Updated 2026-08-22: Implemented A1b — `PROWL_PANE_ID` in every pane's environment, manual identity section, and the `prowl-cli` skill rewritten around it — see [004-pane-identity-env.md](004-pane-identity-env.md). +- Updated 2026-08-22: Implemented A2 with the typed Profile launch boundary, prompted/background `create tab|pane`, and `profiles list`; the final A1/A2 Swift interface question is resolved — see [005-cli-profile-launch.md](005-cli-profile-launch.md). diff --git a/docs-ai/063-agent-workflows/005-cli-profile-launch.md b/docs-ai/063-agent-workflows/005-cli-profile-launch.md new file mode 100644 index 00000000..263d89aa --- /dev/null +++ b/docs-ai/063-agent-workflows/005-cli-profile-launch.md @@ -0,0 +1,37 @@ +# 063.005 — Profile Launch Boundary and CLI (A2) + +## Context + +Release R1 already had deterministic anchored split creation (A1) and per-pane identity (A1b), but Agent Profile launches still depended on mutable focused placement, returned only an optional pane UUID, and always used an interactive start. CLI orchestration could create shells but could not resolve Profiles, seed a runtime-specific prompt, preserve background selection, or synchronously capture the launched tab and pane. The future 064-S3 hook work also needed one launch-scoped invocation/environment seam rather than a second terminal path. + +## Change + +- `AgentProfileLaunchPlanner.plan(for:intent:homeBaseDirectory:)` now forwards `.interactive` (default), `.prompt`, or `.headless` to the runtime adapter; Amp continues to reject interactive seeded prompts in its adapter. +- `AgentProfileLaunchRequest` carries the compiled plan, per-launch tab/split placement, explicit split anchor, direction, background behavior, working-directory override, and title. `WorktreeTerminalState.launchAgentProfile(_:)` returns `Result` with exact tab and pane identities. +- Explicit split requests use A1's anchored primitive and never fall back to a tab. The existing plan-only state method and `TerminalClient.Command.launchAgentProfile` remain compatibility wrappers: menu/palette launches retain split-to-tab fallback and `agentProfileLaunched` / `agentProfileLaunchFailed` events. +- `TerminalClient` adds a synchronous result-returning Profile launch closure. The CLI provider plans once, launches through the same terminal boundary, and resolves the returned pane without using focus as targeting input. +- `prowl create tab|pane` accepts `--profile `, the stdin-only `--prompt -`, and Profile-only `--background`. Enabled Profile lookup is UUID first, then exact unique name; disabled Profiles are not launchable. `prowl.cli.create.v1` adds optional `launch { profile_id, profile_name, agent }`. +- `prowl profiles list` returns every persisted Profile in Settings order under `prowl.cli.profiles.v1`, including `enabled`, runtime, and shell-probe-only availability (`available` / `unavailable` / `unknown`). Runtime-home heuristics remain internal to UI warnings and one-time seeding. +- Parser, wire types, handlers, executable schema, contracts, manual, Agent Profiles guide, and bundled `prowl-cli` skill ship together. The skill includes the explicit reviewer-beside-self recipe and captures `.data.target.pane.id`. + +## Decisions + +- Background is explicit for both placements. A background tab preserves the selected worktree/tab/pane; a background split inserts beside the resolved anchor with `focusing: false` and never selects a hidden anchor's worktree or tab. +- Foreground pane launch resolves and splits the anchor before changing UI selection. `anchor` in the response remains the pre-split snapshot. +- Availability is advisory and never blocks launch. The CLI contract exposes only login-shell probe truth; the default-home existence heuristic is not reliable enough for automation. +- Disabled Profiles remain discoverable but do not participate in name uniqueness and cannot launch. A matching disabled UUID returns `PROFILE_NOT_FOUND` with an explicit message. +- CLI/workflow launches use the synchronous closure and do not emit the menu/palette recommendation-memory events. The adapter-rendered invocation and surface environment inside the compiled plan remain the clean 064-S3 seam; A2 injects no hooks. + +## Verification + +- TDD RED runs failed on the absent planner intent, launch request/result, CLI flags/wire fields, Profile lookup, profiles command, and executable schema; the corresponding focused suites were then driven GREEN. +- Focused app suites (`WorktreeTerminalStateAgentProfileTests`, `AgentProfileTests`, `AgentRuntimeAdapterTests`, `CLILifecycleCommandHandlerTests`, `CLIProfilesCommandHandlerTests`): 82 tests passed. +- `make check`, `make build-cli`, `make test-cli-smoke`, and `make test-cli-integration` passed; integration verified 84 socket/schema tests. +- `make build-app` passed with zero errors and warnings. +- Live isolated Debug verification used a dedicated `PROWL_CLI_SOCKET` and the repository CLI. A real Claude Profile received distinct kickoff prompts in an anchored right split and a background tab; `agents --json` identified both returned panes, `read --wait-stable` observed `PROWL_A2_SPLIT_OK` / `PROWL_A2_BACKGROUND_OK`, and the background target remained `selected: false`, `focused: false`. The created split, background tab, and anchor tab were closed and the isolated Debug process was terminated. + +## Refs + +- Slice: 063-A2 +- Branch: `feat/cli-profile-launch` +- Follows: [003-cli-create-pane.md](003-cli-create-pane.md), [004-pane-identity-env.md](004-pane-identity-env.md) diff --git a/docs/components/agent-profiles.md b/docs/components/agent-profiles.md index 701f480d..3545e979 100644 --- a/docs/components/agent-profiles.md +++ b/docs/components/agent-profiles.md @@ -38,10 +38,16 @@ respawn. one click launches the Recommended profile directly, skipping the popover. - **Command Palette** (`⌘P`) — "Launch Agent: " rows dispatch the exact same action, and carry the same availability warning in their subtitle. - -A launch creates a **new** tab (or split, per placement) in the current -worktree, running the agent interactively with no initial prompt. Prowl never -types into an existing shell. The new pane records its profile identity at +- **CLI** — `prowl profiles list` returns enabled and disabled Profiles with their + runtime and shell-probe availability. Launch into a deterministic new tab or anchored + split with `prowl create tab|pane … --profile `; add `--prompt -` to read a + kickoff prompt from stdin and `--background` to preserve the current selection/focus. + Disabled Profiles cannot launch; availability warnings never block an attempt. + +Every launch creates a **new** tab or split; Prowl never types the invocation into an +existing shell. Toolbar and Command Palette launches use the Profile's saved placement in +the current worktree and start interactively with no initial prompt. CLI launches override +placement from the `create tab|pane` command and may supply the kickoff prompt. The new pane records its profile identity at creation: the Active Agents rows and the capsule show the profile's display name (frozen at launch — later renames don't relabel live panes). The identity lives exactly as long as the launched agent: once it exits, any agent started @@ -187,9 +193,8 @@ control; Extra Arguments stay available for expert overrides. Amp has one additional limitation: it supports bare interactive Profile launch and `--execute` headless launch, but has no argv form that seeds a prompt and -then remains interactive. Current Agent Profiles always launch bare, so this -does not block the shipped UI; it does prevent treating Amp as an interactive -prompt receiver in a future handoff workflow without another transport. +then remains interactive. Toolbar/Command Palette and CLI launches without `--prompt` +still work; `create … --profile --prompt -` fails without creating a pane. ## Where things live on disk diff --git a/docs/components/cli.md b/docs/components/cli.md index 5f33a795..528c2aef 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -4,7 +4,7 @@ > an agent) can list panes, read their screens, run commands and capture output, > send keystrokes, focus, and open/close tabs and panes programmatically. -**Keywords:** prowl cli, command line, prowl list, prowl agents, prowl agents read, prowl read, prowl send, prowl key, prowl focus, prowl create, prowl close, prowl open, prowl handoff, pane id, agent, automation, json, capture, socket +**Keywords:** prowl cli, command line, prowl list, prowl agents, prowl agents read, prowl profiles list, prowl read, prowl send, prowl key, prowl focus, prowl create, prowl close, prowl open, prowl handoff, pane id, agent, profile, automation, json, capture, socket **Related:** [terminal](terminal.md) · [concepts](../concepts.md) · [active-agents](active-agents.md) · [agent-detection](agent-detection.md) · the bundled **`prowl-cli` skill** (`skills/prowl-cli/SKILL.md`) @@ -227,6 +227,22 @@ no heading or added newline. For every other result state it exits non-zero with `SESSION_UNRESOLVED`, `RESULT_NOT_FOUND`, `RESULT_INCOMPLETE`, or `RESULT_TOO_LARGE`. Empty `agents` roster output remains `No agents found.`. +### `prowl profiles list` + +Read-only snapshot of every configured Agent Profile, including disabled profiles, in +Settings order: + +```bash +prowl profiles list --json +``` + +Each `.data.profiles[]` item contains `id`, `name`, `enabled`, `runtime`, and +`availability`. `availability.status` is `available`, `unavailable`, or `unknown` and +reflects the login-shell executable probe only; `reason` provides optional human context. +Availability is advisory and never blocks launch. Disabled profiles remain visible but +cannot be passed to `create --profile`. Use the Profile UUID for stable automation; an +exact enabled name also works when unique. + ### `prowl read [target]` Read a pane's content. @@ -320,6 +336,20 @@ either positionally or with `--worktree`; `--path` must remain inside it. pane="$(prowl create tab "$wt" --json | jq -r '.data.target.pane.id')" ``` +Add `--profile ` to launch an enabled Agent Profile instead of a shell. An +optional kickoff prompt uses the sole stdin spelling `--prompt -`: + +```bash +pane="$( + prowl create tab "$wt" --profile Reviewer --prompt - --json <<'EOF' | jq -r '.data.target.pane.id' +Review the current diff and report actionable findings. +EOF +)" +``` + +`--background` is Profile-only and creates the tab without changing the selected +worktree, tab, or pane. + ### `prowl create pane` Create a split beside an explicit pane anchor. The anchor is a pane UUID or current-process `pN` handle, supplied positionally or with `--pane`; `--direction` is required. @@ -328,14 +358,17 @@ Create a split beside an explicit pane anchor. The anchor is a pane UUID or curr pane="$(prowl create pane "$anchor" --direction right --json | jq -r '.data.target.pane.id')" ``` -Directions are `right`, `left`, `up`, and `down`. The created pane inherits the anchor's -working directory and terminal configuration, becomes focused in that tab, and is returned -as `.data.target.pane.id`. Like `create tab`, the command selects the anchor's worktree and -tab, so an anchor in another tab or worktree is brought into view. `.data.anchor` records the -source pane as resolved before the split (its `focused` flag is pre-split state) and -`.data.direction` records the public direction. The operation targets the anchor directly; -it never depends on current UI focus. Use `prowl send --pane "$pane" …` after creation when -you want to run input. +Directions are `right`, `left`, `up`, and `down`. Without `--profile`, the created pane +inherits the anchor's working directory and terminal configuration, becomes focused in that +tab, and is returned as `.data.target.pane.id`. Like `create tab`, the command selects the +anchor's worktree and tab. With `--profile`, it launches the selected Profile and may take a +kickoff prompt from `--prompt -`; `--background` inserts the split without focusing it or +selecting a hidden anchor's worktree/tab. + +`.data.anchor` records the source pane as resolved before the split (its `focused` flag is +pre-split state), `.data.direction` records the public direction, and a Profile launch adds +`.data.launch.{profile_id,profile_name,agent}`. The operation targets the anchor directly; +it never depends on current UI focus. ### `prowl close` Close one explicit tab or pane. The positional form uses a UUID, `pN`, or `tN`; the @@ -469,6 +502,8 @@ artifacts and terminal excerpts do not appear in `git status`. | `SOCKET_PERMISSION_DENIED` | The socket exists but the client cannot connect, usually because a sandbox blocked the Unix socket. Allowlist the socket path, run outside the sandbox, or use matching `PROWL_CLI_SOCKET` values for both app and CLI. | | `TARGET_NOT_FOUND` | Selector matched nothing — re-run `list` and pick a UUID or current short handle. | | `TARGET_NOT_UNIQUE` | Selector matched several — be more specific (use `--pane`). | +| `PROFILE_NOT_FOUND` | No enabled Profile matches the UUID or exact name — re-run `profiles list`; disabled Profiles cannot launch. | +| `PROFILE_NOT_UNIQUE` | Several enabled Profiles have the exact name — use the Profile UUID from `profiles list`. | | `AGENT_NOT_FOUND` / `AGENT_UNSUPPORTED` | `agents read` target no longer hosts an agent, or it is not Codex/Claude Code. Re-run `agents`. | | `BLOCKER_UNREADABLE` | A blocked screen was detected but Prowl could not safely extract its current interaction text. Re-run `agents read` or inspect with `read`. | | `SESSION_UNRESOLVED` / `RESULT_NOT_FOUND` / `RESULT_INCOMPLETE` / `RESULT_TOO_LARGE` | `agents read --result-only` could not provide one trustworthy complete result. Drop `--result-only` to retain the live snapshot and inspect `.data.result`. | @@ -481,7 +516,7 @@ artifacts and terminal excerpts do not appear in `git status`. | `PATH_NOT_FOUND` / `PATH_NOT_DIRECTORY` / `PATH_NOT_ALLOWED` | Fix the `open`/`create tab` path. | | `LAUNCH_FAILED` | App launch or socket wait failed; the message includes the last socket diagnostic when available. | | `TRANSPORT_FAILED` | Socket transport failed for a reason other than app availability or permission, such as `ENOTSOCK` or an invalid `PROWL_CLI_SOCKET` path. | -| `*_FAILED` (`LIST_FAILED`, `AGENTS_FAILED`, `FOCUS_FAILED`, `SEND_FAILED`, `READ_FAILED`, `CREATE_FAILED`, `CLOSE_FAILED`, `TAB_FAILED`, `PANE_FAILED`, `OPEN_FAILED`, `HANDOFF_FAILED`) | The action itself failed. | +| `*_FAILED` (`LIST_FAILED`, `AGENTS_FAILED`, `PROFILES_FAILED`, `FOCUS_FAILED`, `SEND_FAILED`, `READ_FAILED`, `CREATE_FAILED`, `CLOSE_FAILED`, `TAB_FAILED`, `PANE_FAILED`, `OPEN_FAILED`, `HANDOFF_FAILED`) | The action itself failed. | ## Safety & self-targeting diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 5a100025..07d7ec47 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -74,6 +74,22 @@ pane="$(prowl create pane "$PROWL_PANE_ID" --direction right --json | jq -r '.da Directions are `right`, `left`, `up`, `down`; the anchor must be a pane UUID or current `pN`. The new pane inherits the anchor's working directory, becomes focused, and Prowl selects its worktree and tab (as `create tab` does). Run input afterwards with an explicit `prowl send --pane "$pane" …`. +Launch a reviewer beside yourself after the identity guard in **Who You Are** has verified `$me`: + +```bash +launch="$(prowl create pane "$PROWL_PANE_ID" --direction right --profile Reviewer --prompt - --json <<'EOF' +Review the current branch against its base. Report only actionable findings with file and line references. +EOF +)" +pane="$(printf '%s\n' "$launch" | jq -r '.data.target.pane.id')" +prowl read --pane "$pane" --last 200 --wait-stable --json +``` + +The returned pane is the launched agent; `.data.launch` records the resolved Profile. Use +`read --wait-stable` today. When `agents wait` ships, prefer it for deterministic completion +before the final read. Add `--background` when the split must not change focus or select a +hidden anchor's tab/worktree. + Create a fresh tab in a listed worktree: ```bash @@ -122,7 +138,8 @@ Key fields by command: - `agents read` → `.data.agent`, `.data.blocker.text`, `.data.result.{state,text}` — `pending`, `unavailable`, `missing`, `incomplete`, `too_large` carry no partial text. - `read` → `.data.text`, `.data.line_count`, `.data.truncated`, `.data.mode`, `.data.source`; `.data.stabilized` / `.data.waited_ms` with `--wait-stable`. - `send` → `.data.input`, `.data.wait.{exit_code,duration_ms}` when waiting, `.data.capture.{text,line_count,truncated}` with `--capture`. -- `create tab` / `open` → `.data.target.{pane,tab,worktree}`; `create pane` → `.data.anchor`, `.data.direction`, `.data.target`. +- `create tab` / `open` → `.data.target.{pane,tab,worktree}`; `create pane` → `.data.anchor`, `.data.direction`, `.data.target`; Profile launches also include `.data.launch.{profile_id,profile_name,agent}`. +- `profiles list` → `.data.profiles[]` with `.id`, `.name`, `.enabled`, `.runtime`, `.availability.{status,reason}`. Terminal text is `.data.text` (read) and `.data.capture.text` (send) — never `.content`, `.output`, or `.stdout`. @@ -165,6 +182,7 @@ done - `SOCKET_PERMISSION_DENIED`: the sandbox or filesystem blocked `connect()`; report a permission problem, not an app-liveness problem. - `TRANSPORT_FAILED`: the connection broke or the socket path is invalid (`ENOTSOCK`, too-long `PROWL_CLI_SOCKET`). - `TARGET_NOT_FOUND` / `TARGET_NOT_UNIQUE`: re-run `prowl list --json` and pass an explicit UUID or a current `pN`. +- `PROFILE_NOT_FOUND` / `PROFILE_NOT_UNIQUE`: re-run `prowl profiles list --json`; choose an enabled Profile UUID. - `NO_ACTIVE_PANE`: focused-pane targeting found nothing — pass `--pane`. `SOURCE_REQUIRED`: `handoff` was run outside a Prowl pane without a selector. - `EMPTY_INPUT`, `INVALID_ARGUMENT`, `UNSUPPORTED_KEY`, `INVALID_REPEAT`: fix the arguments (`prowl --help`). - `CAPTURE_UNSUPPORTED`: drop `--capture` and use `read --wait-stable` or file redirection. `WAIT_TIMEOUT`: raise `--timeout` or use `--no-wait`. @@ -190,4 +208,4 @@ Required sections are `## Objective`, `## Current State`, and `## Next Steps`; o ## Command Set -`list`, `agents`, `agents read`, `read`, `send`, `key`, `focus`, `create tab`, `create pane`, `close`, `handoff to`, `handoff save`, and `open` (default). There is no CLI `quit`; close temporary tabs or panes with an explicit `close`. `tab create`, `tab close`, and `pane close` remain deprecated aliases for one release. +`list`, `agents`, `agents read`, `profiles list`, `read`, `send`, `key`, `focus`, `create tab`, `create pane`, `close`, `handoff to`, `handoff save`, and `open` (default). There is no CLI `quit`; close temporary tabs or panes with an explicit `close`. `tab create`, `tab close`, and `pane close` remain deprecated aliases for one release. diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index a6e88c0f..873e1618 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -352,6 +352,9 @@ struct SupacodeApp: App { createTabInDirectory: { worktree, directory in terminalManager.createTabInDirectory(worktree, directory: directory) }, + launchAgentProfile: { worktree, request in + terminalManager.launchAgentProfile(request, in: worktree) + }, events: { terminalManager.eventStream() }, @@ -673,6 +676,14 @@ struct SupacodeApp: App { terminalManager: terminalManager ) } + let profilesHandler = ProfilesCommandHandler { + @Shared(.userGlobalSettings) var settings + @Shared(.agentRuntimeAvailabilityProbeResults) var probeResults + return ProfilesRuntimeSnapshot( + profiles: settings.agentProfiles, + probeResults: probeResults + ) + } let agentsHandler = AgentsCommandHandler { var screenDetectionsBySurfaceID: [UUID: AgentScreenDetection] = [:] for terminalState in terminalManager.activeWorktreeStates { @@ -878,6 +889,17 @@ struct SupacodeApp: App { resolveCloseTarget: resolveLifecycleTarget, createTab: createTab, createPane: createPane, + profiles: { + @Shared(.userGlobalSettings) var settings + return settings.agentProfiles + }, + launchAgentProfile: { request in + launchCLIProfile( + request, + appStore: appStore, + terminalManager: terminalManager + ) + }, closeTab: closeTab, closePane: closePane ) @@ -965,6 +987,7 @@ struct SupacodeApp: App { listHandler: listHandler, agentsHandler: agentsHandler, agentsReadHandler: agentReadHandler, + profilesHandler: profilesHandler, focusHandler: focusHandler, sendHandler: sendHandler, keyHandler: keyHandler, @@ -1225,6 +1248,74 @@ struct SupacodeApp: App { return nil } + private static func launchCLIProfile( + _ request: CLIProfileLaunchRequest, + appStore: StoreOf, + terminalManager: WorktreeTerminalManager + ) -> TabResolvedTarget? { + let repositories = Array(appStore.state.repositories.repositories) + guard + let worktree = resolveCLITerminalWorktree( + id: request.target.worktreeID, + repositories: repositories + ) + else { return nil } + + let intent = request.prompt.map(AgentStartIntent.prompt) ?? .interactive + guard + let plan = try? AgentProfileLaunchPlanner.plan( + for: request.profile, + intent: intent, + homeBaseDirectory: SupacodePaths.agentProfileHomesDirectory + ) + else { return nil } + + let placement: AgentProfileLaunchRequest.Placement + switch request.resource { + case .tab: + placement = .tab(background: request.background) + case .pane: + guard + let anchor = UUID(uuidString: request.target.paneID), + let direction = request.direction + else { return nil } + placement = .split( + anchor: anchor, + direction: direction.terminalSplitDirection, + background: request.background + ) + } + let directory = request.path.map { URL(fileURLWithPath: $0, isDirectory: true) } + let launchRequest = AgentProfileLaunchRequest( + plan: plan, + placement: placement, + workingDirectoryOverride: directory, + title: request.profile.name + ) + guard + case .success(let launched) = terminalManager.launchAgentProfile( + launchRequest, + in: worktree + ) + else { return nil } + + if !request.background { + selectCLIWorktreeContext( + worktreeID: request.target.worktreeID, + appStore: appStore, + terminalManager: terminalManager + ) + terminalManager.state(for: worktree).selectTab(launched.tabID) + } + let resolver = makeTargetResolver(appStore: appStore, terminalManager: terminalManager) + switch resolver.resolve(.pane(launched.surfaceID.uuidString)) { + case .success(let resolved): + return TabResolvedTarget(from: resolved) + case .failure: + return nil + } + } + private static func createCLIPane( anchor: TabResolvedTarget, direction: CreatePaneDirection, diff --git a/supacode/CLIService/CLICommandRouter.swift b/supacode/CLIService/CLICommandRouter.swift index 8367ed8d..ee2385be 100644 --- a/supacode/CLIService/CLICommandRouter.swift +++ b/supacode/CLIService/CLICommandRouter.swift @@ -9,6 +9,7 @@ final class CLICommandRouter { private let listHandler: any CommandHandler private let agentsHandler: any CommandHandler private let agentsReadHandler: any CommandHandler + private let profilesHandler: any CommandHandler private let focusHandler: any CommandHandler private let sendHandler: any CommandHandler private let keyHandler: any CommandHandler @@ -24,6 +25,7 @@ final class CLICommandRouter { listHandler: any CommandHandler = StubCommandHandler(command: "list"), agentsHandler: any CommandHandler = StubCommandHandler(command: "agents"), agentsReadHandler: any CommandHandler = StubCommandHandler(command: "agents.read"), + profilesHandler: any CommandHandler = StubCommandHandler(command: "profiles"), focusHandler: any CommandHandler = StubCommandHandler(command: "focus"), sendHandler: any CommandHandler = StubCommandHandler(command: "send"), keyHandler: any CommandHandler = StubCommandHandler(command: "key"), @@ -38,6 +40,7 @@ final class CLICommandRouter { self.listHandler = listHandler self.agentsHandler = agentsHandler self.agentsReadHandler = agentsReadHandler + self.profilesHandler = profilesHandler self.focusHandler = focusHandler self.sendHandler = sendHandler self.keyHandler = keyHandler @@ -59,6 +62,7 @@ final class CLICommandRouter { case .list: handler = listHandler case .agents: handler = agentsHandler case .agentsRead: handler = agentsReadHandler + case .profiles: handler = profilesHandler case .focus: handler = focusHandler case .send: handler = sendHandler case .key: handler = keyHandler diff --git a/supacode/CLIService/LifecycleCommandHandler.swift b/supacode/CLIService/LifecycleCommandHandler.swift index 5d3cc588..3ceb48dc 100644 --- a/supacode/CLIService/LifecycleCommandHandler.swift +++ b/supacode/CLIService/LifecycleCommandHandler.swift @@ -5,6 +5,21 @@ struct LifecycleResolvedTarget: Sendable, Equatable { let target: TabResolvedTarget } +struct CLIProfileLaunchRequest: Sendable, Equatable { + let resource: LifecycleResource + let target: TabResolvedTarget + let profile: AgentProfile + let prompt: String? + let path: String? + let direction: CreatePaneDirection? + let background: Bool +} + +private enum CLIProfileLookupError: Error { + case notFound(String) + case notUnique(String) +} + @MainActor final class LifecycleCommandHandler: CommandHandler { typealias ResolveCreateTargetProvider = @MainActor (TargetSelector) -> Result @@ -12,6 +27,8 @@ final class LifecycleCommandHandler: CommandHandler { @MainActor (TargetSelector) -> Result typealias CreateTabProvider = @MainActor (TabResolvedTarget, String?) -> TabResolvedTarget? typealias CreatePaneProvider = @MainActor (TabResolvedTarget, CreatePaneDirection) -> TabResolvedTarget? + typealias ProfilesProvider = @MainActor () -> [AgentProfile] + typealias ProfileLaunchProvider = @MainActor (CLIProfileLaunchRequest) -> TabResolvedTarget? typealias CloseTabProvider = @MainActor (TabResolvedTarget, Bool) -> Bool typealias ClosePaneProvider = @MainActor (TabResolvedTarget, Bool) -> Bool @@ -19,6 +36,8 @@ final class LifecycleCommandHandler: CommandHandler { private let resolveCloseTarget: ResolveCloseTargetProvider private let createTab: CreateTabProvider private let createPane: CreatePaneProvider + private let profiles: ProfilesProvider + private let launchAgentProfile: ProfileLaunchProvider private let closeTab: CloseTabProvider private let closePane: ClosePaneProvider @@ -27,6 +46,8 @@ final class LifecycleCommandHandler: CommandHandler { resolveCloseTarget: @escaping ResolveCloseTargetProvider, createTab: @escaping CreateTabProvider, createPane: @escaping CreatePaneProvider, + profiles: @escaping ProfilesProvider = { [] }, + launchAgentProfile: @escaping ProfileLaunchProvider = { _ in nil }, closeTab: @escaping CloseTabProvider, closePane: @escaping ClosePaneProvider ) { @@ -34,6 +55,8 @@ final class LifecycleCommandHandler: CommandHandler { self.resolveCloseTarget = resolveCloseTarget self.createTab = createTab self.createPane = createPane + self.profiles = profiles + self.launchAgentProfile = launchAgentProfile self.closeTab = closeTab self.closePane = closePane } @@ -52,6 +75,15 @@ final class LifecycleCommandHandler: CommandHandler { } private func handleCreate(_ input: CreateInput) -> CommandResponse { + if let prompt = input.launch?.prompt, + prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return errorResponse( + command: "create", + code: CLIErrorCode.emptyInput, + message: "The kickoff prompt is empty." + ) + } switch input.resource { case .tab: return handleCreateTab(input) @@ -68,6 +100,9 @@ final class LifecycleCommandHandler: CommandHandler { message: "create tab requires a worktree target and does not accept a direction." ) } + guard input.launch != nil || !input.background else { + return backgroundRequiresProfileError() + } let target: TabResolvedTarget switch resolveCreateTarget(input.selector) { @@ -85,6 +120,14 @@ final class LifecycleCommandHandler: CommandHandler { message: "Tab path must be inside the resolved worktree." ) } + if let launch = input.launch { + return handleProfileLaunch( + input: input, + launch: launch, + target: target, + path: path + ) + } guard let createdTarget = createTab(target, path) else { return errorResponse(command: "create", code: CLIErrorCode.createFailed, message: "Failed to create tab.") } @@ -99,6 +142,9 @@ final class LifecycleCommandHandler: CommandHandler { message: "create pane requires a pane target and an explicit direction." ) } + guard input.launch != nil || !input.background else { + return backgroundRequiresProfileError() + } let anchor: TabResolvedTarget switch resolveCreateTarget(input.selector) { @@ -108,6 +154,14 @@ final class LifecycleCommandHandler: CommandHandler { return mapResolverError(command: "create", error: error) } + if let launch = input.launch { + return handleProfileLaunch( + input: input, + launch: launch, + target: anchor, + path: nil + ) + } guard let createdTarget = createPane(anchor, direction) else { return errorResponse(command: "create", code: CLIErrorCode.createFailed, message: "Failed to create pane.") } @@ -120,6 +174,82 @@ final class LifecycleCommandHandler: CommandHandler { ) } + private func handleProfileLaunch( + input: CreateInput, + launch: CreateLaunchInput, + target: TabResolvedTarget, + path: String? + ) -> CommandResponse { + let profile: AgentProfile + switch resolveProfile(launch.profile) { + case .success(let resolved): + profile = resolved + case .failure(.notFound(let message)): + return errorResponse(command: "create", code: CLIErrorCode.profileNotFound, message: message) + case .failure(.notUnique(let message)): + return errorResponse(command: "create", code: CLIErrorCode.profileNotUnique, message: message) + } + + let request = CLIProfileLaunchRequest( + resource: input.resource, + target: target, + profile: profile, + prompt: launch.prompt, + path: path, + direction: input.direction, + background: input.background + ) + guard let createdTarget = launchAgentProfile(request) else { + return errorResponse( + command: "create", + code: CLIErrorCode.createFailed, + message: "Failed to launch Agent Profile “\(profile.name)”." + ) + } + return success( + command: "create", + resource: input.resource, + target: createdTarget, + anchor: input.resource == .pane ? target : nil, + direction: input.direction, + launch: LifecycleCommandLaunch( + profileID: profile.id.uuidString, + profileName: profile.name, + agent: profile.runtime.agent.rawValue + ) + ) + } + + private func resolveProfile(_ reference: String) -> Result { + let allProfiles = profiles() + if let id = UUID(uuidString: reference), + let profile = allProfiles.first(where: { $0.id == id }) + { + guard profile.isEnabled else { + return .failure(.notFound("Agent Profile “\(profile.name)” is disabled.")) + } + return .success(profile) + } + let enabledProfiles = allProfiles.filter(\.isEnabled) + let named = enabledProfiles.filter { $0.name == reference } + switch named.count { + case 1: + return .success(named[0]) + case 0: + return .failure(.notFound("No enabled Agent Profile matches “\(reference)”.")) + default: + return .failure(.notUnique("Multiple enabled Agent Profiles are named “\(reference)”; use a UUID.")) + } + } + + private func backgroundRequiresProfileError() -> CommandResponse { + errorResponse( + command: "create", + code: CLIErrorCode.invalidArgument, + message: "--background requires an Agent Profile launch." + ) + } + private func handleClose(_ input: CloseInput) -> CommandResponse { guard !input.selector.isNone else { return errorResponse( @@ -176,7 +306,8 @@ final class LifecycleCommandHandler: CommandHandler { resource: LifecycleResource, target: TabResolvedTarget, anchor: TabResolvedTarget? = nil, - direction: CreatePaneDirection? = nil + direction: CreatePaneDirection? = nil, + launch: LifecycleCommandLaunch? = nil ) -> CommandResponse { do { return try CommandResponse( @@ -188,6 +319,7 @@ final class LifecycleCommandHandler: CommandHandler { resource: resource, anchor: anchor.map { makePayloadTarget(from: $0) }, direction: direction, + launch: launch, target: makePayloadTarget(from: target) ) ) diff --git a/supacode/CLIService/ProfilesCommandHandler.swift b/supacode/CLIService/ProfilesCommandHandler.swift new file mode 100644 index 00000000..6cd5e3ac --- /dev/null +++ b/supacode/CLIService/ProfilesCommandHandler.swift @@ -0,0 +1,74 @@ +import Foundation + +struct ProfilesRuntimeSnapshot { + let profiles: [AgentProfile] + let probeResults: [AgentProfileRuntime: AgentRuntimeAvailabilityProbeResult] +} + +final class ProfilesCommandHandler: CommandHandler { + typealias SnapshotProvider = @MainActor () throws -> ProfilesRuntimeSnapshot + + private let snapshotProvider: SnapshotProvider + + init(snapshotProvider: @escaping SnapshotProvider) { + self.snapshotProvider = snapshotProvider + } + + // swiftlint:disable:next async_without_await + func handle(envelope: CommandEnvelope) async -> CommandResponse { + do { + let snapshot = try snapshotProvider() + let payload = ProfilesCommandPayload( + count: snapshot.profiles.count, + profiles: snapshot.profiles.map { profile in + ProfilesCommandProfile( + id: profile.id.uuidString, + name: profile.name, + enabled: profile.isEnabled, + runtime: profile.runtime.rawValue, + availability: availability( + for: profile.runtime, + probeResult: snapshot.probeResults[profile.runtime] + ) + ) + } + ) + return try CommandResponse( + ok: true, + command: "profiles", + schemaVersion: "prowl.cli.profiles.v1", + data: RawJSON(encoding: payload) + ) + } catch { + return CommandResponse( + ok: false, + command: "profiles", + schemaVersion: "prowl.cli.profiles.v1", + error: CommandError( + code: CLIErrorCode.profilesFailed, + message: "Failed to list Agent Profiles." + ) + ) + } + } + + private func availability( + for runtime: AgentProfileRuntime, + probeResult: AgentRuntimeAvailabilityProbeResult? + ) -> ProfilesCommandAvailability { + guard let probeResult else { + return ProfilesCommandAvailability( + status: .unknown, + reason: "Availability check has not completed" + ) + } + if probeResult.isAvailable { + return ProfilesCommandAvailability(status: .available) + } + let name = AgentRuntimeAdapterRegistry.displayName(for: runtime) + return ProfilesCommandAvailability( + status: .unavailable, + reason: "\(name) is not on your shell's PATH" + ) + } +} diff --git a/supacode/CLIService/Shared/CommandEnvelope.swift b/supacode/CLIService/Shared/CommandEnvelope.swift index 10cd9098..b4829f4b 100644 --- a/supacode/CLIService/Shared/CommandEnvelope.swift +++ b/supacode/CLIService/Shared/CommandEnvelope.swift @@ -18,6 +18,7 @@ public enum Command: Codable, Sendable { case list(ListInput) case agents(AgentsInput) case agentsRead(AgentReadInput) + case profiles(ProfilesInput) case focus(FocusInput) case send(SendInput) case key(KeyInput) @@ -34,6 +35,7 @@ public enum Command: Codable, Sendable { case .list: "list" case .agents: "agents" case .agentsRead: "agents.read" + case .profiles: "profiles" case .focus: "focus" case .send: "send" case .key: "key" diff --git a/supacode/CLIService/Shared/ErrorCodes.swift b/supacode/CLIService/Shared/ErrorCodes.swift index e28c1375..3af51ad8 100644 --- a/supacode/CLIService/Shared/ErrorCodes.swift +++ b/supacode/CLIService/Shared/ErrorCodes.swift @@ -22,6 +22,9 @@ public enum CLIErrorCode { // Agents public static let agentsFailed = "AGENTS_FAILED" + + // Profiles + public static let profilesFailed = "PROFILES_FAILED" public static let agentNotFound = "AGENT_NOT_FOUND" public static let agentUnsupported = "AGENT_UNSUPPORTED" public static let agentReadFailed = "AGENT_READ_FAILED" @@ -52,6 +55,8 @@ public enum CLIErrorCode { // Lifecycle public static let createFailed = "CREATE_FAILED" public static let closeFailed = "CLOSE_FAILED" + public static let profileNotFound = "PROFILE_NOT_FOUND" + public static let profileNotUnique = "PROFILE_NOT_UNIQUE" // Tab public static let tabFailed = "TAB_FAILED" diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index 09c95c6b..64718862 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -30,6 +30,10 @@ public struct AgentsInput: Codable, Sendable { public init() {} } +public struct ProfilesInput: Codable, Sendable { + public init() {} +} + public struct AgentReadInput: Codable, Sendable { public static let defaultMaxBytes = 1_024 * 1_024 public static let maximumMaxBytes = 4 * 1_024 * 1_024 @@ -212,22 +216,57 @@ public enum CreatePaneDirection: String, Codable, CaseIterable, Sendable, Equata case down } +public struct CreateLaunchInput: Codable, Sendable, Equatable { + public let profile: String + public let prompt: String? + + public init(profile: String, prompt: String? = nil) { + self.profile = profile + self.prompt = prompt + } +} + public struct CreateInput: Codable, Sendable { public let resource: LifecycleResource public let selector: TargetSelector public let path: String? public let direction: CreatePaneDirection? + public let launch: CreateLaunchInput? + public let background: Bool + + enum CodingKeys: String, CodingKey { + case resource + case selector + case path + case direction + case launch + case background + } public init( resource: LifecycleResource, selector: TargetSelector, path: String? = nil, - direction: CreatePaneDirection? = nil + direction: CreatePaneDirection? = nil, + launch: CreateLaunchInput? = nil, + background: Bool = false ) { self.resource = resource self.selector = selector self.path = path self.direction = direction + self.launch = launch + self.background = background + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + resource = try container.decode(LifecycleResource.self, forKey: .resource) + selector = try container.decode(TargetSelector.self, forKey: .selector) + path = try container.decodeIfPresent(String.self, forKey: .path) + direction = try container.decodeIfPresent(CreatePaneDirection.self, forKey: .direction) + launch = try container.decodeIfPresent(CreateLaunchInput.self, forKey: .launch) + background = try container.decodeIfPresent(Bool.self, forKey: .background) ?? false } } diff --git a/supacode/CLIService/Shared/LifecycleCommandPayload.swift b/supacode/CLIService/Shared/LifecycleCommandPayload.swift index 24fb4eb7..38a60281 100644 --- a/supacode/CLIService/Shared/LifecycleCommandPayload.swift +++ b/supacode/CLIService/Shared/LifecycleCommandPayload.swift @@ -1,20 +1,41 @@ import Foundation +public struct LifecycleCommandLaunch: Codable, Sendable, Equatable { + public let profileID: String + public let profileName: String + public let agent: String + + enum CodingKeys: String, CodingKey { + case profileID = "profile_id" + case profileName = "profile_name" + case agent + } + + public init(profileID: String, profileName: String, agent: String) { + self.profileID = profileID + self.profileName = profileName + self.agent = agent + } +} + public struct LifecycleCommandPayload: Codable, Sendable, Equatable { public let resource: LifecycleResource public let anchor: TabTarget? public let direction: CreatePaneDirection? + public let launch: LifecycleCommandLaunch? public let target: TabTarget public init( resource: LifecycleResource, anchor: TabTarget? = nil, direction: CreatePaneDirection? = nil, + launch: LifecycleCommandLaunch? = nil, target: TabTarget ) { self.resource = resource self.anchor = anchor self.direction = direction + self.launch = launch self.target = target } } diff --git a/supacode/CLIService/Shared/ProfilesCommandPayload.swift b/supacode/CLIService/Shared/ProfilesCommandPayload.swift new file mode 100644 index 00000000..1153bcaf --- /dev/null +++ b/supacode/CLIService/Shared/ProfilesCommandPayload.swift @@ -0,0 +1,49 @@ +import Foundation + +public struct ProfilesCommandPayload: Codable, Sendable, Equatable { + public let count: Int + public let profiles: [ProfilesCommandProfile] + + public init(count: Int, profiles: [ProfilesCommandProfile]) { + self.count = count + self.profiles = profiles + } +} + +public struct ProfilesCommandProfile: Codable, Sendable, Equatable { + public let id: String + public let name: String + public let enabled: Bool + public let runtime: String + public let availability: ProfilesCommandAvailability + + public init( + id: String, + name: String, + enabled: Bool, + runtime: String, + availability: ProfilesCommandAvailability + ) { + self.id = id + self.name = name + self.enabled = enabled + self.runtime = runtime + self.availability = availability + } +} + +public struct ProfilesCommandAvailability: Codable, Sendable, Equatable { + public let status: ProfilesCommandAvailabilityStatus + public let reason: String? + + public init(status: ProfilesCommandAvailabilityStatus, reason: String? = nil) { + self.status = status + self.reason = reason + } +} + +public enum ProfilesCommandAvailabilityStatus: String, Codable, Sendable, Equatable { + case available + case unavailable + case unknown +} diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index c49f9c97..716e91e7 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -5,6 +5,11 @@ struct TerminalClient { var send: @MainActor @Sendable (Command) -> Void /// Creates and selects a tab synchronously so Canvas can target its exact ID. var createTabInDirectory: @MainActor @Sendable (Worktree, URL) -> TerminalTabID? + /// Launches a compiled profile request synchronously and returns the exact tab + /// and pane identities. This is the CLI/workflow boundary; the legacy command + /// remains event-driven for menu and palette launches. + var launchAgentProfile: + @MainActor @Sendable (Worktree, AgentProfileLaunchRequest) -> Result var events: @MainActor @Sendable () -> AsyncStream var canvasFocusedWorktreeID: @MainActor @Sendable () -> Worktree.ID? /// Active surface in the selected tab. Lets the reducer capture the target @@ -101,6 +106,7 @@ extension TerminalClient: DependencyKey { static let liveValue = TerminalClient( send: { _ in fatalError("TerminalClient.send not configured") }, createTabInDirectory: { _, _ in fatalError("TerminalClient.createTabInDirectory not configured") }, + launchAgentProfile: { _, _ in fatalError("TerminalClient.launchAgentProfile not configured") }, events: { fatalError("TerminalClient.events not configured") }, canvasFocusedWorktreeID: { nil }, selectedSurfaceID: { _ in nil }, @@ -117,6 +123,7 @@ extension TerminalClient: DependencyKey { static let testValue = TerminalClient( send: { _ in }, createTabInDirectory: { _, _ in nil }, + launchAgentProfile: { _, _ in .failure(.tabCreationFailed) }, events: { AsyncStream { $0.finish() } }, canvasFocusedWorktreeID: { nil }, selectedSurfaceID: { _ in nil }, diff --git a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift index dafd7d32..b360f71e 100644 --- a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift +++ b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift @@ -63,6 +63,50 @@ nonisolated struct AgentProfileLaunchPlan: Equatable, Sendable { var previewText: String { terminalInput } } +/// 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 title: String? + + init( + plan: AgentProfileLaunchPlan, + placement: Placement, + workingDirectoryOverride: URL? = nil, + title: String? = nil + ) { + self.plan = plan + self.placement = placement + self.workingDirectoryOverride = workingDirectoryOverride + self.title = title + } +} + +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) +} + /// 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). @@ -200,6 +244,7 @@ nonisolated enum AgentProfileLaunchPlanner { /// home provisioning happens at the launch boundary, not here. static func plan( for profile: AgentProfile, + intent: AgentStartIntent = .interactive, homeBaseDirectory: URL ) throws -> AgentProfileLaunchPlan { guard let adapter = AgentRuntimeAdapterRegistry.profileAdapter(for: profile.runtime) else { @@ -238,7 +283,7 @@ nonisolated enum AgentProfileLaunchPlanner { let invocation = try AgentRuntimeAdapterRegistry.makeStartInvocation( AgentStartRequest( runtime: profile.runtime, - intent: .interactive, + intent: intent, configuration: configuration, dedicatedHome: dedicatedHome ) diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 47bdccf0..7530c971 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -60,6 +60,15 @@ final class WorktreeTerminalManager { createTabAsync(in: worktree, runSetupScriptIfNew: false, workingDirectory: directory) } + /// Synchronous launch boundary used by the CLI and workflow runner. Menu and + /// palette launch events remain owned by the compatibility command below. + func launchAgentProfile( + _ request: AgentProfileLaunchRequest, + in worktree: Worktree + ) -> Result { + state(for: worktree).launchAgentProfile(request) + } + /// The launch outcome is reported as an event either way: the reducer /// records the per-repo launch memory only on success and surfaces the /// failure as a toast (docs-ai 053/005). diff --git a/supacode/Features/Terminal/Models/TerminalTabID.swift b/supacode/Features/Terminal/Models/TerminalTabID.swift index a1df1ab2..e7e4aaaf 100644 --- a/supacode/Features/Terminal/Models/TerminalTabID.swift +++ b/supacode/Features/Terminal/Models/TerminalTabID.swift @@ -1,6 +1,6 @@ import Foundation -struct TerminalTabID: Hashable, Identifiable, Sendable { +nonisolated struct TerminalTabID: Hashable, Identifiable, Sendable { let rawValue: UUID init() { diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index c9824e26..32ed6af0 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -479,59 +479,132 @@ final class WorktreeTerminalState { return tabId } - /// Launches an agent profile per its compiled plan (docs-ai 053): provisions - /// the dedicated home when bound, creates the placement surface with the - /// environment patch, and records the profile identity on the new surface. - /// Split placement degrades to a new tab when nothing is splittable. + /// Launches an agent profile through the deterministic A2 boundary. Explicit + /// split placement never falls back to a tab; callers receive both identities + /// synchronously and can resolve the exact created target without using focus. + @discardableResult + func launchAgentProfile( + _ request: AgentProfileLaunchRequest + ) -> Result { + guard provisionAgentProfileHome(for: request.plan) else { + return .failure(.homeProvisioningFailed) + } + return launchProvisionedAgentProfile(request) + } + + /// Compatibility wrapper for the shipped menu/palette path. It preserves the + /// original split-to-tab fallback and UUID-only result while the CLI/runner use + /// the typed request boundary above. @discardableResult func launchAgentProfile(_ plan: AgentProfileLaunchPlan) -> UUID? { - if let home = plan.dedicatedHome { - do { - try AgentProfileHomeProvisioner.provision( - home: home, - base: SupacodePaths.agentProfileHomesDirectory + guard provisionAgentProfileHome(for: plan) else { return nil } + if plan.placement == .split { + let splitRequest = AgentProfileLaunchRequest( + plan: plan, + placement: .split( + anchor: nil, + direction: plan.splitDirection, + background: false ) - } catch { - terminalStateLogger.warning("Agent profile home provisioning failed: \(error)") - return nil + ) + if case .success(let launched) = launchProvisionedAgentProfile(splitRequest) { + return launched.surfaceID + } + } + return try? launchProvisionedAgentProfile( + AgentProfileLaunchRequest( + plan: plan, + placement: .tab(background: false) + ) + ).get().surfaceID + } + + private func provisionAgentProfileHome(for plan: AgentProfileLaunchPlan) -> Bool { + guard let home = plan.dedicatedHome else { return true } + do { + try AgentProfileHomeProvisioner.provision( + home: home, + base: SupacodePaths.agentProfileHomesDirectory + ) + return true + } catch { + terminalStateLogger.warning("Agent profile home provisioning failed: \(error)") + return false + } + } + + private func launchProvisionedAgentProfile( + _ request: AgentProfileLaunchRequest + ) -> Result { + let plan = request.plan + let launched: Result + switch request.placement { + case .tab(let background): + launched = createAgentProfileTab(request, background: background) + case .split(let requestedAnchor, let direction, let background): + guard let anchor = requestedAnchor ?? currentFocusedSurfaceId() else { + return .failure(.splitAnchorUnavailable) + } + switch createSplit( + of: anchor, + direction: direction, + initialInput: plan.terminalInput, + additionalEnvironment: plan.surfaceEnvironment, + focusing: !background + ) { + case .success(let surfaceID): + guard let tabID = tabID(containing: surfaceID) else { + return .failure(.splitCreationFailed(.insertionFailed)) + } + launched = .success(LaunchedSurface(tabID: tabID, surfaceID: surfaceID)) + case .failure(let error): + launched = .failure(.splitCreationFailed(error)) } } - let identity = SurfaceLaunchProfile( + + guard case .success(let surface) = launched else { return launched } + launchProfilesBySurface[surface.surfaceID] = SurfaceLaunchProfile( profileID: plan.profileID, name: plan.profileName, runtime: plan.runtime, dedicatedHome: plan.dedicatedHome, sessionConfigRoot: plan.sessionConfigRoot ) - if plan.placement == .split, - let surfaceID = createSplitOnFocusedSurface( - direction: plan.splitDirection, - initialInput: plan.terminalInput, - additionalEnvironment: plan.surfaceEnvironment - ) + if case .split = request.placement, + let icon = Self.launchTabIcon(for: plan.runtime) { - launchProfilesBySurface[surfaceID] = identity - if let icon = Self.launchTabIcon(for: plan.runtime), let tabID = tabID(containing: surfaceID) { - applyResolvedIcon(icon, surfaceId: surfaceID, tabId: tabID) - } - return surfaceID - } - let tabId = createTab( - TabCreation( - title: plan.profileName, - icon: Self.launchTabIcon(for: plan.runtime)?.storageString ?? "terminal", - isTitleLocked: false, - initialInput: runScriptInput(plan.terminalInput), - focusing: true, - inheritingFromSurfaceId: currentFocusedSurfaceId(), - context: GHOSTTY_SURFACE_CONTEXT_TAB, - workingDirectoryOverride: nil, - additionalEnvironment: plan.surfaceEnvironment + applyResolvedIcon(icon, surfaceId: surface.surfaceID, tabId: surface.tabID) + } + return launched + } + + private func createAgentProfileTab( + _ request: AgentProfileLaunchRequest, + background: Bool + ) -> Result { + let plan = request.plan + guard + let tabID = createTab( + TabCreation( + title: request.title ?? plan.profileName, + icon: Self.launchTabIcon(for: plan.runtime)?.storageString ?? "terminal", + isTitleLocked: false, + initialInput: runScriptInput(plan.terminalInput), + focusing: !background, + selecting: !background, + inheritingFromSurfaceId: currentFocusedSurfaceId(), + context: GHOSTTY_SURFACE_CONTEXT_TAB, + workingDirectoryOverride: request.workingDirectoryOverride, + additionalEnvironment: plan.surfaceEnvironment + ) ) - ) - guard let tabId, let surfaceID = trees[tabId]?.root?.leftmostLeaf().id else { return nil } - launchProfilesBySurface[surfaceID] = identity - return surfaceID + else { + return .failure(.tabCreationFailed) + } + guard let surfaceID = trees[tabID]?.root?.leftmostLeaf().id else { + return .failure(.launchedSurfaceMissing(tabID)) + } + return .success(LaunchedSurface(tabID: tabID, surfaceID: surfaceID)) } /// Icon for a profile launch. The launch path knows its runtime, so it diff --git a/supacodeTests/AgentProfileTests.swift b/supacodeTests/AgentProfileTests.swift index 20497a2c..4d59b241 100644 --- a/supacodeTests/AgentProfileTests.swift +++ b/supacodeTests/AgentProfileTests.swift @@ -285,6 +285,39 @@ struct AgentProfileTests { #expect(plan.previewText == plan.invocation.terminalInput) } + @Test func plannerForwardsPromptIntentThroughTheProfileAdapter() throws { + var preset = profile(name: "Codex · Reviewer") + preset.model = "gpt-5.4" + + let plan = try AgentProfileLaunchPlanner.plan( + for: preset, + intent: .prompt("Review the current diff."), + homeBaseDirectory: URL(fileURLWithPath: "/base", isDirectory: true) + ) + + #expect(plan.invocation.executable == "codex") + #expect(plan.invocation.arguments == ["--model", "gpt-5.4", "Review the current diff."]) + } + + @Test func plannerKeepsInteractiveAsTheDefaultIntent() throws { + let plan = try AgentProfileLaunchPlanner.plan( + for: profile(name: "Codex"), + homeBaseDirectory: URL(fileURLWithPath: "/base", isDirectory: true) + ) + + #expect(plan.invocation.arguments.isEmpty) + } + + @Test func plannerPreservesAmpPromptRejection() { + #expect(throws: AgentRuntimeError.unsupportedStartIntent(.amp, .prompt("Review."))) { + try AgentProfileLaunchPlanner.plan( + for: profile(name: "Amp", runtime: .amp), + intent: .prompt("Review."), + homeBaseDirectory: URL(fileURLWithPath: "/base", isDirectory: true) + ) + } + } + @Test func boundProfilePlanDerivesHomeFromUUIDInsideBase() throws { var bound = profile(name: "Codex · Work") bound.bindsDedicatedHome = true diff --git a/supacodeTests/CLILifecycleCommandHandlerTests.swift b/supacodeTests/CLILifecycleCommandHandlerTests.swift index be16cf10..efac083f 100644 --- a/supacodeTests/CLILifecycleCommandHandlerTests.swift +++ b/supacodeTests/CLILifecycleCommandHandlerTests.swift @@ -157,6 +157,269 @@ struct CLILifecycleCommandHandlerTests { #expect(response.data == nil) } + @Test func createTabLaunchesAnEnabledProfileByExactNameAndReturnsMetadata() async throws { + let base = makeTarget(tabID: "base-tab", paneID: "base-pane") + let created = makeTarget(tabID: "created-tab", paneID: "created-pane") + let profile = AgentProfile(name: "Reviewer", runtime: .claude) + var launchRequest: CLIProfileLaunchRequest? + let handler = LifecycleCommandHandler( + resolveCreateTarget: { _ in .success(base) }, + resolveCloseTarget: { _ in .success(LifecycleResolvedTarget(resource: .pane, target: base)) }, + createTab: { _, _ in nil }, + createPane: { _, _ in nil }, + profiles: { [profile] }, + launchAgentProfile: { request in + launchRequest = request + return created + }, + closeTab: { _, _ in true }, + closePane: { _, _ in true } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .create( + CreateInput( + resource: .tab, + selector: .worktree("App"), + launch: CreateLaunchInput(profile: "Reviewer", prompt: "Review the diff."), + background: true + ) + ) + ) + ) + + #expect(response.ok) + #expect(launchRequest?.profile == profile) + #expect(launchRequest?.target == base) + #expect(launchRequest?.prompt == "Review the diff.") + #expect(launchRequest?.background == true) + let data = try #require(response.data) + let payload = try data.decode(as: LifecycleCommandPayload.self) + #expect( + payload.launch + == LifecycleCommandLaunch( + profileID: profile.id.uuidString, + profileName: "Reviewer", + agent: "claude" + ) + ) + } + + @Test func disabledNamesDoNotMakeAnEnabledProfileNonUnique() async { + let target = makeTarget() + let enabled = AgentProfile(name: "Reviewer", runtime: .claude) + let disabled = AgentProfile(name: "Reviewer", isEnabled: false, runtime: .codex) + var launchedProfile: AgentProfile? + let handler = LifecycleCommandHandler( + resolveCreateTarget: { _ in .success(target) }, + resolveCloseTarget: { _ in .success(LifecycleResolvedTarget(resource: .pane, target: target)) }, + createTab: { _, _ in nil }, + createPane: { _, _ in nil }, + profiles: { [disabled, enabled] }, + launchAgentProfile: { request in + launchedProfile = request.profile + return target + }, + closeTab: { _, _ in true }, + closePane: { _, _ in true } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .create( + CreateInput( + resource: .pane, + selector: .pane("p12"), + direction: .right, + launch: CreateLaunchInput(profile: "Reviewer") + ) + ) + ) + ) + + #expect(response.ok) + #expect(launchedProfile?.id == enabled.id) + } + + @Test func profileUUIDWinsOverAnExactNameMatch() async { + let target = makeTarget() + let byID = AgentProfile(name: "By ID", runtime: .claude) + let byName = AgentProfile(name: byID.id.uuidString, runtime: .codex) + var launchedProfile: AgentProfile? + let handler = LifecycleCommandHandler( + resolveCreateTarget: { _ in .success(target) }, + resolveCloseTarget: { _ in .success(LifecycleResolvedTarget(resource: .pane, target: target)) }, + createTab: { _, _ in nil }, + createPane: { _, _ in nil }, + profiles: { [byName, byID] }, + launchAgentProfile: { request in + launchedProfile = request.profile + return target + }, + closeTab: { _, _ in true }, + closePane: { _, _ in true } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .create( + CreateInput( + resource: .tab, + selector: .worktree("App"), + launch: CreateLaunchInput(profile: byID.id.uuidString) + ) + ) + ) + ) + + #expect(response.ok) + #expect(launchedProfile?.id == byID.id) + } + + @Test func disabledProfileUUIDIsNotLaunchable() async { + let target = makeTarget() + let disabled = AgentProfile(name: "Reviewer", isEnabled: false, runtime: .claude) + let handler = makeProfileLookupHandler(target: target, profiles: [disabled]) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .create( + CreateInput( + resource: .tab, + selector: .worktree("App"), + launch: CreateLaunchInput(profile: disabled.id.uuidString) + ) + ) + ) + ) + + #expect(!response.ok) + #expect(response.error?.code == CLIErrorCode.profileNotFound) + } + + @Test func duplicateEnabledProfileNameIsRejected() async { + let target = makeTarget() + let profiles = [ + AgentProfile(name: "Reviewer", runtime: .claude), + AgentProfile(name: "Reviewer", runtime: .codex), + ] + let handler = makeProfileLookupHandler(target: target, profiles: profiles) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .create( + CreateInput( + resource: .tab, + selector: .worktree("App"), + launch: CreateLaunchInput(profile: "Reviewer") + ) + ) + ) + ) + + #expect(!response.ok) + #expect(response.error?.code == CLIErrorCode.profileNotUnique) + } + + @Test func profileLaunchFailureMapsToCreateFailed() async { + let target = makeTarget() + let profile = AgentProfile(name: "Reviewer", runtime: .claude) + let handler = LifecycleCommandHandler( + resolveCreateTarget: { _ in .success(target) }, + resolveCloseTarget: { _ in .success(LifecycleResolvedTarget(resource: .pane, target: target)) }, + createTab: { _, _ in nil }, + createPane: { _, _ in nil }, + profiles: { [profile] }, + launchAgentProfile: { _ in nil }, + closeTab: { _, _ in true }, + closePane: { _, _ in true } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .create( + CreateInput( + resource: .tab, + selector: .worktree("App"), + launch: CreateLaunchInput(profile: "Reviewer") + ) + ) + ) + ) + + #expect(!response.ok) + #expect(response.error?.code == CLIErrorCode.createFailed) + } + + @Test func emptyProfilePromptIsRejectedBeforeResolution() async { + let target = makeTarget() + var didResolve = false + let handler = LifecycleCommandHandler( + resolveCreateTarget: { _ in + didResolve = true + return .success(target) + }, + resolveCloseTarget: { _ in .success(LifecycleResolvedTarget(resource: .pane, target: target)) }, + createTab: { _, _ in nil }, + createPane: { _, _ in nil }, + closeTab: { _, _ in true }, + closePane: { _, _ in true } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .create( + CreateInput( + resource: .tab, + selector: .worktree("App"), + launch: CreateLaunchInput(profile: "Reviewer", prompt: " \n") + ) + ) + ) + ) + + #expect(!response.ok) + #expect(response.error?.code == CLIErrorCode.emptyInput) + #expect(!didResolve) + } + + @Test func backgroundWithoutProfileIsRejectedBeforeResolution() async { + let target = makeTarget() + var didResolve = false + let handler = LifecycleCommandHandler( + resolveCreateTarget: { _ in + didResolve = true + return .success(target) + }, + resolveCloseTarget: { _ in .success(LifecycleResolvedTarget(resource: .pane, target: target)) }, + createTab: { _, _ in nil }, + createPane: { _, _ in nil }, + closeTab: { _, _ in true }, + closePane: { _, _ in true } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .create( + CreateInput(resource: .tab, selector: .worktree("App"), background: true) + ) + ) + ) + + #expect(!response.ok) + #expect(response.error?.code == CLIErrorCode.invalidArgument) + #expect(!didResolve) + } + @Test func closeUsesResolvedResourceAndReturnsClosePayload() async throws { let target = makeTarget(tabID: "tab-to-close", paneID: "pane-to-close") var closedPane: TabResolvedTarget? @@ -190,6 +453,22 @@ struct CLILifecycleCommandHandlerTests { #expect(payload.target.pane.id == "pane-to-close") } + private func makeProfileLookupHandler( + target: TabResolvedTarget, + profiles: [AgentProfile] + ) -> LifecycleCommandHandler { + LifecycleCommandHandler( + resolveCreateTarget: { _ in .success(target) }, + resolveCloseTarget: { _ in .success(LifecycleResolvedTarget(resource: .pane, target: target)) }, + createTab: { _, _ in nil }, + createPane: { _, _ in nil }, + profiles: { profiles }, + launchAgentProfile: { _ in target }, + closeTab: { _, _ in true }, + closePane: { _, _ in true } + ) + } + private func makeTarget( worktreeID: String = "App:/Projects/App", worktreeName: String = "App", diff --git a/supacodeTests/CLIProfilesCommandHandlerTests.swift b/supacodeTests/CLIProfilesCommandHandlerTests.swift new file mode 100644 index 00000000..78cf35c2 --- /dev/null +++ b/supacodeTests/CLIProfilesCommandHandlerTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct CLIProfilesCommandHandlerTests { + @Test func listPreservesOrderAndMapsShellProbeAvailability() async throws { + let available = AgentProfile(name: "Reviewer", runtime: .claude) + let unavailable = AgentProfile(name: "Builder", isEnabled: false, runtime: .codex) + let unknown = AgentProfile(name: "Researcher", runtime: .amp) + let checkedAt = Date(timeIntervalSince1970: 1_787_390_000) + let handler = ProfilesCommandHandler { + ProfilesRuntimeSnapshot( + profiles: [available, unavailable, unknown], + probeResults: [ + .claude: AgentRuntimeAvailabilityProbeResult(isAvailable: true, checkedAt: checkedAt), + .codex: AgentRuntimeAvailabilityProbeResult(isAvailable: false, checkedAt: checkedAt), + ] + ) + } + + let response = await handler.handle( + envelope: CommandEnvelope(output: .json, command: .profiles(ProfilesInput())) + ) + + #expect(response.ok) + #expect(response.schemaVersion == "prowl.cli.profiles.v1") + let data = try #require(response.data) + let payload = try data.decode(as: ProfilesCommandPayload.self) + #expect(payload.count == 3) + #expect(payload.profiles.map(\.id) == [available.id, unavailable.id, unknown.id].map(\.uuidString)) + #expect(payload.profiles.map(\.enabled) == [true, false, true]) + #expect(payload.profiles.map(\.runtime) == ["claude", "codex", "amp"]) + #expect(payload.profiles[0].availability == ProfilesCommandAvailability(status: .available)) + #expect( + payload.profiles[1].availability + == ProfilesCommandAvailability( + status: .unavailable, + reason: "Codex is not on your shell's PATH" + ) + ) + #expect( + payload.profiles[2].availability + == ProfilesCommandAvailability( + status: .unknown, + reason: "Availability check has not completed" + ) + ) + } + + @Test func snapshotFailureReturnsProfilesFailed() async { + struct SnapshotError: Error {} + let handler = ProfilesCommandHandler { throw SnapshotError() } + + let response = await handler.handle( + envelope: CommandEnvelope(output: .json, command: .profiles(ProfilesInput())) + ) + + #expect(!response.ok) + #expect(response.schemaVersion == "prowl.cli.profiles.v1") + #expect(response.error?.code == CLIErrorCode.profilesFailed) + } +} diff --git a/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift b/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift index 0523bd7c..407e6eaf 100644 --- a/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift +++ b/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift @@ -14,14 +14,142 @@ struct WorktreeTerminalStateAgentProfileTests { let plan = makePlan( dedicatedHome: URL(filePath: "/tmp/prowl-test-outside-base/home", directoryHint: .isDirectory) ) + let request = AgentProfileLaunchRequest( + plan: plan, + placement: .tab(background: false) + ) - let surfaceID = state.launchAgentProfile(plan) + let result = state.launchAgentProfile(request) - #expect(surfaceID == nil) + #expect(result == .failure(.homeProvisioningFailed)) #expect(state.tabManager.tabs.isEmpty) #expect(state.launchProfilesBySurface.isEmpty) } + @Test func explicitAnchorWinsOverCurrentSelectionAndReturnsBothIdentities() throws { + let state = makeState() + let anchor = try state.launchAgentProfile( + AgentProfileLaunchRequest( + plan: makePlan(dedicatedHome: nil), + placement: .tab(background: false) + ) + ).get() + let selected = try state.launchAgentProfile( + AgentProfileLaunchRequest( + plan: makePlan(dedicatedHome: nil), + placement: .tab(background: false) + ) + ).get() + #expect(state.tabManager.selectedTabId == selected.tabID) + + let launched = try state.launchAgentProfile( + AgentProfileLaunchRequest( + plan: makePlan(dedicatedHome: nil, runtime: .claude), + placement: .split(anchor: anchor.surfaceID, direction: .left, background: false) + ) + ).get() + + #expect(launched.tabID == anchor.tabID) + #expect(launched.surfaceID != anchor.surfaceID) + #expect(state.tabID(containing: launched.surfaceID) == anchor.tabID) + #expect(state.focusedSurfaceId(in: anchor.tabID) == launched.surfaceID) + #expect(state.launchProfilesBySurface[launched.surfaceID]?.runtime == .claude) + } + + @Test func requestPlacementOverridesTheProfilePlan() throws { + let state = makeState() + let anchor = try state.launchAgentProfile( + AgentProfileLaunchRequest( + plan: makePlan(dedicatedHome: nil), + placement: .tab(background: false) + ) + ).get() + let plan = makePlan(dedicatedHome: nil, runtime: .claude, placement: .tab) + + let launched = try state.launchAgentProfile( + AgentProfileLaunchRequest( + plan: plan, + placement: .split(anchor: anchor.surfaceID, direction: .down, background: false) + ) + ).get() + + #expect(state.tabManager.tabs.count == 1) + #expect(launched.tabID == anchor.tabID) + #expect(state.tabID(containing: launched.surfaceID) == anchor.tabID) + } + + @Test func backgroundTabPreservesTheSelectedTabAndFocusedSurface() throws { + let state = makeState() + let foreground = try state.launchAgentProfile( + AgentProfileLaunchRequest( + plan: makePlan(dedicatedHome: nil), + placement: .tab(background: false) + ) + ).get() + + let background = try state.launchAgentProfile( + AgentProfileLaunchRequest( + plan: makePlan(dedicatedHome: nil, runtime: .claude), + placement: .tab(background: true) + ) + ).get() + + #expect(background.tabID != foreground.tabID) + #expect(background.surfaceID != foreground.surfaceID) + #expect(state.tabManager.selectedTabId == foreground.tabID) + #expect(state.currentFocusedSurfaceId() == foreground.surfaceID) + #expect(state.tabID(containing: background.surfaceID) == background.tabID) + } + + @Test func backgroundSplitPreservesTheAnchorFocus() throws { + let state = makeState() + let anchor = try state.launchAgentProfile( + AgentProfileLaunchRequest( + plan: makePlan(dedicatedHome: nil), + placement: .tab(background: false) + ) + ).get() + + let background = try state.launchAgentProfile( + AgentProfileLaunchRequest( + plan: makePlan(dedicatedHome: nil, runtime: .claude), + placement: .split(anchor: anchor.surfaceID, direction: .right, background: true) + ) + ).get() + + #expect(background.tabID == anchor.tabID) + #expect(state.focusedSurfaceId(in: anchor.tabID) == anchor.surfaceID) + #expect(state.currentFocusedSurfaceId() == anchor.surfaceID) + } + + @Test func explicitSplitFailureDoesNotFallBackToATab() { + let state = makeState() + let missingAnchor = UUID() + + let result = state.launchAgentProfile( + AgentProfileLaunchRequest( + plan: makePlan(dedicatedHome: nil, placement: .split), + placement: .split(anchor: missingAnchor, direction: .right, background: false) + ) + ) + + #expect(result == .failure(.splitCreationFailed(.anchorNotFound(missingAnchor)))) + #expect(state.tabManager.tabs.isEmpty) + } + + @Test func compatibilityWrapperKeepsSplitToTabFallback() throws { + let state = makeState() + + let surfaceID = try #require( + state.launchAgentProfile( + makePlan(dedicatedHome: nil, placement: .split) + ) + ) + + #expect(state.tabManager.tabs.count == 1) + #expect(state.tabID(containing: surfaceID) == state.tabManager.tabs.first?.id) + } + @Test func launchProfileNameOnlyAppliesToTheLaunchedRuntime() { let state = makeState() let surfaceID = UUID()