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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 45 additions & 7 deletions ProwlCLI/Commands/HandoffCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@ struct HandoffToCommand: ParsableCommand {

@Argument(
help:
"The agent to hand off to. Launch supported: \(HandoffAgentSupport.launchableAgentsDescription); use --no-launch for other detected agents."
"The agent to hand off to. Required unless --agent-profile-id is provided. Launch supported: \(HandoffAgentSupport.launchableAgentsDescription); use --no-launch for other detected agents."
)
var agent: String
var agent: String?

@Argument(help: "Source pane/tab UUID or worktree id/name/path (defaults to the calling pane).")
var target: String?
Expand All @@ -127,13 +127,43 @@ struct HandoffToCommand: ParsableCommand {
@Option(name: .long, help: "Optional note appended to the handoff log.")
var note: String?

@Option(
name: .customLong("agent-profile-id"),
help: "Receiving Prowl Agent Profile UUID. Cannot be combined with the agent argument."
)
var agentProfileID: String?

@Flag(name: .customLong("no-launch"), help: "Archive + save only; do not launch the receiving agent.")
var noLaunch = false

mutating func run() throws {
try CLIExecution.run(command: "handoff", output: options.outputMode, colorEnabled: options.colorEnabled) {
let rawAgent = agent.lowercased()
guard let normalizedAgent = HandoffAgentSupport.normalize(rawAgent) else {
struct ReceivingTarget: Equatable {
let agent: String?
let profileID: UUID?
}

func resolveReceivingTarget() throws -> ReceivingTarget {
switch (agent, agentProfileID) {
case (nil, nil):
throw ExitError(
code: CLIErrorCode.invalidArgument,
message: "handoff to requires exactly one receiver: an agent argument or --agent-profile-id <uuid>."
)
case (.some, .some):
throw ExitError(
code: CLIErrorCode.invalidArgument,
message:
"handoff to with --agent-profile-id does not accept positional arguments; select the source with --pane, --tab, or --worktree."
)
case (nil, .some(let rawProfileID)):
guard let profileID = UUID(uuidString: rawProfileID) else {
throw ExitError(
code: CLIErrorCode.invalidArgument,
message: "--agent-profile-id requires a valid UUID."
)
}
return ReceivingTarget(agent: nil, profileID: profileID)
case (.some(let rawAgent), nil):
guard let normalizedAgent = HandoffAgentSupport.normalize(rawAgent.lowercased()) else {
throw ExitError(
code: CLIErrorCode.invalidArgument,
message: "handoff to requires an agent of: \(HandoffAgentSupport.supportedAgentsDescription)."
Expand All @@ -146,14 +176,22 @@ struct HandoffToCommand: ParsableCommand {
"handoff can only launch: \(HandoffAgentSupport.launchableAgentsDescription). Use --no-launch for other agents."
)
}
return ReceivingTarget(agent: normalizedAgent, profileID: nil)
}
}

mutating func run() throws {
try CLIExecution.run(command: "handoff", output: options.outputMode, colorEnabled: options.colorEnabled) {
let receivingTarget = try resolveReceivingTarget()
let resolvedBrief = try briefOptions.resolve()
let envelope = CommandEnvelope(
output: options.outputMode,
command: .handoff(
HandoffInput(
action: .toAgent,
selector: try selector.resolve(positionalTarget: target),
toAgent: normalizedAgent,
toAgent: receivingTarget.agent,
toProfileID: receivingTarget.profileID,
note: note,
launch: !noLaunch,
brief: resolvedBrief.brief,
Expand Down
4 changes: 4 additions & 0 deletions ProwlCLI/Output/OutputRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,10 @@ enum OutputRenderer {
if let archived = payload.archivedPath {
lines.append(" \("archived:".dim) \(archived)")
}
if let profileID = payload.toProfileID {
let profileName = payload.toProfileName ?? "Unknown Profile"
lines.append(" \("profile:".dim) \(profileName.green) \(profileID.uuidString.dim)")
}
lines.append(contentsOf: renderHandoffBriefing(payload.briefing))
lines.append(contentsOf: renderHandoffSession(payload.sessionContext))
if let pane = payload.launchedPane {
Expand Down
50 changes: 50 additions & 0 deletions ProwlCLITests/HandoffCommandParsingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ final class HandoffCommandParsingTests: XCTestCase {
let command = try HandoffToCommand.parse(["claude", "App"])

XCTAssertEqual(command.agent, "claude")
XCTAssertEqual(
try command.resolveReceivingTarget(),
HandoffToCommand.ReceivingTarget(agent: "claude", profileID: nil)
)
XCTAssertEqual(
try command.selector.resolve(positionalTarget: command.target),
.auto("App")
Expand All @@ -77,4 +81,50 @@ final class HandoffCommandParsingTests: XCTestCase {
XCTAssertTrue(command.briefOptions.noBrief)
XCTAssertTrue(command.noLaunch)
}

func testToAcceptsProfileWithoutSourceSelector() throws {
let profileID = UUID()
let command = try HandoffToCommand.parse(["--agent-profile-id", profileID.uuidString])

XCTAssertNil(command.agent)
XCTAssertNil(command.target)
XCTAssertEqual(
try command.resolveReceivingTarget(),
HandoffToCommand.ReceivingTarget(agent: nil, profileID: profileID)
)
XCTAssertEqual(try command.selector.resolve(positionalTarget: command.target), .none)
}

func testToProfileAcceptsExplicitSelectorAndNoLaunch() throws {
let profileID = UUID()
let command = try HandoffToCommand.parse([
"--agent-profile-id", profileID.uuidString,
"--pane", "p1",
"--no-launch",
])

XCTAssertEqual(try command.selector.resolve(positionalTarget: command.target), .pane("p1"))
XCTAssertTrue(command.noLaunch)
}

func testToRejectsMissingOrMultipleReceivingTargets() throws {
let profileID = UUID().uuidString
let missing = try HandoffToCommand.parse([])
let multiple = try HandoffToCommand.parse(["codex", "--agent-profile-id", profileID])

XCTAssertThrowsError(try missing.resolveReceivingTarget())
XCTAssertThrowsError(try multiple.resolveReceivingTarget())
}

func testToProfileRejectsPositionalSource() throws {
let command = try HandoffToCommand.parse(["App", "--agent-profile-id", UUID().uuidString])

XCTAssertThrowsError(try command.resolveReceivingTarget())
}

func testToProfileRejectsMalformedUUID() throws {
let command = try HandoffToCommand.parse(["--agent-profile-id", "not-a-uuid"])

XCTAssertThrowsError(try command.resolveReceivingTarget())
}
}
117 changes: 117 additions & 0 deletions ProwlCLITests/ProwlCLIIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1652,6 +1652,94 @@ final class ProwlCLIIntegrationTests: XCTestCase {
}
}

func testHandoffToProfileRoundTripsOverSocketWithoutSelector() throws {
let profileID = UUID()
let socketPath = temporarySocketPath(suffix: "handoff-to-profile")
let response = try CommandResponse(
ok: true,
command: "handoff",
schemaVersion: "prowl.cli.handoff.v2",
data: RawJSON(encoding: HandoffCommandPayload(
action: .toAgent,
artifactPath: "/Projects/App/.prowl/handoff/current.md",
outgoingAgent: "claude",
toAgent: "codex",
toProfileID: profileID,
toProfileName: "Codex Work"
))
)

let (requestData, result) = try runWithMockServer(
socketPath: socketPath,
response: response,
args: ["handoff", "to", "--agent-profile-id", profileID.uuidString, "--no-launch", "--json"]
)

XCTAssertEqual(result.exitCode, 0)
let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData)
if case .handoff(let input) = envelope.command {
XCTAssertEqual(input.action, .toAgent)
XCTAssertNil(input.toAgent)
XCTAssertEqual(input.toProfileID, profileID)
XCTAssertEqual(input.selector, .none)
XCTAssertFalse(input.launch)
} else {
XCTFail("Expected handoff command envelope")
}

let payload = try jsonObject(from: result.stdout)
XCTAssertEqual(payload["schema_version"] as? String, "prowl.cli.handoff.v2")
let data = try XCTUnwrap(payload["data"] as? [String: Any])
XCTAssertEqual(data["to_agent"] as? String, "codex")
XCTAssertEqual(data["to_profile_id"] as? String, profileID.uuidString)
XCTAssertEqual(data["to_profile_name"] as? String, "Codex Work")
}

func testHandoffToProfileAcceptsExplicitSelector() throws {
let profileID = UUID()
let socketPath = temporarySocketPath(suffix: "handoff-to-profile-pane")
let response = try CommandResponse(
ok: true,
command: "handoff",
schemaVersion: "prowl.cli.handoff.v2",
data: RawJSON(encoding: makeHandoffPayload(action: .toAgent))
)

let (requestData, result) = try runWithMockServer(
socketPath: socketPath,
response: response,
args: ["handoff", "to", "--agent-profile-id", profileID.uuidString, "--pane", "p1", "--json"]
)

XCTAssertEqual(result.exitCode, 0)
let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData)
if case .handoff(let input) = envelope.command {
XCTAssertEqual(input.toProfileID, profileID)
XCTAssertEqual(input.selector, .pane("p1"))
} else {
XCTFail("Expected handoff command envelope")
}
}

func testHandoffToProfileValidationFailsBeforeTransport() throws {
let profileID = UUID().uuidString
let invalidArguments = [
["handoff", "to", "--json"],
["handoff", "to", "codex", "--agent-profile-id", profileID, "--json"],
["handoff", "to", "App", "--agent-profile-id", profileID, "--json"],
["handoff", "to", "--agent-profile-id", "not-a-uuid", "--json"],
]

for arguments in invalidArguments {
let result = try runProwl(args: arguments)

XCTAssertNotEqual(result.exitCode, 0, "Expected failure for \(arguments)")
let payload = try jsonObject(from: result.stdout)
let error = try XCTUnwrap(payload["error"] as? [String: Any])
XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument)
}
}

func testHandoffToNormalizesAgentCaseAndNoLaunch() throws {
let socketPath = temporarySocketPath(suffix: "handoff-to-no-launch")
let response = try CommandResponse(
Expand Down Expand Up @@ -1792,6 +1880,35 @@ final class ProwlCLIIntegrationTests: XCTestCase {
XCTAssertFalse(result.stdout.contains("use --no-launch handoff"), result.stdout)
}

func testHandoffToProfileTextIncludesProfileMetadataAndResolvedRuntime() throws {
let profileID = UUID()
let socketPath = temporarySocketPath(suffix: "handoff-to-profile-text")
let response = try CommandResponse(
ok: true,
command: "handoff",
schemaVersion: "prowl.cli.handoff.v2",
data: RawJSON(encoding: HandoffCommandPayload(
action: .toAgent,
artifactPath: "/Projects/App/.prowl/handoff/current.md",
outgoingAgent: "claude",
toAgent: "codex",
toProfileID: profileID,
toProfileName: "Codex Work"
))
)

let (_, result) = try runWithMockServer(
socketPath: socketPath,
response: response,
args: ["handoff", "to", "--agent-profile-id", profileID.uuidString]
)

XCTAssertEqual(result.exitCode, 0)
XCTAssertTrue(result.stdout.contains("claude → codex"), result.stdout)
XCTAssertTrue(result.stdout.contains("Codex Work"), result.stdout)
XCTAssertTrue(result.stdout.contains(profileID.uuidString), result.stdout)
}

// MARK: - Helpers

private func makeHandoffPayload(action: HandoffAction) -> HandoffCommandPayload {
Expand Down
3 changes: 3 additions & 0 deletions docs-ai/047-cross-agent-handoff/000-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,6 @@ cwd, which is weaker than the pid-anchored, ambiguity-safe native session identi
- Updated 2026-07-21: HUD request ownership — injected requests now need an
atomic single-transition claim, and fallback cancellation needs a visible commit
boundary — see [005-hud-request-ownership](005-hud-request-ownership.md).
- Updated 2026-08-01: planned Profile-aware receiving targets with UUID-bound HUD/CLI requests,
execution-time Profile resolution, and one shared background launch path — see
[053.007 profile-aware handoff](../053-agent-profiles/007-profile-aware-handoff.md).
20 changes: 14 additions & 6 deletions docs-ai/053-agent-profiles/000-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,9 @@ Prowl 不提供任何目录共享功能(V1 及以后均然)。希望在绑定 pr
"用户 home + 固定点目录"推广为 **config root** 参数:默认即 `~/.claude` /
`~/.codex`;Prowl 启动且绑定账号的 surface 以记录的 profile 推导 root(env 是
Prowl 自己设置的,root 是已知量,无需探测)。parsePath 的全局子串 marker 改为按
已知 root 前缀匹配。绑定 surface 的 resume invocation 必须携带同一环境 patch,
否则 CLI 会在默认 home 中查找 session。进程分类(argv)、OSC working/idle、
已知 root 前缀匹配。绑定 source surface 的 resume invocation 仍需携带同一环境 patch,
否则 CLI 会在默认 home 中查找 session;该 resume env 缝未在 V1 实现,且 053.007
只配置 handoff receiver,因此继续延期。进程分类(argv)、OSC working/idle、
child-env session id 证据与其余 runtime 均不受影响。用户手动携带自定义
`CLAUDE_CONFIG_DIR` / `CODEX_HOME` 启动的 agent 维持现状(agent 本体可检测、
session 身份不可得);读取 TUI 进程 env 反推 root 列为 follow-up。
Expand Down Expand Up @@ -265,8 +266,9 @@ agent 列表不变。
未来的 handoff 集成必须让选定 profile 同时贯穿两条路径:HUD 的注入请求与 CLI/fallback
处理器。只更新 `supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift` 会让 fallback
静默丢失 profile,因为 `supacode/CLIService/HandoffCommandHandler.swift` 目前会重建继承
配置。该阶段应扩展结构化的 handoff request/registry 边界;绝不能在请求开始后再读取
"当前 profile"。
配置。该阶段应扩展结构化的 handoff request/registry 边界;绝不能在请求开始后读取
ambient "当前/推荐 profile" 来改变目标。后续 053.007 绑定稳定 UUID,并允许在执行时
按该 UUID 读取最新持久化配置;这不是重新选择目标。

## 验证

Expand All @@ -285,8 +287,8 @@ agent 列表不变。
一个 surface(tab 或按 placement 的 split,空 worktree 时 split 退化为 tab)、推荐
变化后 surface 的 profile 身份保持稳定、纯 preset 启动不设任何环境变量。
- Session 检测测试:`AgentSessionResolver` 以注入的 config root 在 profile home 布局下
解析出 Claude/Codex session;默认 root 行为不变;绑定 surface 的 resume argv 携带
同一环境 patch
解析出 Claude/Codex session;默认 root 行为不变绑定 source surface 的 resume argv
环境传播未在 V1 实现,且 053.007 只配置 handoff receiver;该 source-side 缺口继续延期
- 手动验证:(a) 同一 runtime 的两个纯 preset(不同 model/effort)并排启动,确认共享同一
登录且 `--resume` 历史统一;(b) 两个账号绑定 profile 分别登录不同账号并排运行,确认
各 CLI 报告自己的身份;(c) 修改 repo 指定或启动其他 profile 后,确认只有后续启动的
Expand Down Expand Up @@ -331,6 +333,12 @@ agent 列表不变。

## Amendments

- Updated 2026-08-01: implemented Profile-aware handoff across HUD, CLI, request ownership, and the
shared Profile launch boundary, while retaining Runtime Default compatibility and keeping native
Codex profile selection in Extra Arguments — design in
[007-profile-aware-handoff.md](007-profile-aware-handoff.md), result in
[008-profile-aware-handoff-action.md](008-profile-aware-handoff-action.md).

- Updated 2026-07-31: **环境补丁语义从 surface-scoped 改为 launch-scoped** — onevcat
定位出"Agents 启动 → agent 退出 → 手动 codex 继承 profile env"的串号链,环境补丁
改为随 `env` 前缀只作用于 launched 进程(home 内联、override 值经 `PROWL_ENV_*`
Expand Down
6 changes: 4 additions & 2 deletions docs-ai/053-agent-profiles/006-launch-scoped-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ env CODEX_HOME='/Users/x/.prowl/agent-profiles/<uuid>' OPENAI_API_KEY="$PROWL_EN
这两条**不再需要**(surface 上只剩 carrier 变量,不继承恰是正确行为),从 follow-up
中撤销。
- restore/手动启动"不重放 override"从 known limitation 升格为规则本身。
- resume(handoff 波次)携带环境的缺口不变:结构化 resume 需要把同一组
token/carrier 语义带过去,仍归 handoff 波次。
- source resume 携带环境的缺口不变:结构化 resume 仍需把同一组 token/carrier
语义带过去,但 053.007 的最小 Profile-aware handoff 波次只配置接收端,不扩展
outgoing source 的 `AgentResumeRequest`;该缺口继续延期(见
[007-profile-aware-handoff.md](007-profile-aware-handoff.md))。

## 已知边界

Expand Down
Loading
Loading