From def9e4caa63ae66d0c789946f42c2981311c6335 Mon Sep 17 00:00:00 2001 From: Sunset Date: Sat, 1 Aug 2026 18:24:43 +0800 Subject: [PATCH 1/5] docs: plan profile-aware handoff --- CONTEXT.md | 29 +++ docs-ai/047-cross-agent-handoff/000-plan.md | 3 + docs-ai/053-agent-profiles/000-plan.md | 19 +- .../006-launch-scoped-environment.md | 6 +- .../007-profile-aware-handoff.md | 225 ++++++++++++++++++ ...01-handoff-targets-prowl-agent-profiles.md | 3 + ...0002-handoff-profile-identity-uses-uuid.md | 3 + ...-handoff-resolves-profiles-at-execution.md | 3 + ...off-cli-has-exclusive-receiving-targets.md | 3 + .../0005-handoff-owns-receiver-placement.md | 3 + ...0006-receiving-profile-is-authoritative.md | 3 + ...-handoff-reuses-profile-launch-pipeline.md | 3 + ...0008-hud-request-binds-receiving-target.md | 3 + 13 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 CONTEXT.md create mode 100644 docs-ai/053-agent-profiles/007-profile-aware-handoff.md create mode 100644 docs/adr/0001-handoff-targets-prowl-agent-profiles.md create mode 100644 docs/adr/0002-handoff-profile-identity-uses-uuid.md create mode 100644 docs/adr/0003-handoff-resolves-profiles-at-execution.md create mode 100644 docs/adr/0004-handoff-cli-has-exclusive-receiving-targets.md create mode 100644 docs/adr/0005-handoff-owns-receiver-placement.md create mode 100644 docs/adr/0006-receiving-profile-is-authoritative.md create mode 100644 docs/adr/0007-handoff-reuses-profile-launch-pipeline.md create mode 100644 docs/adr/0008-hud-request-binds-receiving-target.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..8eac46230 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,29 @@ +# Agent Launch and Handoff + +This context defines the identities used when Prowl launches a coding agent or transfers a task to a receiving agent. + +## Language + +**Codex Config Profile**: +A named configuration layer owned by Codex and selected for a Codex launch within the active Codex home. +_Avoid_: Codex profile, Prowl profile + +**Prowl Agent Profile**: +A named Prowl-owned launch configuration for one supported agent runtime, optionally representing a distinct account. +_Avoid_: Codex profile, config profile, preset + +**Receiving Profile**: +The Prowl Agent Profile selected to launch the agent that takes over a handoff. +_Avoid_: Destination profile, receiving Codex profile + +**Runtime Default Target**: +A receiving target that launches a supported agent runtime without selecting a Prowl Agent Profile. +_Avoid_: Bare agent, empty profile + +**Receiving Target**: +The launch choice for the agent taking over a handoff: either a Receiving Profile or a Runtime Default Target. +_Avoid_: Destination string, target agent token + +**Profile-Aware Handoff**: +A handoff that launches its receiving agent with a selected Prowl Agent Profile instead of only a runtime default. +_Avoid_: Codex-profile handoff, profiled handoff diff --git a/docs-ai/047-cross-agent-handoff/000-plan.md b/docs-ai/047-cross-agent-handoff/000-plan.md index 12da90f47..f3274eaec 100644 --- a/docs-ai/047-cross-agent-handoff/000-plan.md +++ b/docs-ai/047-cross-agent-handoff/000-plan.md @@ -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). diff --git a/docs-ai/053-agent-profiles/000-plan.md b/docs-ai/053-agent-profiles/000-plan.md index 68e133c32..fa9b460b3 100644 --- a/docs-ai/053-agent-profiles/000-plan.md +++ b/docs-ai/053-agent-profiles/000-plan.md @@ -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。 @@ -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 读取最新持久化配置;这不是重新选择目标。 ## 验证 @@ -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 后,确认只有后续启动的 @@ -331,6 +333,11 @@ agent 列表不变。 ## Amendments +- Updated 2026-08-01: planned 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 — see + [007-profile-aware-handoff.md](007-profile-aware-handoff.md). + - Updated 2026-07-31: **环境补丁语义从 surface-scoped 改为 launch-scoped** — onevcat 定位出"Agents 启动 → agent 退出 → 手动 codex 继承 profile env"的串号链,环境补丁 改为随 `env` 前缀只作用于 launched 进程(home 内联、override 值经 `PROWL_ENV_*` diff --git a/docs-ai/053-agent-profiles/006-launch-scoped-environment.md b/docs-ai/053-agent-profiles/006-launch-scoped-environment.md index 68e732a58..c94be59b2 100644 --- a/docs-ai/053-agent-profiles/006-launch-scoped-environment.md +++ b/docs-ai/053-agent-profiles/006-launch-scoped-environment.md @@ -58,8 +58,10 @@ env CODEX_HOME='/Users/x/.prowl/agent-profiles/' 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))。 ## 已知边界 diff --git a/docs-ai/053-agent-profiles/007-profile-aware-handoff.md b/docs-ai/053-agent-profiles/007-profile-aware-handoff.md new file mode 100644 index 000000000..53308cd81 --- /dev/null +++ b/docs-ai/053-agent-profiles/007-profile-aware-handoff.md @@ -0,0 +1,225 @@ +# 053.007 — Profile-aware handoff + +| | | +| --- | --- | +| **Date** | 2026-08-01 | +| **Status** | Planned | +| **Primary PRs** | TBD | +| **Related** | [053 plan](000-plan.md), [053.006](006-launch-scoped-environment.md), [047.004](../047-cross-agent-handoff/004-inline-handoff-redesign.md), [047.005](../047-cross-agent-handoff/005-hud-request-ownership.md), [049 Agents HUD](../049-agents-toolbar-entry/000-plan.md), [048 runtime adapters](../048-agent-runtime-adapters/000-plan.md), [handoff manual](../../docs/components/handoff.md) | + +## Context + +The Hand Off HUD and `prowl handoff to` currently identify a receiver only by runtime token. +The CLI handler rebuilds a small inherited `AgentLaunchConfiguration`, while the HUD fallback +independently renders a runtime invocation and creates a tab. Neither route can select a Prowl +Agent Profile, so they lose the profile's model, effort, extra arguments, launch-scoped environment, +Dedicated Home, account, and surface identity. + +This follow-up closes the seam reserved by 053. A native Codex Config Profile remains ordinary +profile configuration: users can already put `-p work` in a Prowl Agent Profile's Extra Arguments. +This wave does not add a Codex-specific field or change the `AgentProfile` persistence schema. + +## Goals + +- Let the HUD and CLI select an enabled Prowl Agent Profile by stable UUID. +- Preserve Runtime Default Claude Code/Codex targets and their existing inheritance behavior. +- Make inline CLI and HUD fallback handoffs execute the same complete profile launch plan. +- Keep artifact, request-ownership, background-launch, notification, and HUD-focus semantics honest + across preflight and receiver-launch failures. +- Keep profile configuration and secrets out of handoff requests, artifacts, logs, and responses. + +### Non-goals + +- A dedicated Codex Config Profile editor field; `-p ` stays in Extra Arguments. +- Propagating the outgoing pane's Profile, Dedicated Home, environment, or account into source + resume/fork collection. +- Removing runtime-default targets or changing their model/unrestricted inheritance. +- Honoring a Receiving Profile's manual-launch `Open In` placement during handoff. +- Detecting whether the launched CLI remains healthy after its terminal surface is created. +- Adding automatic HUD timeouts, Profile import/export, or more runtimes. + +## Contract + +### Receiving Target + +Introduce one runtime-neutral domain value with two cases: + +```swift +enum HandoffReceivingTarget { + case runtimeDefault(DetectedAgent) + case profile(AgentProfile.ID) +} +``` + +An enabled Profile is shown as a Receiving Profile. Runtime Default remains the compatibility path. +The HUD orders targets as Recommended Profile, remaining enabled Profiles in Settings order, then +Runtime Defaults, then Brief Only. With no enabled Profiles, its target list behaves as it does now. + +Profile names are presentation only. The request carries the UUID; runtime, display name, and launch +configuration are resolved from the latest persisted Profile after briefing collection and before +artifact commit, then frozen in memory for that execution. A missing or disabled Profile, or a launch +planning error, fails before any handoff artifact is mutated. + +### CLI and wire format + +Keep the existing form and add one mutually exclusive Profile form: + +```bash +prowl handoff to codex [source] [options] +prowl handoff to --agent-profile-id [options] +prowl handoff to --agent-profile-id --pane [options] +``` + +- The runtime positional argument and `--agent-profile-id` are exactly-one. +- Runtime handoffs retain the optional positional source for compatibility. +- Profile handoffs accept no positional source; an explicit source uses `--pane`, `--tab`, or + `--worktree`. With no selector, caller-pane self-handoff resolution remains unchanged. +- CLI parsing rejects both, neither, and malformed UUID cases before transport; the app handler + repeats the invariant for direct or older clients. +- `HandoffInput` adds optional `to_profile_id`. Successful payloads add optional + `to_profile_id` and frozen `to_profile_name` while keeping `to_agent` as the resolved runtime. + These additive fields remain in `prowl.cli.handoff.v2`. +- `--no-launch` still resolves an existing, enabled Profile and records its frozen identity, but it + does not compile/provision a launch, create a surface, or update Last Launched Profile. + +### HUD request ownership and completion + +Bind each HUD request UUID to its source pane plus exact operation: checkpoint, Runtime Default +handoff, or Profile handoff. A handoff operation also requires `launch == true`. The injected command +uses the selected Profile UUID and an explicit source-pane selector; it never embeds the Profile name. + +Registry claim distinguishes claimed, target mismatch, superseded, duplicate, and unknown requests. +A mismatch returns a stable CLI error before briefing or artifact work and does not consume the +pending request, so the source agent may correct and retry. A fallback must still atomically supersede +the pending inline request before collecting ownership of the transition. + +A mismatch is deliberately a retryable, non-terminal HUD event: the HUD keeps waiting and retains its +existing Fork Briefing, Context Only, and Cancel exits. Exactly-once terminal completion begins only +after a matching claim; this wave does not add an automatic timeout. + +Once a matching request is claimed, the handler emits exactly one typed completion: success with +not-applicable/skipped/launched disposition, or failure with an `artifactsReady` flag. This lets the HUD +terminate on Profile/preflight failure and distinguish it from a post-commit receiver-launch failure; +the current success-only completion must not leave a claimed request waiting forever. + +### Execution and failure boundary + +The Profile path executes in this order: + +```text +claim matching HUD request, if any +→ collect/validate briefing without artifact writes +→ resolve latest enabled Profile and compile prompted launch plan +→ commit transition artifacts +→ launch through the shared Profile surface boundary +→ append transition log and publish response/completion +``` + +The Receiving Profile is authoritative for model, effort, execution mode, Extra Arguments, +launch-scoped environment, Dedicated Home, account, and identity. It never inherits launch settings +from the outgoing agent. Runtime Default keeps the current explicit-model and observed-unrestricted +inheritance. + +| Failure point | Required outcome | +| --- | --- | +| Invalid target, missing/disabled Profile, or plan failure | Error before artifact mutation; no launch-memory update | +| Artifact commit failure | Error; no receiver launch; do not claim a complete artifact set | +| Dedicated Home or surface creation failure after commit | Retain artifacts/archive, append `launch=failed`, report “progress saved, receiver not launched,” no launch-memory update | +| `--no-launch` | Successful archive-only result with `launch=skipped`; no provision, surface, or launch-memory update | +| Surface created | Record exact pane, notify, and emit the existing Profile launch-success event | + +### Shared Profile launch boundary + +Extend `AgentProfileLaunchPlanner.plan` with an `AgentStartIntent` parameter defaulting to +`.interactive`; handoff passes `.prompt(kickoffPrompt)`. The adapter therefore keeps Extra Arguments +such as Codex `-p work` before the positional prompt and remains the sole quoting authority. + +Add a handoff launch context and typed result to the existing Profile terminal path. The handoff +context always creates a new background tab at the handoff root, ignores Profile tab/split placement, +preserves launch-scoped environment and Dedicated Home provisioning, and records Profile identity. +The result returns the exact tab and surface IDs without a post-creation resolver lookup. + +`WorktreeTerminalState` and `WorktreeTerminalManager` stay synchronous on `@MainActor`. +`TerminalClient` exposes a synchronous main-actor Profile-launch closure so CLI and fallback callers +receive the typed result; they must not infer success from the fire-and-forget command path. Artifact +work may stay detached, but launch crosses back to the main actor. + +`WorktreeTerminalManager` remains the one executor and emits the existing success/failure events. +Only a created surface updates per-repository Last Launched Profile through +`AppFeature+TerminalEvents`. Ordinary Profile launches retain their configured placement. + +Core and CLI launches never focus the receiver. If the initiating HUD still exists and is waiting for +that exact completion, it focuses the returned pane after success. A dismissed HUD and an ordinary CLI +handoff leave the receiver in the background; launch failure leaves the user on the source pane. + +### Output and privacy + +The response and internal completion may expose only resolved runtime, Profile UUID, and frozen +single-line display name. The append-only log adds those same optional fields and sanitizes Profile +names against newline/quote injection. Archive filenames continue to use the runtime token. Extra +Arguments, environment names/values, carrier values, Dedicated Home paths, and credentials never enter +the handoff request, response, artifact, or log. + +## Implementation plan + +1. Add the exclusive CLI option, optional wire/payload fields, text rendering, and request-mismatch + error in `ProwlCLI/Commands/HandoffCommand.swift`, `ProwlCLI/Output/OutputRenderer.swift`, + `supacode/CLIService/Shared/InputModels.swift`, `supacode/CLIService/Shared/HandoffCommandPayload.swift`, + and `supacode/CLIService/Shared/ErrorCodes.swift`. +2. Add Receiving Target/request-expectation types and exact claim semantics in + `supacode/Domain/Handoff/HandoffRequestRegistry.swift`, + `supacode/Clients/Handoff/HandoffRequestClient.swift`, and + `supacode/Domain/Handoff/HandoffInjection.swift`. +3. Resolve and freeze Profiles, enforce target exclusivity, split preflight from artifact commit, + publish typed completion, and add sanitized Profile log metadata in + `supacode/CLIService/HandoffCommandHandler.swift` and + `supacode/Domain/Handoff/HandoffCoordinator.swift`. +4. Extend the shared planner and terminal launch boundary in + `supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift`, + `supacode/Clients/Terminal/TerminalClient.swift`, + `supacode/Features/Terminal/Models/WorktreeTerminalState.swift`, and + `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift`. +5. Build the ordered Profile/runtime target list, preserve execution-time Profile lookup in fallback, + correlate typed completion, and focus only from a waiting HUD in + `supacode/Features/App/Reducer/AppFeature+Handoff.swift`, + `supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift`, and + `supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift`. +6. Wire Profile resolution and the shared launcher once in `supacode/App/supacodeApp.swift`; retain the + existing Runtime Default launch branch and Profile success-event memory path. +7. Update current behavior in `docs/components/handoff.md`, `docs/components/cli.md`, + `docs/components/agent-profiles.md`, and `skills/prowl-cli/SKILL.md`; after implementation, record + actual files, tests, deviations, and PR references in a new 053 action/amendment file. + +## Verification + +- Parser and socket tests: Profile/runtime forms, both/neither/malformed UUID, explicit Profile source, + additive payload/text fields, `--no-launch`, and unchanged runtime compatibility. +- Registry/handler tests: source+operation binding, mismatch without consumption, supersession, + execution-time latest Profile, missing/disabled zero-side-effect rejection, authoritative Profile + configuration, Runtime Default inheritance, sanitized/no-secret output, and post-commit failure. +- Planner/terminal tests: prompted plan with `-p work`, environment and Dedicated Home intact; handoff + ignores split placement and creates a background tab; exact result and success/failure events. +- HUD/app tests: Recommended ordering, no-Profile fallback, UUID injection, completion correlation, + fallback execution-time re-resolution, partial-success messaging, exact-pane focus, and real router + `--no-launch` wiring. +- Required commands: focused Xcode suites, `swift test --filter HandoffCommandParsingTests`, + `make build-cli`, `make test-cli-smoke`, `make test-cli-integration`, `make check`, `make test`, and + `make build-app`. +- Manual GUI pass: a Codex Profile with Extra Arguments `-p work`, environment overrides, and Dedicated + Home through inline and fallback; deleted/disabled Profile; forced launch failure; Runtime Default + regression; verify command preview, JSON, logs, and artifacts contain no secret values. + +## Alternatives and decisions + +- Prowl Agent Profile instead of a Codex-only string keeps handoff runtime-neutral and reuses the full + launch contract; native Codex profiles remain encapsulated in Extra Arguments. +- UUID instead of display name survives rename and duplicate names. +- Execution-time resolution instead of configuration snapshots avoids credentials in requests and uses + the latest persisted configuration. +- Reusing the Profile planner/executor instead of copying argv/environment/home logic keeps inline, + fallback, preview, and ordinary launches consistent. +- Runtime Defaults remain available to avoid a migration cliff and preserve current behavior. + +## Open questions + +None for this wave. diff --git a/docs/adr/0001-handoff-targets-prowl-agent-profiles.md b/docs/adr/0001-handoff-targets-prowl-agent-profiles.md new file mode 100644 index 000000000..b43628136 --- /dev/null +++ b/docs/adr/0001-handoff-targets-prowl-agent-profiles.md @@ -0,0 +1,3 @@ +# Profile-aware handoff targets Prowl Agent Profiles + +A profile-aware handoff identifies its receiving configuration with a Prowl Agent Profile rather than accepting runtime-specific input such as a Codex Config Profile name. Existing Runtime Default Targets remain supported for backward compatibility. This keeps the handoff contract runtime-neutral and lets the inline CLI path and HUD fallback launch the same complete profile configuration; native runtime profiles remain encapsulated by the selected Prowl Agent Profile. diff --git a/docs/adr/0002-handoff-profile-identity-uses-uuid.md b/docs/adr/0002-handoff-profile-identity-uses-uuid.md new file mode 100644 index 000000000..54b8f1018 --- /dev/null +++ b/docs/adr/0002-handoff-profile-identity-uses-uuid.md @@ -0,0 +1,3 @@ +# Handoff profile identity uses UUID + +A profile-aware handoff carries the selected Prowl Agent Profile's stable UUID across the HUD, injected CLI request, socket payload, and fallback path; mutable display names are used only for presentation and logs. This prevents a rename or duplicate name from resolving the handoff to a different account or launch configuration. diff --git a/docs/adr/0003-handoff-resolves-profiles-at-execution.md b/docs/adr/0003-handoff-resolves-profiles-at-execution.md new file mode 100644 index 000000000..5c29f7868 --- /dev/null +++ b/docs/adr/0003-handoff-resolves-profiles-at-execution.md @@ -0,0 +1,3 @@ +# Handoff resolves profiles at execution + +A profile-aware handoff carries only the Prowl Agent Profile UUID and resolves its latest persisted configuration when the transition executes. A missing or disabled profile fails before any artifact, archive, or log mutation; Prowl does not snapshot profile configuration or place environment values and credentials in the handoff request. The same validation applies to `--no-launch`: a successful archive-only transition records the resolved profile identity but neither creates a receiver surface nor updates `Last Launched Profile`. diff --git a/docs/adr/0004-handoff-cli-has-exclusive-receiving-targets.md b/docs/adr/0004-handoff-cli-has-exclusive-receiving-targets.md new file mode 100644 index 000000000..1222e341a --- /dev/null +++ b/docs/adr/0004-handoff-cli-has-exclusive-receiving-targets.md @@ -0,0 +1,3 @@ +# Handoff CLI has exclusive receiving targets + +`prowl handoff to` accepts exactly one receiving target: the existing runtime argument such as `codex`, or `--agent-profile-id `. The profile form derives its runtime from the resolved Prowl Agent Profile, so the protocol cannot represent a contradictory agent token and profile runtime; display names remain presentation-only. Because the existing positional grammar assigns its first value to the runtime and its second to the source, the profile form accepts an explicitly named source only through `--pane`, `--tab`, or `--worktree`; omitting a selector still means the calling pane. Runtime-targeted handoffs retain the existing optional positional source for backward compatibility. diff --git a/docs/adr/0005-handoff-owns-receiver-placement.md b/docs/adr/0005-handoff-owns-receiver-placement.md new file mode 100644 index 000000000..8cc165eb4 --- /dev/null +++ b/docs/adr/0005-handoff-owns-receiver-placement.md @@ -0,0 +1,3 @@ +# Handoff owns receiver placement + +A profile-aware handoff always creates its receiving agent in a new background tab, ignoring the selected Prowl Agent Profile's manual-launch `Open In` placement. The profile still supplies its runtime configuration, environment, account, and identity. An ordinary CLI handoff, or a handoff whose HUD is no longer waiting, leaves the receiver in the background; a HUD that is still waiting for that request focuses the exact launched receiver pane after success. This preserves the core launcher's no-layout-disruption contract while keeping the existing HUD completion behavior. diff --git a/docs/adr/0006-receiving-profile-is-authoritative.md b/docs/adr/0006-receiving-profile-is-authoritative.md new file mode 100644 index 000000000..9a039110b --- /dev/null +++ b/docs/adr/0006-receiving-profile-is-authoritative.md @@ -0,0 +1,3 @@ +# Receiving Profile is authoritative + +When a handoff selects a Prowl Agent Profile, that profile exclusively determines the receiver's model, reasoning effort, execution mode, extra arguments, environment, and account; no launch configuration is inherited from the outgoing agent. Existing model and explicitly observed unrestricted-mode inheritance remains only for Runtime Default Targets, preventing an explicit Standard profile from being silently escalated. diff --git a/docs/adr/0007-handoff-reuses-profile-launch-pipeline.md b/docs/adr/0007-handoff-reuses-profile-launch-pipeline.md new file mode 100644 index 000000000..1fff243ed --- /dev/null +++ b/docs/adr/0007-handoff-reuses-profile-launch-pipeline.md @@ -0,0 +1,3 @@ +# Handoff reuses the Profile launch pipeline + +Profile-aware handoff extends and reuses the existing Agent Profile launch planner and surface-launch boundary instead of duplicating profile command, Dedicated Home, environment, and identity handling inside handoff. The shared path accepts a prompted start intent and a handoff-owned background-tab context, while ordinary profile launches retain their current interactive intent and placement behavior. diff --git a/docs/adr/0008-hud-request-binds-receiving-target.md b/docs/adr/0008-hud-request-binds-receiving-target.md new file mode 100644 index 000000000..a51451881 --- /dev/null +++ b/docs/adr/0008-hud-request-binds-receiving-target.md @@ -0,0 +1,3 @@ +# HUD request binds its source and operation + +Each HUD-generated request binds its request UUID to the source pane and exact operation selected by the user: checkpoint, Runtime Default handoff, or Profile-aware handoff with launch enabled. The CLI service claims the request only when the source and operation match. A mismatch rejects that command before briefing or artifact mutation without consuming the pending request, so the source agent may correct and retry. Ordinary CLI handoffs without a HUD request UUID remain independent, and fallback supersession continues to invalidate the original request. From 8b85e88aa454228e2ca438fac14d007f114e7dd8 Mon Sep 17 00:00:00 2001 From: Sunset Date: Sat, 1 Aug 2026 21:39:42 +0800 Subject: [PATCH 2/5] docs: simplify profile-aware handoff plan --- CONTEXT.md | 29 -- .../007-profile-aware-handoff.md | 274 +++++------------- ...01-handoff-targets-prowl-agent-profiles.md | 3 - ...0002-handoff-profile-identity-uses-uuid.md | 3 - ...-handoff-resolves-profiles-at-execution.md | 3 - ...off-cli-has-exclusive-receiving-targets.md | 3 - .../0005-handoff-owns-receiver-placement.md | 3 - ...0006-receiving-profile-is-authoritative.md | 3 - ...-handoff-reuses-profile-launch-pipeline.md | 3 - ...0008-hud-request-binds-receiving-target.md | 3 - 10 files changed, 80 insertions(+), 247 deletions(-) delete mode 100644 CONTEXT.md delete mode 100644 docs/adr/0001-handoff-targets-prowl-agent-profiles.md delete mode 100644 docs/adr/0002-handoff-profile-identity-uses-uuid.md delete mode 100644 docs/adr/0003-handoff-resolves-profiles-at-execution.md delete mode 100644 docs/adr/0004-handoff-cli-has-exclusive-receiving-targets.md delete mode 100644 docs/adr/0005-handoff-owns-receiver-placement.md delete mode 100644 docs/adr/0006-receiving-profile-is-authoritative.md delete mode 100644 docs/adr/0007-handoff-reuses-profile-launch-pipeline.md delete mode 100644 docs/adr/0008-hud-request-binds-receiving-target.md diff --git a/CONTEXT.md b/CONTEXT.md deleted file mode 100644 index 8eac46230..000000000 --- a/CONTEXT.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Launch and Handoff - -This context defines the identities used when Prowl launches a coding agent or transfers a task to a receiving agent. - -## Language - -**Codex Config Profile**: -A named configuration layer owned by Codex and selected for a Codex launch within the active Codex home. -_Avoid_: Codex profile, Prowl profile - -**Prowl Agent Profile**: -A named Prowl-owned launch configuration for one supported agent runtime, optionally representing a distinct account. -_Avoid_: Codex profile, config profile, preset - -**Receiving Profile**: -The Prowl Agent Profile selected to launch the agent that takes over a handoff. -_Avoid_: Destination profile, receiving Codex profile - -**Runtime Default Target**: -A receiving target that launches a supported agent runtime without selecting a Prowl Agent Profile. -_Avoid_: Bare agent, empty profile - -**Receiving Target**: -The launch choice for the agent taking over a handoff: either a Receiving Profile or a Runtime Default Target. -_Avoid_: Destination string, target agent token - -**Profile-Aware Handoff**: -A handoff that launches its receiving agent with a selected Prowl Agent Profile instead of only a runtime default. -_Avoid_: Codex-profile handoff, profiled handoff diff --git a/docs-ai/053-agent-profiles/007-profile-aware-handoff.md b/docs-ai/053-agent-profiles/007-profile-aware-handoff.md index 53308cd81..0aced4491 100644 --- a/docs-ai/053-agent-profiles/007-profile-aware-handoff.md +++ b/docs-ai/053-agent-profiles/007-profile-aware-handoff.md @@ -9,217 +9,103 @@ ## Context -The Hand Off HUD and `prowl handoff to` currently identify a receiver only by runtime token. -The CLI handler rebuilds a small inherited `AgentLaunchConfiguration`, while the HUD fallback -independently renders a runtime invocation and creates a tab. Neither route can select a Prowl -Agent Profile, so they lose the profile's model, effort, extra arguments, launch-scoped environment, +Hand Off currently identifies a receiver only by runtime token. The CLI handler rebuilds an inherited +`AgentLaunchConfiguration`, while HUD fallback separately renders an invocation and creates a tab. +Both routes therefore lose a Prowl Agent Profile's effort, Extra Arguments, launch-scoped environment, Dedicated Home, account, and surface identity. -This follow-up closes the seam reserved by 053. A native Codex Config Profile remains ordinary -profile configuration: users can already put `-p work` in a Prowl Agent Profile's Extra Arguments. -This wave does not add a Codex-specific field or change the `AgentProfile` persistence schema. +This follow-up closes the seam reserved by 053 without changing the `AgentProfile` persistence schema. +A native Codex Config Profile remains ordinary Extra Arguments such as `-p work`. -## Goals +## Scope -- Let the HUD and CLI select an enabled Prowl Agent Profile by stable UUID. -- Preserve Runtime Default Claude Code/Codex targets and their existing inheritance behavior. -- Make inline CLI and HUD fallback handoffs execute the same complete profile launch plan. -- Keep artifact, request-ownership, background-launch, notification, and HUD-focus semantics honest - across preflight and receiver-launch failures. -- Keep profile configuration and secrets out of handoff requests, artifacts, logs, and responses. +- Let HUD and CLI select an enabled Prowl Agent Profile by stable UUID. +- Keep Runtime Default Claude Code/Codex targets and their current inheritance behavior. +- Make inline CLI and HUD fallback use the same complete Profile launch path. +- Keep preflight, artifact, launch-memory, background/focus, and privacy boundaries explicit. -### Non-goals +Non-goals: a Codex-specific field; outgoing Profile/Home/environment propagation into source +resume/fork; more runtimes; Profile import/export; automatic HUD timeout; receiver process-health +checks; or honoring a Receiving Profile's manual-launch `Open In` placement. -- A dedicated Codex Config Profile editor field; `-p ` stays in Extra Arguments. -- Propagating the outgoing pane's Profile, Dedicated Home, environment, or account into source - resume/fork collection. -- Removing runtime-default targets or changing their model/unrestricted inheritance. -- Honoring a Receiving Profile's manual-launch `Open In` placement during handoff. -- Detecting whether the launched CLI remains healthy after its terminal surface is created. -- Adding automatic HUD timeouts, Profile import/export, or more runtimes. +## Alternatives & decisions -## Contract - -### Receiving Target - -Introduce one runtime-neutral domain value with two cases: - -```swift -enum HandoffReceivingTarget { - case runtimeDefault(DetectedAgent) - case profile(AgentProfile.ID) -} -``` - -An enabled Profile is shown as a Receiving Profile. Runtime Default remains the compatibility path. -The HUD orders targets as Recommended Profile, remaining enabled Profiles in Settings order, then -Runtime Defaults, then Brief Only. With no enabled Profiles, its target list behaves as it does now. - -Profile names are presentation only. The request carries the UUID; runtime, display name, and launch -configuration are resolved from the latest persisted Profile after briefing collection and before -artifact commit, then frozen in memory for that execution. A missing or disabled Profile, or a launch -planning error, fails before any handoff artifact is mutated. - -### CLI and wire format - -Keep the existing form and add one mutually exclusive Profile form: - -```bash -prowl handoff to codex [source] [options] -prowl handoff to --agent-profile-id [options] -prowl handoff to --agent-profile-id --pane [options] -``` - -- The runtime positional argument and `--agent-profile-id` are exactly-one. -- Runtime handoffs retain the optional positional source for compatibility. -- Profile handoffs accept no positional source; an explicit source uses `--pane`, `--tab`, or - `--worktree`. With no selector, caller-pane self-handoff resolution remains unchanged. -- CLI parsing rejects both, neither, and malformed UUID cases before transport; the app handler - repeats the invariant for direct or older clients. -- `HandoffInput` adds optional `to_profile_id`. Successful payloads add optional - `to_profile_id` and frozen `to_profile_name` while keeping `to_agent` as the resolved runtime. - These additive fields remain in `prowl.cli.handoff.v2`. -- `--no-launch` still resolves an existing, enabled Profile and records its frozen identity, but it - does not compile/provision a launch, create a surface, or update Last Launched Profile. - -### HUD request ownership and completion - -Bind each HUD request UUID to its source pane plus exact operation: checkpoint, Runtime Default -handoff, or Profile handoff. A handoff operation also requires `launch == true`. The injected command -uses the selected Profile UUID and an explicit source-pane selector; it never embeds the Profile name. - -Registry claim distinguishes claimed, target mismatch, superseded, duplicate, and unknown requests. -A mismatch returns a stable CLI error before briefing or artifact work and does not consume the -pending request, so the source agent may correct and retry. A fallback must still atomically supersede -the pending inline request before collecting ownership of the transition. - -A mismatch is deliberately a retryable, non-terminal HUD event: the HUD keeps waiting and retains its -existing Fork Briefing, Context Only, and Cancel exits. Exactly-once terminal completion begins only -after a matching claim; this wave does not add an automatic timeout. - -Once a matching request is claimed, the handler emits exactly one typed completion: success with -not-applicable/skipped/launched disposition, or failure with an `artifactsReady` flag. This lets the HUD -terminate on Profile/preflight failure and distinguish it from a post-commit receiver-launch failure; -the current success-only completion must not leave a claimed request waiting forever. - -### Execution and failure boundary - -The Profile path executes in this order: +| Area | Decision | +| --- | --- | +| Receiver identity | `HandoffReceivingTarget` is either `.runtimeDefault(DetectedAgent)` or `.profile(AgentProfile.ID)`. Names are presentation only. | +| HUD order | Recommended Profile first and selected by default, remaining enabled Profiles in Settings order, Runtime Defaults, then Brief Only. No enabled Profiles preserves today's list. | +| Native Codex profile | Keep `-p ` in Prowl Profile Extra Arguments; do not add a Codex-only field. | +| CLI grammar | Accept exactly one of runtime positional or `--agent-profile-id `. Runtime keeps optional positional source; Profile rejects positional source but may use `--pane`/`--tab`/`--worktree`. No selector still means caller pane. | +| Resolution | Bind UUID in the request; after briefing collection, resolve the latest persisted enabled Profile and freeze it before artifact commit. Missing, disabled, or unplannable fails before artifact mutation. | +| Launch authority | Receiving Profile exclusively supplies model, effort, execution mode, Extra Arguments, environment, Dedicated Home, account, and identity. Runtime Default alone retains source model/unrestricted inheritance. | +| Placement | Handoff always creates a new background tab and ignores Profile `Open In`. Only a still-waiting HUD focuses the exact returned pane after success. | +| `--no-launch` | Resolve and record an enabled Profile, but do not plan/provision a launch, create a surface, or update Last Launched Profile. | +| Request ownership | Bind HUD request UUID to source pane plus checkpoint/runtime/Profile operation with launch enabled. Mismatch returns `INVALID_ARGUMENT` before briefing/artifacts and leaves the request pending for correction; fallback atomically supersedes it. | +| Completion | Keep `HandoffCLICompletion` and `launched` optional. Add optional Profile identity plus `failureMessage` and `artifactsReady`; after a matching claim, publish exactly one terminal completion. | +| Output | Add optional `to_profile_id` and frozen `to_profile_name` while keeping `to_agent` as resolved runtime in `prowl.cli.handoff.v2`. Sanitize the name to one log line; archive filenames remain runtime-based. | +| Privacy | Never place Extra Arguments, environment, carrier values, home paths, or credentials in requests, payloads, artifacts, or logs. | + +## Execution design ```text claim matching HUD request, if any → collect/validate briefing without artifact writes -→ resolve latest enabled Profile and compile prompted launch plan +→ resolve enabled Profile and compile prompted launch plan → commit transition artifacts -→ launch through the shared Profile surface boundary -→ append transition log and publish response/completion +→ launch through the shared Profile terminal boundary +→ log and publish response/completion ``` -The Receiving Profile is authoritative for model, effort, execution mode, Extra Arguments, -launch-scoped environment, Dedicated Home, account, and identity. It never inherits launch settings -from the outgoing agent. Runtime Default keeps the current explicit-model and observed-unrestricted -inheritance. - -| Failure point | Required outcome | +- Add `intent: AgentStartIntent = .interactive` to `AgentProfileLaunchPlanner.plan`; handoff passes + `.prompt(kickoffPrompt)`, so adapters remain the sole argv ordering and quoting authority. +- Add a handoff-background context and a result containing exact tab/surface identity to the existing + Profile launcher. `WorktreeTerminalState` and `WorktreeTerminalManager` remain synchronous on + `@MainActor`; `TerminalClient` exposes that result-returning closure for CLI and fallback. +- `WorktreeTerminalManager` remains the sole Profile executor and emits existing success/failure events. + Only surface success updates Last Launched Profile through `AppFeature+TerminalEvents`. +- Build `HandoffLaunchedPane` directly from the launch result; do not create a surface and then perform + a fallible `TargetResolver` lookup. + +| Failure point | Outcome | | --- | --- | -| Invalid target, missing/disabled Profile, or plan failure | Error before artifact mutation; no launch-memory update | -| Artifact commit failure | Error; no receiver launch; do not claim a complete artifact set | -| Dedicated Home or surface creation failure after commit | Retain artifacts/archive, append `launch=failed`, report “progress saved, receiver not launched,” no launch-memory update | -| `--no-launch` | Successful archive-only result with `launch=skipped`; no provision, surface, or launch-memory update | -| Surface created | Record exact pane, notify, and emit the existing Profile launch-success event | - -### Shared Profile launch boundary - -Extend `AgentProfileLaunchPlanner.plan` with an `AgentStartIntent` parameter defaulting to -`.interactive`; handoff passes `.prompt(kickoffPrompt)`. The adapter therefore keeps Extra Arguments -such as Codex `-p work` before the positional prompt and remains the sole quoting authority. - -Add a handoff launch context and typed result to the existing Profile terminal path. The handoff -context always creates a new background tab at the handoff root, ignores Profile tab/split placement, -preserves launch-scoped environment and Dedicated Home provisioning, and records Profile identity. -The result returns the exact tab and surface IDs without a post-creation resolver lookup. - -`WorktreeTerminalState` and `WorktreeTerminalManager` stay synchronous on `@MainActor`. -`TerminalClient` exposes a synchronous main-actor Profile-launch closure so CLI and fallback callers -receive the typed result; they must not infer success from the fire-and-forget command path. Artifact -work may stay detached, but launch crosses back to the main actor. - -`WorktreeTerminalManager` remains the one executor and emits the existing success/failure events. -Only a created surface updates per-repository Last Launched Profile through -`AppFeature+TerminalEvents`. Ordinary Profile launches retain their configured placement. - -Core and CLI launches never focus the receiver. If the initiating HUD still exists and is waiting for -that exact completion, it focuses the returned pane after success. A dismissed HUD and an ordinary CLI -handoff leave the receiver in the background; launch failure leaves the user on the source pane. - -### Output and privacy - -The response and internal completion may expose only resolved runtime, Profile UUID, and frozen -single-line display name. The append-only log adds those same optional fields and sanitizes Profile -names against newline/quote injection. Archive filenames continue to use the runtime token. Extra -Arguments, environment names/values, carrier values, Dedicated Home paths, and credentials never enter -the handoff request, response, artifact, or log. - -## Implementation plan - -1. Add the exclusive CLI option, optional wire/payload fields, text rendering, and request-mismatch - error in `ProwlCLI/Commands/HandoffCommand.swift`, `ProwlCLI/Output/OutputRenderer.swift`, - `supacode/CLIService/Shared/InputModels.swift`, `supacode/CLIService/Shared/HandoffCommandPayload.swift`, - and `supacode/CLIService/Shared/ErrorCodes.swift`. -2. Add Receiving Target/request-expectation types and exact claim semantics in - `supacode/Domain/Handoff/HandoffRequestRegistry.swift`, - `supacode/Clients/Handoff/HandoffRequestClient.swift`, and - `supacode/Domain/Handoff/HandoffInjection.swift`. -3. Resolve and freeze Profiles, enforce target exclusivity, split preflight from artifact commit, - publish typed completion, and add sanitized Profile log metadata in - `supacode/CLIService/HandoffCommandHandler.swift` and +| Invalid target, invalid briefing, missing/disabled Profile, or plan failure | Fail before artifact mutation; no launch-memory update | +| Artifact commit failure | Error and no receiver launch; do not claim a complete artifact set | +| Dedicated Home or surface failure after commit | Retain artifacts/archive, log `launch=failed`, report progress saved but receiver not launched, no launch-memory update | +| Surface created | Record exact pane, notify, and emit Profile launch success | + +## Implementation slices + +1. CLI/wire: `ProwlCLI/Commands/HandoffCommand.swift`, `ProwlCLI/Output/OutputRenderer.swift`, + `supacode/CLIService/Shared/InputModels.swift`, and + `supacode/CLIService/Shared/HandoffCommandPayload.swift`. +2. Request/HUD: `supacode/Domain/Handoff/HandoffRequestRegistry.swift`, + `supacode/Clients/Handoff/HandoffRequestClient.swift`, + `supacode/Domain/Handoff/HandoffInjection.swift`, + `supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift`, and + `supacode/Features/App/Reducer/AppFeature+Handoff.swift`. Registry claim results are only + `claimed`, `mismatch`, and `unavailable`. +3. Handler/logging: `supacode/CLIService/HandoffCommandHandler.swift` and `supacode/Domain/Handoff/HandoffCoordinator.swift`. -4. Extend the shared planner and terminal launch boundary in - `supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift`, +4. Shared launch: `supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift`, `supacode/Clients/Terminal/TerminalClient.swift`, - `supacode/Features/Terminal/Models/WorktreeTerminalState.swift`, and - `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift`. -5. Build the ordered Profile/runtime target list, preserve execution-time Profile lookup in fallback, - correlate typed completion, and focus only from a waiting HUD in - `supacode/Features/App/Reducer/AppFeature+Handoff.swift`, - `supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift`, and - `supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift`. -6. Wire Profile resolution and the shared launcher once in `supacode/App/supacodeApp.swift`; retain the - existing Runtime Default launch branch and Profile success-event memory path. -7. Update current behavior in `docs/components/handoff.md`, `docs/components/cli.md`, - `docs/components/agent-profiles.md`, and `skills/prowl-cli/SKILL.md`; after implementation, record - actual files, tests, deviations, and PR references in a new 053 action/amendment file. + `supacode/Features/Terminal/Models/WorktreeTerminalState.swift`, + `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift`, and + `supacode/App/supacodeApp.swift`. +5. After implementation, update `docs/components/handoff.md`, `docs/components/cli.md`, + `docs/components/agent-profiles.md`, and `skills/prowl-cli/SKILL.md`, then record actual results in + the next 053 amendment. ## Verification -- Parser and socket tests: Profile/runtime forms, both/neither/malformed UUID, explicit Profile source, - additive payload/text fields, `--no-launch`, and unchanged runtime compatibility. -- Registry/handler tests: source+operation binding, mismatch without consumption, supersession, - execution-time latest Profile, missing/disabled zero-side-effect rejection, authoritative Profile - configuration, Runtime Default inheritance, sanitized/no-secret output, and post-commit failure. -- Planner/terminal tests: prompted plan with `-p work`, environment and Dedicated Home intact; handoff - ignores split placement and creates a background tab; exact result and success/failure events. -- HUD/app tests: Recommended ordering, no-Profile fallback, UUID injection, completion correlation, - fallback execution-time re-resolution, partial-success messaging, exact-pane focus, and real router - `--no-launch` wiring. -- Required commands: focused Xcode suites, `swift test --filter HandoffCommandParsingTests`, - `make build-cli`, `make test-cli-smoke`, `make test-cli-integration`, `make check`, `make test`, and - `make build-app`. -- Manual GUI pass: a Codex Profile with Extra Arguments `-p work`, environment overrides, and Dedicated - Home through inline and fallback; deleted/disabled Profile; forced launch failure; Runtime Default - regression; verify command preview, JSON, logs, and artifacts contain no secret values. - -## Alternatives and decisions - -- Prowl Agent Profile instead of a Codex-only string keeps handoff runtime-neutral and reuses the full - launch contract; native Codex profiles remain encapsulated in Extra Arguments. -- UUID instead of display name survives rename and duplicate names. -- Execution-time resolution instead of configuration snapshots avoids credentials in requests and uses - the latest persisted configuration. -- Reusing the Profile planner/executor instead of copying argv/environment/home logic keeps inline, - fallback, preview, and ordinary launches consistent. -- Runtime Defaults remain available to avoid a migration cliff and preserve current behavior. - -## Open questions - -None for this wave. +- Parser/socket: Profile/runtime exclusivity, malformed UUID, source selectors, additive payload/text, + `--no-launch`, and runtime compatibility. +- Registry/handler: exact claim, retryable mismatch, supersession, latest Profile resolution, + authoritative Profile versus Runtime Default inheritance, zero-side-effect preflight, sanitized + no-secret output, and post-commit launch failure. +- Planner/terminal/HUD: prompted `-p work` plan retains environment/Home; handoff ignores split and + stays background; manager emits one result event; inline and fallback correlate/focus the exact pane. +- Run focused Xcode suites, `swift test --filter HandoffCommandParsingTests`, `make build-cli`, + `make test-cli-smoke`, `make test-cli-integration`, `make check`, `make test`, and `make build-app`. +- Manually exercise inline and fallback with a Codex Profile using `-p work`, environment overrides, + and Dedicated Home; also cover deleted/disabled Profile, launch failure, Runtime Default, and secret + absence from preview, JSON, artifacts, and logs. diff --git a/docs/adr/0001-handoff-targets-prowl-agent-profiles.md b/docs/adr/0001-handoff-targets-prowl-agent-profiles.md deleted file mode 100644 index b43628136..000000000 --- a/docs/adr/0001-handoff-targets-prowl-agent-profiles.md +++ /dev/null @@ -1,3 +0,0 @@ -# Profile-aware handoff targets Prowl Agent Profiles - -A profile-aware handoff identifies its receiving configuration with a Prowl Agent Profile rather than accepting runtime-specific input such as a Codex Config Profile name. Existing Runtime Default Targets remain supported for backward compatibility. This keeps the handoff contract runtime-neutral and lets the inline CLI path and HUD fallback launch the same complete profile configuration; native runtime profiles remain encapsulated by the selected Prowl Agent Profile. diff --git a/docs/adr/0002-handoff-profile-identity-uses-uuid.md b/docs/adr/0002-handoff-profile-identity-uses-uuid.md deleted file mode 100644 index 54b8f1018..000000000 --- a/docs/adr/0002-handoff-profile-identity-uses-uuid.md +++ /dev/null @@ -1,3 +0,0 @@ -# Handoff profile identity uses UUID - -A profile-aware handoff carries the selected Prowl Agent Profile's stable UUID across the HUD, injected CLI request, socket payload, and fallback path; mutable display names are used only for presentation and logs. This prevents a rename or duplicate name from resolving the handoff to a different account or launch configuration. diff --git a/docs/adr/0003-handoff-resolves-profiles-at-execution.md b/docs/adr/0003-handoff-resolves-profiles-at-execution.md deleted file mode 100644 index 5c29f7868..000000000 --- a/docs/adr/0003-handoff-resolves-profiles-at-execution.md +++ /dev/null @@ -1,3 +0,0 @@ -# Handoff resolves profiles at execution - -A profile-aware handoff carries only the Prowl Agent Profile UUID and resolves its latest persisted configuration when the transition executes. A missing or disabled profile fails before any artifact, archive, or log mutation; Prowl does not snapshot profile configuration or place environment values and credentials in the handoff request. The same validation applies to `--no-launch`: a successful archive-only transition records the resolved profile identity but neither creates a receiver surface nor updates `Last Launched Profile`. diff --git a/docs/adr/0004-handoff-cli-has-exclusive-receiving-targets.md b/docs/adr/0004-handoff-cli-has-exclusive-receiving-targets.md deleted file mode 100644 index 1222e341a..000000000 --- a/docs/adr/0004-handoff-cli-has-exclusive-receiving-targets.md +++ /dev/null @@ -1,3 +0,0 @@ -# Handoff CLI has exclusive receiving targets - -`prowl handoff to` accepts exactly one receiving target: the existing runtime argument such as `codex`, or `--agent-profile-id `. The profile form derives its runtime from the resolved Prowl Agent Profile, so the protocol cannot represent a contradictory agent token and profile runtime; display names remain presentation-only. Because the existing positional grammar assigns its first value to the runtime and its second to the source, the profile form accepts an explicitly named source only through `--pane`, `--tab`, or `--worktree`; omitting a selector still means the calling pane. Runtime-targeted handoffs retain the existing optional positional source for backward compatibility. diff --git a/docs/adr/0005-handoff-owns-receiver-placement.md b/docs/adr/0005-handoff-owns-receiver-placement.md deleted file mode 100644 index 8cc165eb4..000000000 --- a/docs/adr/0005-handoff-owns-receiver-placement.md +++ /dev/null @@ -1,3 +0,0 @@ -# Handoff owns receiver placement - -A profile-aware handoff always creates its receiving agent in a new background tab, ignoring the selected Prowl Agent Profile's manual-launch `Open In` placement. The profile still supplies its runtime configuration, environment, account, and identity. An ordinary CLI handoff, or a handoff whose HUD is no longer waiting, leaves the receiver in the background; a HUD that is still waiting for that request focuses the exact launched receiver pane after success. This preserves the core launcher's no-layout-disruption contract while keeping the existing HUD completion behavior. diff --git a/docs/adr/0006-receiving-profile-is-authoritative.md b/docs/adr/0006-receiving-profile-is-authoritative.md deleted file mode 100644 index 9a039110b..000000000 --- a/docs/adr/0006-receiving-profile-is-authoritative.md +++ /dev/null @@ -1,3 +0,0 @@ -# Receiving Profile is authoritative - -When a handoff selects a Prowl Agent Profile, that profile exclusively determines the receiver's model, reasoning effort, execution mode, extra arguments, environment, and account; no launch configuration is inherited from the outgoing agent. Existing model and explicitly observed unrestricted-mode inheritance remains only for Runtime Default Targets, preventing an explicit Standard profile from being silently escalated. diff --git a/docs/adr/0007-handoff-reuses-profile-launch-pipeline.md b/docs/adr/0007-handoff-reuses-profile-launch-pipeline.md deleted file mode 100644 index 1fff243ed..000000000 --- a/docs/adr/0007-handoff-reuses-profile-launch-pipeline.md +++ /dev/null @@ -1,3 +0,0 @@ -# Handoff reuses the Profile launch pipeline - -Profile-aware handoff extends and reuses the existing Agent Profile launch planner and surface-launch boundary instead of duplicating profile command, Dedicated Home, environment, and identity handling inside handoff. The shared path accepts a prompted start intent and a handoff-owned background-tab context, while ordinary profile launches retain their current interactive intent and placement behavior. diff --git a/docs/adr/0008-hud-request-binds-receiving-target.md b/docs/adr/0008-hud-request-binds-receiving-target.md deleted file mode 100644 index a51451881..000000000 --- a/docs/adr/0008-hud-request-binds-receiving-target.md +++ /dev/null @@ -1,3 +0,0 @@ -# HUD request binds its source and operation - -Each HUD-generated request binds its request UUID to the source pane and exact operation selected by the user: checkpoint, Runtime Default handoff, or Profile-aware handoff with launch enabled. The CLI service claims the request only when the source and operation match. A mismatch rejects that command before briefing or artifact mutation without consuming the pending request, so the source agent may correct and retry. Ordinary CLI handoffs without a HUD request UUID remain independent, and fallback supersession continues to invalidate the original request. From 0140730199d0e91565cc75f79ba8f4ae7780317b Mon Sep 17 00:00:00 2001 From: Sunset Date: Sat, 1 Aug 2026 23:03:03 +0800 Subject: [PATCH 3/5] feat: support agent profiles in handoff --- ProwlCLI/Commands/HandoffCommand.swift | 52 +- ProwlCLI/Output/OutputRenderer.swift | 4 + .../HandoffCommandParsingTests.swift | 50 ++ ProwlCLITests/ProwlCLIIntegrationTests.swift | 117 ++++ docs-ai/053-agent-profiles/000-plan.md | 9 +- .../007-profile-aware-handoff.md | 2 +- .../008-profile-aware-handoff-action.md | 64 ++ supacode/App/supacodeApp.swift | 46 +- .../CLIService/HandoffCommandHandler.swift | 574 +++++++++++++++--- .../Shared/HandoffCommandPayload.swift | 9 + supacode/CLIService/Shared/InputModels.swift | 6 + .../Handoff/HandoffRequestClient.swift | 6 +- .../Clients/Terminal/TerminalClient.swift | 4 + .../AgentProfile/AgentProfileLaunchPlan.swift | 5 +- .../Domain/Handoff/HandoffCoordinator.swift | 17 + .../Domain/Handoff/HandoffInjection.swift | 45 +- .../Handoff/HandoffRequestRegistry.swift | 61 +- .../App/Reducer/AppFeature+Handoff.swift | 20 +- .../Reducer/HandoffHudFeature.swift | 291 +++++++-- .../Views/HandoffHudOverlayView.swift | 39 +- .../WorktreeTerminalManager.swift | 11 +- .../Models/WorktreeTerminalState.swift | 75 ++- supacodeTests/AgentProfileTests.swift | 13 + supacodeTests/AppFeatureHandoffTests.swift | 54 +- .../HandoffCommandHandlerTests.swift | 246 +++++++- supacodeTests/HandoffHudFeatureTests.swift | 364 ++++++++++- .../HandoffRequestRegistryTests.swift | 98 ++- .../WorktreeTerminalManagerTests.swift | 64 ++ ...rktreeTerminalStateAgentProfileTests.swift | 49 +- 29 files changed, 2122 insertions(+), 273 deletions(-) create mode 100644 docs-ai/053-agent-profiles/008-profile-aware-handoff-action.md diff --git a/ProwlCLI/Commands/HandoffCommand.swift b/ProwlCLI/Commands/HandoffCommand.swift index 1afe1a425..567414cb2 100644 --- a/ProwlCLI/Commands/HandoffCommand.swift +++ b/ProwlCLI/Commands/HandoffCommand.swift @@ -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? @@ -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 ." + ) + 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)." @@ -146,6 +176,13 @@ 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, @@ -153,7 +190,8 @@ struct HandoffToCommand: ParsableCommand { HandoffInput( action: .toAgent, selector: try selector.resolve(positionalTarget: target), - toAgent: normalizedAgent, + toAgent: receivingTarget.agent, + toProfileID: receivingTarget.profileID, note: note, launch: !noLaunch, brief: resolvedBrief.brief, diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift index ee32c4b86..e9e135537 100644 --- a/ProwlCLI/Output/OutputRenderer.swift +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -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 { diff --git a/ProwlCLITests/HandoffCommandParsingTests.swift b/ProwlCLITests/HandoffCommandParsingTests.swift index 028af1364..1311c9067 100644 --- a/ProwlCLITests/HandoffCommandParsingTests.swift +++ b/ProwlCLITests/HandoffCommandParsingTests.swift @@ -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") @@ -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()) + } } diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index b23202a0b..f11c7fea6 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -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( @@ -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 { diff --git a/docs-ai/053-agent-profiles/000-plan.md b/docs-ai/053-agent-profiles/000-plan.md index fa9b460b3..206a181fb 100644 --- a/docs-ai/053-agent-profiles/000-plan.md +++ b/docs-ai/053-agent-profiles/000-plan.md @@ -333,10 +333,11 @@ ambient "当前/推荐 profile" 来改变目标。后续 053.007 绑定稳定 UU ## Amendments -- Updated 2026-08-01: planned 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 — see - [007-profile-aware-handoff.md](007-profile-aware-handoff.md). +- 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"的串号链,环境补丁 diff --git a/docs-ai/053-agent-profiles/007-profile-aware-handoff.md b/docs-ai/053-agent-profiles/007-profile-aware-handoff.md index 0aced4491..99a588ecf 100644 --- a/docs-ai/053-agent-profiles/007-profile-aware-handoff.md +++ b/docs-ai/053-agent-profiles/007-profile-aware-handoff.md @@ -3,7 +3,7 @@ | | | | --- | --- | | **Date** | 2026-08-01 | -| **Status** | Planned | +| **Status** | Implemented (see [053.008](008-profile-aware-handoff-action.md)) | | **Primary PRs** | TBD | | **Related** | [053 plan](000-plan.md), [053.006](006-launch-scoped-environment.md), [047.004](../047-cross-agent-handoff/004-inline-handoff-redesign.md), [047.005](../047-cross-agent-handoff/005-hud-request-ownership.md), [049 Agents HUD](../049-agents-toolbar-entry/000-plan.md), [048 runtime adapters](../048-agent-runtime-adapters/000-plan.md), [handoff manual](../../docs/components/handoff.md) | diff --git a/docs-ai/053-agent-profiles/008-profile-aware-handoff-action.md b/docs-ai/053-agent-profiles/008-profile-aware-handoff-action.md new file mode 100644 index 000000000..aaa0d38bb --- /dev/null +++ b/docs-ai/053-agent-profiles/008-profile-aware-handoff-action.md @@ -0,0 +1,64 @@ +# 053.008 — Profile-aware handoff implementation + +| | | +| --- | --- | +| **Date** | 2026-08-01 | +| **Status** | Implemented | +| **Primary PRs** | TBD | +| **Design** | [053.007](007-profile-aware-handoff.md) | + +## Result + +Hand Off now accepts either a Runtime Default receiver or an enabled Prowl Agent Profile. The HUD +orders the recommended and remaining enabled Profiles before Runtime Defaults, injects only the +Profile UUID and exact source pane, and correlates completion back to the exact launched surface. +The direct CLI form is: + +```bash +prowl handoff to --agent-profile-id --brief - +``` + +Native Codex config profiles remain ordinary Profile Extra Arguments such as `-p work`; no +Codex-specific persistence field was added. + +## Implementation + +- `HandoffReceivingTarget` and the HUD request registry bind one request UUID to the exact source + pane plus checkpoint, Runtime Default, or Profile operation. A mismatch stays pending; a claimed + or superseded UUID cannot be reopened. +- The CLI and socket payload accept `to_profile_id`; successful output adds frozen + `to_profile_id` / `to_profile_name` while retaining `to_agent` as the resolved runtime. +- Profile resolution intentionally happens after briefing collection. The latest enabled Profile is + frozen into a prompted `AgentProfileLaunchPlan` before artifact commit. Runtime Default alone keeps + the previous portable source-configuration inheritance. +- `WorktreeTerminalState` is the shared Profile executor for manual and handoff launches. Handoff + context forces a background tab at the source root, ignores Profile split placement, and returns + the exact tab/surface identity. Surface success remains the only path that updates launch memory. +- Inline CLI completion and HUD fallback both report the frozen Profile identity. A waiting HUD + focuses the exact receiver; a later Profile rename is reflected from completion rather than stale + chooser state. + +## Failure and privacy boundaries + +- Invalid requests, invalid briefing, missing/disabled Profiles, and launch-plan failures stop before + artifact mutation. `--no-launch` resolves identity but does not plan, provision, launch, or update + launch memory. +- A Dedicated Home or surface failure after artifact commit retains the saved progress, records + `launch=failed`, and reports that the receiver was not launched. +- Requests, payloads, artifacts, and transition logs never contain Extra Arguments, environment + values, carrier values, home paths, or credentials. Logs contain only sanitized Profile name/UUID + and resolved runtime metadata. + +## Verification + +- `make check`: passed. +- Focused Xcode suites for handler, HUD, request ownership, app wiring, planner, terminal state, and + terminal manager: passed. +- `swift test --filter HandoffCommandParsingTests`: 14 passed. +- `make build-cli`, `make test-cli-smoke`, `make test-cli-integration`: passed; integration suite 68 + passed. +- `make test`: 2,173 passed, 0 failed. +- `make build-app`: passed with 0 errors and 0 warnings. + +The repository's real-GUI `self-verify-prowl` workflow was not run because that skill is explicitly +opt-in and this implementation request did not separately authorize it. diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 5b47acd0f..4d675cb36 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -234,8 +234,8 @@ struct SupacodeApp: App { coordinator: coordinator ) values.handoffRequestClient = HandoffRequestClient( - register: { requestID in - handoffRequestRegistry.register(requestID) + register: { requestID, expectation in + handoffRequestRegistry.register(requestID, expectation: expectation) }, supersede: { requestID in handoffRequestRegistry.supersede(requestID) @@ -352,6 +352,9 @@ struct SupacodeApp: App { createTabInDirectory: { worktree, directory in terminalManager.createTabInDirectory(worktree, directory: directory) }, + launchAgentProfile: { plan, worktree, context in + terminalManager.launchAgentProfile(plan, in: worktree, context: context) + }, events: { terminalManager.eventStream() }, @@ -780,20 +783,47 @@ struct SupacodeApp: App { terminalManager: terminalManager ) }, + profileProvider: { profileID in + @Shared(.userGlobalSettings) var settings + return settings.agentProfiles.first { $0.id == profileID } + }, + profileLaunchProvider: { target, plan in + let repositories = Array(appStore.state.repositories.repositories) + guard + let worktree = resolveCLITerminalWorktree( + id: target.worktreeID, + repositories: repositories + ), + let result = terminalManager.launchAgentProfile( + plan, + in: worktree, + context: .handoffBackgroundTab( + root: URL(fileURLWithPath: target.rootPath, isDirectory: true) + ) + ) + else { return nil } + return HandoffLaunchedPane( + worktreeID: target.worktreeID, + worktreeName: target.worktreeName, + tabID: result.tabID.rawValue.uuidString, + paneID: result.surfaceID.uuidString, + paneTitle: result.paneTitle + ) + }, forkProvider: Self.forkHandoffBriefing, - notifyLaunch: { launched, from, toAgent in + notifyLaunch: { launched, from, receiverDisplayName in Self.notifyHandoffLaunch( launched: launched, from: from, - toAgent: toAgent, + receiverDisplayName: receiverDisplayName, terminalManager: terminalManager ) }, completionObserver: { completion in appStore.send(.handoffCliCompleted(completion)) }, - requestAuthorizer: { requestID in - handoffRequestRegistry.claim(requestID) + requestAuthorizer: { requestID, expectation in + handoffRequestRegistry.claim(requestID, actual: expectation) } ) @@ -860,7 +890,7 @@ struct SupacodeApp: App { private static func notifyHandoffLaunch( launched: HandoffLaunchedPane, from: String, - toAgent: String, + receiverDisplayName: String, terminalManager: WorktreeTerminalManager ) { let watching = @@ -871,7 +901,7 @@ struct SupacodeApp: App { let state = terminalManager.stateIfExists(for: launched.worktreeID) else { return } state.appendNotification( - title: "\(from) → \(toAgent)", + title: "\(from) → \(receiverDisplayName)", body: "Took over in \(launched.worktreeName)", surfaceId: paneID ) diff --git a/supacode/CLIService/HandoffCommandHandler.swift b/supacode/CLIService/HandoffCommandHandler.swift index 2864575be..53bdd08d7 100644 --- a/supacode/CLIService/HandoffCommandHandler.swift +++ b/supacode/CLIService/HandoffCommandHandler.swift @@ -39,10 +39,40 @@ struct HandoffCLICompletion: Sendable, Equatable { let action: HandoffAction let sourcePaneID: String let toAgent: String? + let toProfileID: AgentProfile.ID? + let toProfileName: String? let briefing: HandoffBriefing let launched: HandoffLaunchedPane? + let failureMessage: String? + /// True once the archive/current/context artifacts have committed, even if + /// the receiving process could not be launched afterward. + let artifactsReady: Bool /// ID generated by the HUD, or nil for an ordinary CLI handoff. let requestID: UUID? + + init( + action: HandoffAction, + sourcePaneID: String, + toAgent: String?, + toProfileID: AgentProfile.ID? = nil, + toProfileName: String? = nil, + briefing: HandoffBriefing, + launched: HandoffLaunchedPane?, + failureMessage: String? = nil, + artifactsReady: Bool = true, + requestID: UUID? + ) { + self.action = action + self.sourcePaneID = sourcePaneID + self.toAgent = toAgent + self.toProfileID = toProfileID + self.toProfileName = toProfileName + self.briefing = briefing + self.launched = launched + self.failureMessage = failureMessage + self.artifactsReady = artifactsReady + self.requestID = requestID + } } @MainActor @@ -50,21 +80,27 @@ final class HandoffCommandHandler: CommandHandler { typealias ResolveProvider = @MainActor (TargetSelector, pid_t?) -> Result typealias LaunchProvider = @MainActor (HandoffResolvedTarget, AgentStartRequest) -> HandoffLaunchedPane? + typealias ProfileProvider = @MainActor (AgentProfile.ID) -> AgentProfile? + typealias ProfileLaunchProvider = + @MainActor (HandoffResolvedTarget, AgentProfileLaunchPlan) -> HandoffLaunchedPane? /// Resumes the source session headlessly and returns its reply text /// (the fork briefing fallback). typealias ForkProvider = @Sendable (AgentResumeRequest, URL) async throws -> String - /// Announces a completed transition (`from`, `to`) for the launched pane. + /// Announces a completed transition (`from`, receiver display name) for the launched pane. typealias LaunchNotifier = @MainActor (HandoffLaunchedPane, String, String) -> Void /// Announces every successful save/to so the UI can observe injected requests. typealias CompletionObserver = @MainActor (HandoffCLICompletion) -> Void - /// Atomically claims a HUD request before its transition starts. - typealias RequestAuthorizer = (UUID) -> Bool + /// Atomically claims an exact HUD request before its transition starts. + typealias RequestAuthorizer = + @MainActor (UUID, HandoffRequestExpectation) -> HandoffRequestClaimResult /// Agents this command can launch (it injects an agent-specific kickoff command). static let supportedAgents = HandoffAgentSupport.launchableAgents private let resolveProvider: ResolveProvider private let launchProvider: LaunchProvider + private let profileProvider: ProfileProvider + private let profileLaunchProvider: ProfileLaunchProvider private let forkProvider: ForkProvider private let notifyLaunch: LaunchNotifier private let completionObserver: CompletionObserver @@ -75,15 +111,19 @@ final class HandoffCommandHandler: CommandHandler { init( resolveProvider: @escaping ResolveProvider, launchProvider: @escaping LaunchProvider, + profileProvider: @escaping ProfileProvider = { _ in nil }, + profileLaunchProvider: @escaping ProfileLaunchProvider = { _, _ in nil }, forkProvider: @escaping ForkProvider, notifyLaunch: @escaping LaunchNotifier = { _, _, _ in }, completionObserver: @escaping CompletionObserver = { _ in }, - requestAuthorizer: @escaping RequestAuthorizer = { _ in true }, + requestAuthorizer: @escaping RequestAuthorizer = { _, _ in .claimed }, now: @escaping @Sendable () -> Date = { Date() } ) { self.resolveProvider = resolveProvider self.launchProvider = launchProvider + self.profileProvider = profileProvider + self.profileLaunchProvider = profileLaunchProvider self.forkProvider = forkProvider self.notifyLaunch = notifyLaunch self.completionObserver = completionObserver @@ -106,20 +146,12 @@ final class HandoffCommandHandler: CommandHandler { message: "--brief and --no-brief are mutually exclusive." ) } - if input.action == .toAgent { - guard let rawAgent = input.toAgent, let toAgent = HandoffAgentSupport.normalize(rawAgent) else { - return errorResponse( - code: CLIErrorCode.invalidArgument, - message: "handoff to requires an agent of: \(HandoffAgentSupport.supportedAgentsDescription)." - ) - } - if input.launch, !HandoffAgentSupport.canLaunch(toAgent) { - let launchable = HandoffAgentSupport.launchableAgentsDescription - return errorResponse( - code: CLIErrorCode.invalidArgument, - message: "handoff can only launch: \(launchable). Use --no-launch for other agents." - ) - } + let receivingTarget: HandoffReceivingTarget? + switch validateReceivingTarget(for: input) { + case .accepted(let target): + receivingTarget = target + case .rejected(let response): + return response } let target: HandoffResolvedTarget @@ -130,40 +162,130 @@ final class HandoffCommandHandler: CommandHandler { return mapResolveError(error) } + if let response = requestAuthorizationFailure( + input: input, + target: target, + receivingTarget: receivingTarget + ) { + return response + } + let briefingSource: HandoffBriefingSource switch briefingDecision(for: input, target: target) { case .source(let source): briefingSource = source case .rejected(let response): - return response - } - - if let requestID = input.requestID, !requestAuthorizer(requestID) { - return errorResponse( - code: CLIErrorCode.handoffRequestSuperseded, - message: "This HUD handoff request was superseded by a fallback. Nothing was changed." + publishFailure( + for: input, + target: target, + receivingTarget: receivingTarget, + message: response.error?.message ?? "The handoff request was rejected." ) + return response } - let store = HandoffStore(rootURL: URL(fileURLWithPath: target.rootPath, isDirectory: true)) - let timestamp = now() - switch input.action { case .save: return await handleSave( input: input, target: target, - store: store, - briefingSource: briefingSource, - timestamp: timestamp + briefingSource: briefingSource ) case .toAgent: + guard let receivingTarget else { + return errorResponse(code: CLIErrorCode.invalidArgument, message: "handoff to requires a receiver.") + } return await handleTo( input: input, target: target, - store: store, - briefingSource: briefingSource, - timestamp: timestamp + receivingTarget: receivingTarget, + briefingSource: briefingSource + ) + } + } + + private enum ReceivingTargetValidation { + case accepted(HandoffReceivingTarget?) + case rejected(CommandResponse) + } + + private func validateReceivingTarget(for input: HandoffInput) -> ReceivingTargetValidation { + switch (input.action, input.toAgent, input.toProfileID) { + case (.save, nil, nil): + return .accepted(nil) + case (.save, _, _): + return .rejected( + errorResponse( + code: CLIErrorCode.invalidArgument, + message: "handoff save does not accept a receiving agent or Agent Profile." + ) + ) + case (.toAgent, .some(let rawAgent), nil): + guard + let token = HandoffAgentSupport.normalize(rawAgent), + let agent = DetectedAgent(rawValue: token) + else { + return .rejected( + errorResponse( + code: CLIErrorCode.invalidArgument, + message: "handoff to requires an agent of: \(HandoffAgentSupport.supportedAgentsDescription)." + ) + ) + } + if input.launch, !HandoffAgentSupport.canLaunch(token) { + return .rejected( + errorResponse( + code: CLIErrorCode.invalidArgument, + message: + "handoff can only launch: \(HandoffAgentSupport.launchableAgentsDescription). " + + "Use --no-launch for other agents." + ) + ) + } + return .accepted(.runtimeDefault(agent)) + case (.toAgent, nil, .some(let profileID)): + return .accepted(.profile(profileID)) + case (.toAgent, _, _): + return .rejected( + errorResponse( + code: CLIErrorCode.invalidArgument, + message: + "handoff to requires exactly one receiver: an agent (launchable: " + + "\(HandoffAgentSupport.launchableAgentsDescription)) or --agent-profile-id ." + ) + ) + } + } + + private func requestAuthorizationFailure( + input: HandoffInput, + target: HandoffResolvedTarget, + receivingTarget: HandoffReceivingTarget? + ) -> CommandResponse? { + guard let requestID = input.requestID else { return nil } + guard input.action != .toAgent || input.launch else { + return errorResponse( + code: CLIErrorCode.invalidArgument, + message: "A HUD handoff request must launch its selected receiver. Nothing was changed." + ) + } + guard let sourcePaneID = UUID(uuidString: target.paneID) else { + return errorResponse(code: CLIErrorCode.invalidArgument, message: "The source pane ID is invalid.") + } + let operation = receivingTarget.map { HandoffRequestOperation.handoff(target: $0) } ?? .checkpoint + let actual = HandoffRequestExpectation(sourcePaneID: sourcePaneID, operation: operation) + switch requestAuthorizer(requestID, actual) { + case .claimed: + return nil + case .mismatch: + return errorResponse( + code: CLIErrorCode.invalidArgument, + message: "This HUD request does not match the source pane or destination. Nothing was changed." + ) + case .unavailable: + return errorResponse( + code: CLIErrorCode.handoffRequestSuperseded, + message: "This HUD handoff request was superseded by a fallback. Nothing was changed." ) } } @@ -193,7 +315,7 @@ final class HandoffCommandHandler: CommandHandler { return .rejected( errorResponse( code: CLIErrorCode.briefRequired, - message: Self.briefRequiredMessage(action: input.action, toAgent: input.toAgent) + message: Self.briefRequiredMessage(input: input) ) ) } @@ -212,11 +334,11 @@ final class HandoffCommandHandler: CommandHandler { private func handleSave( input: HandoffInput, target: HandoffResolvedTarget, - store: HandoffStore, - briefingSource: HandoffBriefingSource, - timestamp: Date + briefingSource: HandoffBriefingSource ) async -> CommandResponse { + let store = HandoffStore(rootURL: URL(fileURLWithPath: target.rootPath, isDirectory: true)) let coordinator = makeCoordinator(store: store) + let timestamp = now() do { let (result, briefing) = try await coordinator.makeCheckpoint( outgoingAgent: target.outgoingAgent, @@ -240,71 +362,287 @@ final class HandoffCommandHandler: CommandHandler { payload: makePayload(action: .save, save: result, briefing: briefing) ) } catch HandoffBriefingError.invalidInlineBrief { - return errorResponse(code: CLIErrorCode.invalidBrief, message: Self.invalidBriefMessage()) + let message = Self.invalidBriefMessage() + publishFailure(for: input, target: target, receivingTarget: nil, message: message) + return errorResponse(code: CLIErrorCode.invalidBrief, message: message) } catch { + let message = "Failed to save handoff: \(String(describing: error))" + publishFailure(for: input, target: target, receivingTarget: nil, message: message) return errorResponse( code: CLIErrorCode.handoffFailed, - message: "Failed to save handoff: \(String(describing: error))" + message: message ) } } // MARK: - to + private struct PreparedReceiver { + let target: HandoffReceivingTarget + let agent: DetectedAgent + let profile: AgentProfile? + let profilePlan: AgentProfileLaunchPlan? + } + + private enum ReceiverPreparation { + case accepted(PreparedReceiver) + case rejected(CommandResponse) + } + + private enum BriefingCollection { + case accepted(HandoffPreparedBriefing) + case rejected(CommandResponse) + } + + private func collectBriefing( + input: HandoffInput, + target: HandoffResolvedTarget, + receivingTarget: HandoffReceivingTarget, + source: HandoffBriefingSource, + coordinator: HandoffCoordinator + ) async -> BriefingCollection { + do { + return .accepted(try await coordinator.collectBriefing(source)) + } catch HandoffBriefingError.invalidInlineBrief { + let message = Self.invalidBriefMessage() + publishFailure( + for: input, + target: target, + receivingTarget: receivingTarget, + message: message + ) + return .rejected(errorResponse(code: CLIErrorCode.invalidBrief, message: message)) + } catch { + let message = "Failed to collect the handoff briefing: \(String(describing: error))" + publishFailure( + for: input, + target: target, + receivingTarget: receivingTarget, + message: message + ) + return .rejected(errorResponse(code: CLIErrorCode.handoffFailed, message: message)) + } + } + + private func prepareReceiver( + input: HandoffInput, + target: HandoffResolvedTarget, + receivingTarget: HandoffReceivingTarget, + briefing: HandoffPreparedBriefing + ) -> ReceiverPreparation { + guard case .profile(let profileID) = receivingTarget else { + guard case .runtimeDefault(let agent) = receivingTarget else { + preconditionFailure("Unknown handoff receiving target") + } + return .accepted( + PreparedReceiver(target: receivingTarget, agent: agent, profile: nil, profilePlan: nil) + ) + } + guard let profile = profileProvider(profileID), profile.isEnabled else { + let message = "The selected Agent Profile is missing or disabled. Nothing was changed." + publishFailure( + for: input, + target: target, + receivingTarget: receivingTarget, + briefing: briefing.outcome, + message: message + ) + return .rejected(errorResponse(code: CLIErrorCode.invalidArgument, message: message)) + } + guard input.launch else { + return .accepted( + PreparedReceiver( + target: receivingTarget, + agent: profile.runtime.agent, + profile: profile, + profilePlan: nil + ) + ) + } + do { + let plan = try AgentProfileLaunchPlanner.plan( + for: profile, + homeBaseDirectory: SupacodePaths.agentProfileHomesDirectory, + intent: .prompt(Self.kickoffPrompt(hasBriefing: briefing.outcome.wroteBriefing)) + ) + return .accepted( + PreparedReceiver( + target: receivingTarget, + agent: profile.runtime.agent, + profile: profile, + profilePlan: plan + ) + ) + } catch { + let message = "The selected Agent Profile could not be prepared. Nothing was changed." + publishFailure( + for: input, + target: target, + receivingTarget: receivingTarget, + profile: profile, + briefing: briefing.outcome, + message: message + ) + return .rejected(errorResponse(code: CLIErrorCode.invalidArgument, message: message)) + } + } + + private func launchReceiver( + target: HandoffResolvedTarget, + receiver: PreparedReceiver, + outgoingAgent: String?, + hasBriefing: Bool + ) -> HandoffLaunchedPane? { + switch receiver.target { + case .runtimeDefault: + let configuration: AgentLaunchConfiguration + if let sourceAgent = outgoingAgent.flatMap(DetectedAgent.init(rawValue:)) { + configuration = AgentRuntimeAdapterRegistry.inheritedConfiguration( + from: sourceAgent, + observation: target.outgoingLaunchObservation, + to: receiver.agent + ) + } else { + configuration = .init() + } + return launchProvider( + target, + AgentStartRequest( + agent: receiver.agent, + intent: .prompt(Self.kickoffPrompt(hasBriefing: hasBriefing)), + configuration: configuration + ) + ) + case .profile: + guard let profilePlan = receiver.profilePlan else { return nil } + return profileLaunchProvider(target, profilePlan) + } + } + + private func successfulTransitionResponse( + input: HandoffInput, + target: HandoffResolvedTarget, + receiver: PreparedReceiver, + artifacts: HandoffCoordinator.TransitionArtifacts, + launched: HandoffLaunchedPane? + ) -> CommandResponse { + let toAgent = receiver.agent.rawValue + completionObserver( + HandoffCLICompletion( + action: .toAgent, + sourcePaneID: target.paneID, + toAgent: toAgent, + toProfileID: receiver.profile?.id, + toProfileName: receiver.profile?.name, + briefing: artifacts.briefing, + launched: launched, + requestID: input.requestID + ) + ) + return success( + payload: makePayload( + action: .toAgent, + save: artifacts.save, + briefing: artifacts.briefing, + toAgent: toAgent, + toProfileID: receiver.profile?.id, + toProfileName: receiver.profile?.name, + archivedPath: artifacts.archivedPath, + launched: launched + ) + ) + } + + private func launchFailureResponse( + input: HandoffInput, + target: HandoffResolvedTarget, + receivingTarget: HandoffReceivingTarget, + receiver: PreparedReceiver, + artifacts: HandoffCoordinator.TransitionArtifacts + ) -> CommandResponse { + let message = "Progress was saved, but \(receiver.profile?.name ?? receiver.agent.rawValue) could not be launched." + publishFailure( + for: input, + target: target, + receivingTarget: receivingTarget, + profile: receiver.profile, + briefing: artifacts.briefing, + artifactsReady: true, + message: message + ) + return errorResponse(code: CLIErrorCode.handoffFailed, message: message) + } + private func handleTo( input: HandoffInput, target: HandoffResolvedTarget, - store: HandoffStore, - briefingSource: HandoffBriefingSource, - timestamp: Date + receivingTarget: HandoffReceivingTarget, + briefingSource: HandoffBriefingSource ) async -> CommandResponse { - guard let rawAgent = input.toAgent, let toAgent = HandoffAgentSupport.normalize(rawAgent) else { - return errorResponse(code: CLIErrorCode.invalidArgument, message: "handoff to requires an agent.") - } + let timestamp = now() let outgoing = target.outgoingAgent let from = outgoing ?? "agent" - guard let destinationAgent = DetectedAgent(rawValue: toAgent) else { - return errorResponse(code: CLIErrorCode.invalidArgument, message: "Unknown handoff agent: \(toAgent).") + let coordinator = makeCoordinator( + store: HandoffStore(rootURL: URL(fileURLWithPath: target.rootPath, isDirectory: true)) + ) + + let preparedBriefing: HandoffPreparedBriefing + switch await collectBriefing( + input: input, + target: target, + receivingTarget: receivingTarget, + source: briefingSource, + coordinator: coordinator + ) { + case .accepted(let briefing): + preparedBriefing = briefing + case .rejected(let response): + return response } - let coordinator = makeCoordinator(store: store) + let receiver: PreparedReceiver + switch prepareReceiver( + input: input, + target: target, + receivingTarget: receivingTarget, + briefing: preparedBriefing + ) { + case .accepted(let prepared): + receiver = prepared + case .rejected(let response): + return response + } + + let toAgent = receiver.agent.rawValue let artifacts: HandoffCoordinator.TransitionArtifacts do { artifacts = try await coordinator.makeTransitionArtifacts( outgoingAgent: outgoing, toAgent: toAgent, sessionContext: target.sessionContext, - briefingSource: briefingSource, + briefing: preparedBriefing, now: timestamp ) - } catch HandoffBriefingError.invalidInlineBrief { - return errorResponse(code: CLIErrorCode.invalidBrief, message: Self.invalidBriefMessage()) } catch { - return errorResponse( - code: CLIErrorCode.handoffFailed, - message: "Failed to prepare handoff: \(String(describing: error))" + let message = "Failed to prepare handoff artifacts: \(String(describing: error))" + publishFailure( + for: input, + target: target, + receivingTarget: receivingTarget, + profile: receiver.profile, + briefing: preparedBriefing.outcome, + message: message ) + return errorResponse(code: CLIErrorCode.handoffFailed, message: message) } - let configuration: AgentLaunchConfiguration - if let sourceAgent = outgoing.flatMap(DetectedAgent.init(rawValue:)) { - configuration = AgentRuntimeAdapterRegistry.inheritedConfiguration( - from: sourceAgent, - observation: target.outgoingLaunchObservation, - to: destinationAgent - ) - } else { - configuration = .init() - } var launched: HandoffLaunchedPane? if input.launch { - launched = launchProvider( - target, - AgentStartRequest( - agent: destinationAgent, - intent: .prompt(Self.kickoffPrompt(hasBriefing: artifacts.hasBriefing)), - configuration: configuration - ) + launched = launchReceiver( + target: target, + receiver: receiver, + outgoingAgent: outgoing, + hasBriefing: artifacts.hasBriefing ) guard let launched else { await coordinator.logTransition( @@ -312,13 +650,21 @@ final class HandoffCommandHandler: CommandHandler { toAgent: toAgent, disposition: .failed, briefing: artifacts.briefing, + toProfileID: receiver.profile?.id, + toProfileName: receiver.profile?.name, archivedPath: artifacts.archivedPath, note: input.note, now: timestamp ) - return errorResponse(code: CLIErrorCode.handoffFailed, message: "Failed to launch \(toAgent).") + return launchFailureResponse( + input: input, + target: target, + receivingTarget: receivingTarget, + receiver: receiver, + artifacts: artifacts + ) } - notifyLaunch(launched, from, toAgent) + notifyLaunch(launched, from, receiver.profile?.name ?? toAgent) } await coordinator.logTransition( @@ -326,30 +672,18 @@ final class HandoffCommandHandler: CommandHandler { toAgent: toAgent, disposition: launched.map { .pane($0.paneID) } ?? .skipped, briefing: artifacts.briefing, + toProfileID: receiver.profile?.id, + toProfileName: receiver.profile?.name, note: input.note, now: timestamp ) - completionObserver( - HandoffCLICompletion( - action: .toAgent, - sourcePaneID: target.paneID, - toAgent: toAgent, - briefing: artifacts.briefing, - launched: launched, - requestID: input.requestID - ) - ) - - return success( - payload: makePayload( - action: .toAgent, - save: artifacts.save, - briefing: artifacts.briefing, - toAgent: toAgent, - archivedPath: artifacts.archivedPath, - launched: launched - ) + return successfulTransitionResponse( + input: input, + target: target, + receiver: receiver, + artifacts: artifacts, + launched: launched ) } @@ -415,11 +749,16 @@ final class HandoffCommandHandler: CommandHandler { // MARK: - Error messages - nonisolated static func briefRequiredMessage(action: HandoffAction, toAgent: String?) -> String { + nonisolated static func briefRequiredMessage(input: HandoffInput) -> String { let command = - switch action { + switch input.action { case .save: "prowl handoff save --brief -" - case .toAgent: "prowl handoff to \(toAgent ?? "") --brief -" + case .toAgent: + if let profileID = input.toProfileID { + "prowl handoff to --agent-profile-id \(profileID.uuidString) --brief -" + } else { + "prowl handoff to \(input.toAgent ?? "") --brief -" + } } return """ Self-handoff requires an inline briefing — you are the author. Rerun with your briefing on stdin: @@ -459,6 +798,8 @@ final class HandoffCommandHandler: CommandHandler { save: HandoffStore.SaveResult, briefing: HandoffBriefing, toAgent: String? = nil, + toProfileID: AgentProfile.ID? = nil, + toProfileName: String? = nil, archivedPath: String? = nil, launched: HandoffLaunchedPane? = nil ) -> HandoffCommandPayload { @@ -467,6 +808,8 @@ final class HandoffCommandHandler: CommandHandler { artifactPath: save.artifactPath, outgoingAgent: save.outgoingAgent, toAgent: toAgent, + toProfileID: toProfileID, + toProfileName: toProfileName, repos: save.repos.map { HandoffRepoPayload( name: $0.name, @@ -494,6 +837,43 @@ final class HandoffCommandHandler: CommandHandler { ) } + private func publishFailure( + for input: HandoffInput, + target: HandoffResolvedTarget, + receivingTarget: HandoffReceivingTarget?, + profile: AgentProfile? = nil, + briefing: HandoffBriefing = .none, + artifactsReady: Bool = false, + message: String + ) { + guard input.requestID != nil else { return } + let toAgent: String? = + switch receivingTarget { + case .runtimeDefault(let agent): agent.rawValue + case .profile: profile?.runtime.agent.rawValue + case nil: nil + } + let toProfileID: AgentProfile.ID? = + switch receivingTarget { + case .profile(let profileID): profileID + case .runtimeDefault, nil: nil + } + completionObserver( + HandoffCLICompletion( + action: input.action, + sourcePaneID: target.paneID, + toAgent: toAgent, + toProfileID: toProfileID, + toProfileName: profile?.name, + briefing: briefing, + launched: nil, + failureMessage: message, + artifactsReady: artifactsReady, + requestID: input.requestID + ) + ) + } + // MARK: - Response helpers private func success(payload: HandoffCommandPayload) -> CommandResponse { diff --git a/supacode/CLIService/Shared/HandoffCommandPayload.swift b/supacode/CLIService/Shared/HandoffCommandPayload.swift index f9c536f75..bb84f7c26 100644 --- a/supacode/CLIService/Shared/HandoffCommandPayload.swift +++ b/supacode/CLIService/Shared/HandoffCommandPayload.swift @@ -7,6 +7,9 @@ public struct HandoffCommandPayload: Codable, Sendable, Equatable { public let outgoingAgent: String? /// The receiving agent for `to`. public let toAgent: String? + /// The receiving Prowl Agent Profile, when the handoff targeted a profile. + public let toProfileID: UUID? + public let toProfileName: String? public let repos: [HandoffRepoPayload] public let changedFileCount: Int /// Archived copy of the previous artifact, relative to the handoff dir (for `to`). @@ -25,6 +28,8 @@ public struct HandoffCommandPayload: Codable, Sendable, Equatable { case artifactPath = "artifact_path" case outgoingAgent = "outgoing_agent" case toAgent = "to_agent" + case toProfileID = "to_profile_id" + case toProfileName = "to_profile_name" case repos case changedFileCount = "changed_file_count" case archivedPath = "archived_path" @@ -39,6 +44,8 @@ public struct HandoffCommandPayload: Codable, Sendable, Equatable { artifactPath: String, outgoingAgent: String? = nil, toAgent: String? = nil, + toProfileID: UUID? = nil, + toProfileName: String? = nil, repos: [HandoffRepoPayload] = [], changedFileCount: Int = 0, archivedPath: String? = nil, @@ -51,6 +58,8 @@ public struct HandoffCommandPayload: Codable, Sendable, Equatable { self.artifactPath = artifactPath self.outgoingAgent = outgoingAgent self.toAgent = toAgent + self.toProfileID = toProfileID + self.toProfileName = toProfileName self.repos = repos self.changedFileCount = changedFileCount self.archivedPath = archivedPath diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index f1ea2844e..6b73f15c4 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -186,6 +186,8 @@ public struct HandoffInput: Codable, Sendable { public let selector: TargetSelector /// Target agent for `to` (e.g. "claude", "codex"). Required for `.to`, nil otherwise. public let toAgent: String? + /// Target Prowl Agent Profile for `to`. Mutually exclusive with `toAgent` on input. + public let toProfileID: UUID? /// Optional free-text note appended to the handoff log. public let note: String? /// When false, `to` refreshes + archives the handoff but does not launch the @@ -205,6 +207,7 @@ public struct HandoffInput: Codable, Sendable { case action case selector case toAgent = "to_agent" + case toProfileID = "to_profile_id" case note case launch case brief @@ -217,6 +220,7 @@ public struct HandoffInput: Codable, Sendable { action: HandoffAction, selector: TargetSelector = .none, toAgent: String? = nil, + toProfileID: UUID? = nil, note: String? = nil, launch: Bool = true, brief: String? = nil, @@ -227,6 +231,7 @@ public struct HandoffInput: Codable, Sendable { self.action = action self.selector = selector self.toAgent = toAgent + self.toProfileID = toProfileID self.note = note self.launch = launch self.brief = brief @@ -240,6 +245,7 @@ public struct HandoffInput: Codable, Sendable { self.action = try container.decode(HandoffAction.self, forKey: .action) self.selector = try container.decode(TargetSelector.self, forKey: .selector) self.toAgent = try container.decodeIfPresent(String.self, forKey: .toAgent) + self.toProfileID = try container.decodeIfPresent(UUID.self, forKey: .toProfileID) self.note = try container.decodeIfPresent(String.self, forKey: .note) self.launch = try container.decodeIfPresent(Bool.self, forKey: .launch) ?? true self.brief = try container.decodeIfPresent(String.self, forKey: .brief) diff --git a/supacode/Clients/Handoff/HandoffRequestClient.swift b/supacode/Clients/Handoff/HandoffRequestClient.swift index f66754778..9134c5102 100644 --- a/supacode/Clients/Handoff/HandoffRequestClient.swift +++ b/supacode/Clients/Handoff/HandoffRequestClient.swift @@ -4,18 +4,18 @@ import Foundation /// Connects the HUD reducer to the app-owned request registry that authorizes /// injected CLI transitions. struct HandoffRequestClient: Sendable { - var register: @MainActor @Sendable (UUID) -> Void + var register: @MainActor @Sendable (UUID, HandoffRequestExpectation) -> Void var supersede: @MainActor @Sendable (UUID) -> Bool } extension HandoffRequestClient: DependencyKey { static let liveValue = Self( - register: { _ in }, + register: { _, _ in }, supersede: { _ in true } ) static let testValue = Self( - register: { _ in }, + register: { _, _ in }, supersede: { _ in true } ) } diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index c49f9c975..04a96b32c 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -5,6 +5,8 @@ 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? + var launchAgentProfile: + @MainActor @Sendable (AgentProfileLaunchPlan, Worktree, AgentProfileLaunchContext) -> AgentProfileLaunchResult? 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 +103,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 +120,7 @@ extension TerminalClient: DependencyKey { static let testValue = TerminalClient( send: { _ in }, createTabInDirectory: { _, _ in nil }, + launchAgentProfile: { _, _, _ in nil }, events: { AsyncStream { $0.finish() } }, canvasFocusedWorktreeID: { nil }, selectedSurfaceID: { _ in nil }, diff --git a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift index fab618f74..7b1ea8dcd 100644 --- a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift +++ b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift @@ -163,7 +163,8 @@ nonisolated enum AgentProfileLaunchPlanner { /// home provisioning happens at the launch boundary, not here. static func plan( for profile: AgentProfile, - homeBaseDirectory: URL + homeBaseDirectory: URL, + intent: AgentStartIntent = .interactive ) throws -> AgentProfileLaunchPlan { guard let adapter = AgentRuntimeAdapterRegistry.adapter(for: profile.runtime.agent) else { throw AgentProfileLaunchPlanError.runtimeUnavailable(profile.runtime) @@ -175,7 +176,7 @@ nonisolated enum AgentProfileLaunchPlanner { extraArguments: ShellWordSplitter.split(profile.extraArguments) ) let invocation = try AgentRuntimeAdapterRegistry.makeStartInvocation( - AgentStartRequest(agent: profile.runtime.agent, intent: .interactive, configuration: configuration) + AgentStartRequest(agent: profile.runtime.agent, intent: intent, configuration: configuration) ) // The whole patch renders as launch-scoped `env` tokens (docs-ai diff --git a/supacode/Domain/Handoff/HandoffCoordinator.swift b/supacode/Domain/Handoff/HandoffCoordinator.swift index 636f4b288..1422b378e 100644 --- a/supacode/Domain/Handoff/HandoffCoordinator.swift +++ b/supacode/Domain/Handoff/HandoffCoordinator.swift @@ -214,6 +214,8 @@ nonisolated struct HandoffCoordinator: Sendable { toAgent: String, disposition: LaunchDisposition, briefing: HandoffBriefing, + toProfileID: AgentProfile.ID? = nil, + toProfileName: String? = nil, archivedPath: String? = nil, note: String? = nil, source: String? = nil, @@ -224,6 +226,8 @@ nonisolated struct HandoffCoordinator: Sendable { toAgent: toAgent, disposition: disposition, briefing: briefing, + toProfileID: toProfileID, + toProfileName: toProfileName, archivedPath: archivedPath, note: note, source: source @@ -239,6 +243,8 @@ nonisolated struct HandoffCoordinator: Sendable { toAgent: String, disposition: LaunchDisposition, briefing: HandoffBriefing, + toProfileID: AgentProfile.ID? = nil, + toProfileName: String? = nil, archivedPath: String? = nil, note: String? = nil, source: String? = nil @@ -251,6 +257,17 @@ nonisolated struct HandoffCoordinator: Sendable { case .failed: " launch=failed" } var line = "\(from) → \(toAgent)\(launchPart) briefing=\(briefing.rawValue)" + if let toProfileID { + line += " profile_id=\(toProfileID.uuidString)" + } + if let toProfileName { + let name = + toProfileName + .replacing("\r", with: " ") + .replacing("\n", with: " ") + .replacing("\"", with: "'") + line += " profile=\"\(name)\"" + } if case .failed = disposition, let archivedPath { line += " archive=\(archivedPath)" } diff --git a/supacode/Domain/Handoff/HandoffInjection.swift b/supacode/Domain/Handoff/HandoffInjection.swift index fab4babee..ae2e4685e 100644 --- a/supacode/Domain/Handoff/HandoffInjection.swift +++ b/supacode/Domain/Handoff/HandoffInjection.swift @@ -4,24 +4,39 @@ import Foundation /// agent. The agent composes the heredoc itself — nothing multi-line is ever /// injected, so any TUI input box can take it. nonisolated enum HandoffInjection { - enum Purpose: Equatable, Sendable { - case handOff(agent: String) - case checkpoint + /// Builds an injected request whose authorization and CLI selector name the + /// same source pane. Profile targets carry only their stable UUID; mutable + /// display names never become command identity. + static func instruction( + for expectation: HandoffRequestExpectation, + requestID: UUID + ) -> String { + let sourcePane = expectation.sourcePaneID.uuidString + let (description, command) = + switch expectation.operation { + case .checkpoint: + ( + "checkpoint your progress for a later handoff", + "prowl handoff save --pane \(sourcePane) --brief -" + ) + case .handoff(target: .runtimeDefault(let agent)): + ( + "hand this task off to \(agent.rawValue)", + "prowl handoff to \(agent.rawValue) --pane \(sourcePane) --brief -" + ) + case .handoff(target: .profile(let profileID)): + ( + "hand this task off to the selected Agent Profile", + "prowl handoff to --agent-profile-id \(profileID.uuidString) --pane \(sourcePane) --brief -" + ) + } + return instructionBody( + ask: "Please \(description): run `\(requestEnvironment(requestID))\(command)`" + ) } - static func instruction(for purpose: Purpose, requestID: UUID) -> String { - + private static func instructionBody(ask: String) -> String { let sections = HandoffStore.briefingSections.joined(separator: ", ") - let ask = - switch purpose { - case .handOff(let agent): - "Please hand this task off to \(agent): run " - + "`\(requestEnvironment(requestID))prowl handoff to \(agent) --brief -`" - case .checkpoint: - "Please checkpoint your progress for a later handoff: run " - + "`\(requestEnvironment(requestID))prowl handoff save --brief -`" - - } return "[Prowl] \(ask) with your briefing on stdin as a heredoc — a markdown document " + "with the sections \(sections), written from your current working knowledge. " + "Keep Next Steps ordered and concrete. The command replies with guidance if the " diff --git a/supacode/Domain/Handoff/HandoffRequestRegistry.swift b/supacode/Domain/Handoff/HandoffRequestRegistry.swift index 0ff3b0b1e..dbbb6c27a 100644 --- a/supacode/Domain/Handoff/HandoffRequestRegistry.swift +++ b/supacode/Domain/Handoff/HandoffRequestRegistry.swift @@ -1,29 +1,72 @@ import Foundation +/// The receiving launch configuration selected by the user. Runtime-default +/// targets preserve the existing inherited-configuration behavior; profiles +/// resolve their complete launch configuration by stable ID at execution. +nonisolated enum HandoffReceivingTarget: Equatable, Hashable, Sendable { + case runtimeDefault(DetectedAgent) + case profile(AgentProfile.ID) +} + +/// The exact operation authorized by one HUD request. A handoff operation +/// always launches its receiver; an injected `--no-launch` request therefore +/// cannot satisfy a handoff expectation. +nonisolated enum HandoffRequestOperation: Equatable, Hashable, Sendable { + case checkpoint + case handoff(target: HandoffReceivingTarget) +} + +/// Immutable facts captured when the HUD injects a request. The source pane is +/// part of the authorization, not merely completion metadata: the same request +/// UUID cannot be replayed from another pane. +nonisolated struct HandoffRequestExpectation: Equatable, Hashable, Sendable { + let sourcePaneID: UUID + let operation: HandoffRequestOperation +} + +nonisolated enum HandoffRequestClaimResult: Equatable, Sendable { + case claimed + /// The request is still pending, but the submitted pane or operation does + /// not match. A later exact submission may still claim it. + case mismatch + /// Unknown, already claimed, or superseded requests are intentionally + /// indistinguishable to callers. + case unavailable +} + /// Owns the one-shot authorization for a HUD-injected handoff request. The /// HUD and socket handler both run on the main actor, so claiming the request /// or superseding it for a fallback is one serialized state transition. @MainActor final class HandoffRequestRegistry { private enum State { - case pending + case pending(HandoffRequestExpectation) case claimed case superseded } private var states: [UUID: State] = [:] - func register(_ requestID: UUID) { - states[requestID] = .pending + func register(_ requestID: UUID, expectation: HandoffRequestExpectation) { + // A UUID is one-shot for the registry's whole lifetime. In particular, a + // duplicate registration cannot reopen a claimed or superseded request. + guard states[requestID] == nil else { return } + states[requestID] = .pending(expectation) } - /// Claims a pending request for its CLI transition. A request can only be - /// claimed once and cannot run after a HUD fallback supersedes it. + /// Claims only an exact pending request. A mismatch deliberately leaves the + /// expectation pending so a malformed or stale submission cannot consume + /// the user's authorized transition. @discardableResult - func claim(_ requestID: UUID) -> Bool { - guard states[requestID] == .pending else { return false } + func claim( + _ requestID: UUID, + actual: HandoffRequestExpectation + ) -> HandoffRequestClaimResult { + guard let state = states[requestID] else { return .unavailable } + guard case .pending(let expected) = state else { return .unavailable } + guard expected == actual else { return .mismatch } states[requestID] = .claimed - return true + return .claimed } /// Supersedes a still-pending injected request before the HUD begins its @@ -31,7 +74,7 @@ final class HandoffRequestRegistry { /// the transition and must be allowed to finish instead. @discardableResult func supersede(_ requestID: UUID) -> Bool { - guard states[requestID] == .pending else { return false } + guard case .pending = states[requestID] else { return false } states[requestID] = .superseded return true } diff --git a/supacode/Features/App/Reducer/AppFeature+Handoff.swift b/supacode/Features/App/Reducer/AppFeature+Handoff.swift index fc56b8e1b..9a6846e8a 100644 --- a/supacode/Features/App/Reducer/AppFeature+Handoff.swift +++ b/supacode/Features/App/Reducer/AppFeature+Handoff.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import Foundation +import Sharing extension AppFeature { /// Open the hand-off HUD for the selected runnable target. Requires a @@ -9,7 +10,7 @@ extension AppFeature { guard state.handoffHud == nil else { return .none } guard let worktree = state.repositories.selectedTerminalWorktree else { return .none } let source = terminalClient.handoffSourceContext(worktree.id) - guard let hudState = HandoffHudFeature.State.make(worktree: worktree, source: source) else { + guard let hudState = makeHandoffHudState(worktree: worktree, source: source) else { return .send(.repositories(.showToast(.warning("No agent detected in the current pane")))) } state.handoffHud = hudState @@ -26,10 +27,25 @@ extension AppFeature { let worktree = state.repositories.terminalWorktree(for: entry.worktreeID) else { return .none } let source = terminalClient.handoffSourceContextForSurface(entry.worktreeID, entry.surfaceID) - guard let hudState = HandoffHudFeature.State.make(worktree: worktree, source: source) else { + guard let hudState = makeHandoffHudState(worktree: worktree, source: source) else { return .send(.repositories(.showToast(.warning("No agent detected in this pane")))) } state.handoffHud = hudState return .none } + + private func makeHandoffHudState( + worktree: Worktree, + source: HandoffSourceContext? + ) -> HandoffHudFeature.State? { + @Shared(.userGlobalSettings) var globalSettings + @Shared(.userRepositorySettings(worktree.repositoryRootURL)) var repositorySettings + return HandoffHudFeature.State.make( + worktree: worktree, + source: source, + profiles: globalSettings.agentProfiles, + designatedProfileID: repositorySettings.defaultAgentProfileID, + lastLaunchedProfileID: repositorySettings.lastLaunchedAgentProfileID + ) + } } diff --git a/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift b/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift index 25d4a3e7f..50db9e312 100644 --- a/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift +++ b/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift @@ -5,6 +5,7 @@ import Foundation struct HandoffTargetOption: Equatable, Identifiable, Sendable { enum Kind: Equatable, Hashable, Sendable { case agent(DetectedAgent) + case profile(AgentProfile.ID, runtime: DetectedAgent) case briefOnly } @@ -17,8 +18,18 @@ struct HandoffTargetOption: Equatable, Identifiable, Sendable { var id: Kind { kind } var agent: DetectedAgent? { - guard case .agent(let agent) = kind else { return nil } - return agent + switch kind { + case .agent(let agent), .profile(_, let agent): agent + case .briefOnly: nil + } + } + + var receivingTarget: HandoffReceivingTarget? { + switch kind { + case .agent(let agent): .runtimeDefault(agent) + case .profile(let profileID, _): .profile(profileID) + case .briefOnly: nil + } } } @@ -98,7 +109,13 @@ struct HandoffHudFeature { /// Build the HUD for a pane with a detected agent; nil without one — the /// no-source mechanical handoff stays CLI-only. - static func make(worktree: Worktree, source: HandoffSourceContext?) -> State? { + static func make( + worktree: Worktree, + source: HandoffSourceContext?, + profiles: [AgentProfile] = [], + designatedProfileID: AgentProfile.ID? = nil, + lastLaunchedProfileID: AgentProfile.ID? = nil + ) -> State? { guard let sessionContext = source?.sessionContext, let agentToken = sessionContext.agent, @@ -112,16 +129,38 @@ struct HandoffHudFeature { session: source?.session, observation: source?.observation ) - var targets = AgentRuntimeAdapterRegistry.launchableAgents.map { agent in + let enabledProfiles = profiles.filter(\.isEnabled) + let recommendedProfile = AgentProfileRecommendation.recommendedProfile( + profiles: enabledProfiles, + designatedID: designatedProfileID, + lastLaunchedID: lastLaunchedProfileID + ) + let orderedProfiles = + (recommendedProfile.map { [$0] } ?? []) + + enabledProfiles.filter { $0.id != recommendedProfile?.id } + var targets = orderedProfiles.map { profile in + let runtime = profile.runtime.agent + return HandoffTargetOption( + kind: .profile(profile.id, runtime: runtime), + title: profile.name, + subtitle: + profile.id == recommendedProfile?.id + ? "Recommended · \(runtime.displayName) Profile" + : "\(runtime.displayName) Profile", + isCurrentAgent: runtime == sourceAgent + ) + } + targets += AgentRuntimeAdapterRegistry.launchableAgents.map { agent in HandoffTargetOption( kind: .agent(agent), title: AgentRuntimeAdapterRegistry.displayName(for: agent), - subtitle: Self.launchSubtitle( - sourceAgent: sourceAgent, - sourceDisplayName: sourceAgent?.displayName ?? agentToken, - observation: source?.observation, - destination: agent - ), + subtitle: (enabledProfiles.isEmpty ? "" : "Runtime Default · ") + + Self.launchSubtitle( + sourceAgent: sourceAgent, + sourceDisplayName: sourceAgent?.displayName ?? agentToken, + observation: source?.observation, + destination: agent + ), isCurrentAgent: agent == sourceAgent ) } @@ -222,17 +261,17 @@ struct HandoffHudFeature { case .confirmSelection: guard state.isChoosing, state.targets.indices.contains(state.selectedIndex) else { return .none } let target = state.targets[state.selectedIndex] - let purpose: HandoffInjection.Purpose = - switch target.kind { - case .agent(let agent): .handOff(agent: agent.rawValue) - case .briefOnly: .checkpoint - } + let operation = target.receivingTarget.map { HandoffRequestOperation.handoff(target: $0) } ?? .checkpoint + let expectation = HandoffRequestExpectation( + sourcePaneID: state.source.sourceSurfaceID, + operation: operation + ) let requestID = uuid() - handoffRequestClient.register(requestID) + handoffRequestClient.register(requestID, expectation) let delivered = terminalClient.sendTextToSurface( state.worktree.id, state.source.sourceSurfaceID, - HandoffInjection.instruction(for: purpose, requestID: requestID) + HandoffInjection.instruction(for: expectation, requestID: requestID) ) state.phase = .running( HandoffHudRun( @@ -288,16 +327,38 @@ struct HandoffHudFeature { guard completion.action == expectedAction else { return .none } switch run.target.kind { case .briefOnly: - guard completion.toAgent == nil else { return .none } + guard completion.toAgent == nil, completion.toProfileID == nil else { return .none } + if let message = completion.failureMessage { + state.phase = .finished(.failed(message: message)) + return .cancel(id: FallbackCancelID(worktreeID: state.worktree.id)) + } state.phase = .finished(.briefSaved) case .agent(let expectedAgent): - guard completion.toAgent == expectedAgent.rawValue, let launched = completion.launched, + guard completion.toAgent == expectedAgent.rawValue, completion.toProfileID == nil else { return .none } + if let message = completion.failureMessage { + state.phase = .finished(.failed(message: message)) + return .cancel(id: FallbackCancelID(worktreeID: state.worktree.id)) + } + guard let launched = completion.launched, let paneID = UUID(uuidString: launched.paneID) else { return .none } state.phase = .finished(.handedOff(agentDisplayName: run.target.title)) // The user is present and asked for this hand-off — jump to the // receiver. The transition core itself never focuses anything. _ = terminalClient.focusSurface(launched.worktreeID, paneID) + case .profile(let expectedProfileID, _): + guard completion.toProfileID == expectedProfileID else { return .none } + if let message = completion.failureMessage { + state.phase = .finished(.failed(message: message)) + return .cancel(id: FallbackCancelID(worktreeID: state.worktree.id)) + } + guard let launched = completion.launched, + let paneID = UUID(uuidString: launched.paneID) + else { return .none } + state.phase = .finished( + .handedOff(agentDisplayName: completion.toProfileName ?? run.target.title) + ) + _ = terminalClient.focusSurface(launched.worktreeID, paneID) } return .cancel(id: FallbackCancelID(worktreeID: state.worktree.id)) @@ -391,69 +452,171 @@ struct HandoffHudFeature { run.stage = .finishing state.phase = .running(run) + let target = run.target + + switch target.kind { + case .briefOnly: + return startCheckpointFallback(state, briefing: briefing) + case .agent(let destination): + return startRuntimeFallback( + state, + destination: destination, + targetTitle: target.title, + briefing: briefing + ) + case .profile(let profileID, _): + return startProfileFallback(state, profileID: profileID, briefing: briefing) + } + } + + private func startCheckpointFallback( + _ state: State, + briefing: HandoffPreparedBriefing + ) -> Effect { + let coordinator = makeCoordinator(state) + let source = state.source + let timestamp = now + return .run { send in + _ = try await coordinator.makeCheckpoint( + outgoingAgent: source.agentToken, + sessionContext: source.sessionContext, + note: nil, + briefing: briefing, + now: timestamp + ) + await send(.fallbackFinished(.briefSaved)) + } catch: { error, send in + await send(.runFailed(message: error.localizedDescription)) + } + } + + private func startRuntimeFallback( + _ state: State, + destination: DetectedAgent, + targetTitle: String, + briefing: HandoffPreparedBriefing + ) -> Effect { let coordinator = makeCoordinator(state) let source = state.source let worktree = state.worktree let rootURL = state.rootURL - let target = run.target let timestamp = now let client = terminalClient - - switch target.kind { - case .briefOnly: - return .run { send in - _ = try await coordinator.makeCheckpoint( - outgoingAgent: source.agentToken, - sessionContext: source.sessionContext, - note: nil, - briefing: briefing, - now: timestamp + let configuration = inheritedConfiguration(source: source, destination: destination) + return .run { send in + let artifacts = try await coordinator.makeTransitionArtifacts( + outgoingAgent: source.agentToken, + toAgent: destination.rawValue, + sessionContext: source.sessionContext, + briefing: briefing, + now: timestamp + ) + let request = AgentStartRequest( + agent: destination, + intent: .prompt(HandoffCommandHandler.kickoffPrompt(hasBriefing: artifacts.hasBriefing)), + configuration: configuration + ) + let kickoff = try AgentRuntimeAdapterRegistry.makeStartInvocation(request).terminalInput + await coordinator.logTransition( + from: source.agentToken, + toAgent: destination.rawValue, + disposition: .requested, + briefing: artifacts.briefing, + source: "agents-hud", + now: timestamp + ) + await client.send( + .createTabWithInput( + worktree, + input: kickoff, + workingDirectory: rootURL, + runSetupScriptIfNew: false, + autoCloseOnSuccess: false, + customCommandName: "Hand off → \(targetTitle)", + customCommandIcon: nil ) - await send(.fallbackFinished(.briefSaved)) - } catch: { error, send in - await send(.runFailed(message: error.localizedDescription)) - } + ) + await send(.fallbackFinished(.handedOff(agentDisplayName: targetTitle))) + } catch: { error, send in + await send(.runFailed(message: error.localizedDescription)) + } + } - case .agent(let destination): - let configuration = inheritedConfiguration(source: source, destination: destination) - let targetTitle = target.title - return .run { send in - let artifacts = try await coordinator.makeTransitionArtifacts( - outgoingAgent: source.agentToken, - toAgent: destination.rawValue, - sessionContext: source.sessionContext, - briefing: briefing, - now: timestamp - ) - let request = AgentStartRequest( - agent: destination, - intent: .prompt(HandoffCommandHandler.kickoffPrompt(hasBriefing: artifacts.hasBriefing)), - configuration: configuration + private func startProfileFallback( + _ state: State, + profileID: AgentProfile.ID, + briefing: HandoffPreparedBriefing + ) -> Effect { + @Shared(.userGlobalSettings) var userGlobalSettings + guard + let profile = userGlobalSettings.agentProfiles.first(where: { $0.id == profileID }), + profile.isEnabled + else { + return .send(.runFailed(message: "The selected Agent Profile is missing or disabled.")) + } + let plan: AgentProfileLaunchPlan + do { + plan = try AgentProfileLaunchPlanner.plan( + for: profile, + homeBaseDirectory: SupacodePaths.agentProfileHomesDirectory, + intent: .prompt(HandoffCommandHandler.kickoffPrompt(hasBriefing: briefing.outcome.wroteBriefing)) + ) + } catch { + return .send(.runFailed(message: "The selected Agent Profile could not be prepared.")) + } + let coordinator = makeCoordinator(state) + let source = state.source + let worktree = state.worktree + let rootURL = state.rootURL + let timestamp = now + let client = terminalClient + return .run { send in + let artifacts = try await coordinator.makeTransitionArtifacts( + outgoingAgent: source.agentToken, + toAgent: profile.runtime.agent.rawValue, + sessionContext: source.sessionContext, + briefing: briefing, + now: timestamp + ) + guard + let result = await client.launchAgentProfile( + plan, + worktree, + .handoffBackgroundTab(root: rootURL) ) - let kickoff = try AgentRuntimeAdapterRegistry.makeStartInvocation(request).terminalInput + else { await coordinator.logTransition( from: source.agentToken, - toAgent: destination.rawValue, - disposition: .requested, + toAgent: profile.runtime.agent.rawValue, + disposition: .failed, briefing: artifacts.briefing, + toProfileID: profile.id, + toProfileName: profile.name, + archivedPath: artifacts.archivedPath, source: "agents-hud", now: timestamp ) - await client.send( - .createTabWithInput( - worktree, - input: kickoff, - workingDirectory: rootURL, - runSetupScriptIfNew: false, - autoCloseOnSuccess: false, - customCommandName: "Hand off → \(targetTitle)", - customCommandIcon: nil + await send( + .fallbackFinished( + .failed(message: "Progress was saved, but \(profile.name) could not be launched.") ) ) - await send(.fallbackFinished(.handedOff(agentDisplayName: targetTitle))) - } catch: { error, send in - await send(.runFailed(message: error.localizedDescription)) + return } + await coordinator.logTransition( + from: source.agentToken, + toAgent: profile.runtime.agent.rawValue, + disposition: .pane(result.surfaceID.uuidString), + briefing: artifacts.briefing, + toProfileID: profile.id, + toProfileName: profile.name, + source: "agents-hud", + now: timestamp + ) + _ = await client.focusSurface(worktree.id, result.surfaceID) + await send(.fallbackFinished(.handedOff(agentDisplayName: profile.name))) + } catch: { error, send in + await send(.runFailed(message: error.localizedDescription)) } } diff --git a/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift b/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift index dbcf70386..3f1d93ca8 100644 --- a/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift +++ b/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift @@ -32,13 +32,15 @@ struct HandoffHudOverlayView: View { .accessibilityLabel("Dismiss Hand Off") GeometryReader { geometry in + let topPadding = max(0, geometry.size.height * 0.22) VStack { HandoffHudCard(store: store) + .frame(maxHeight: max(0, geometry.size.height - topPadding - 32)) .zIndex(1) Spacer(minLength: 0) } .frame(width: geometry.size.width, height: geometry.size.height, alignment: .top) - .padding(.top, max(0, geometry.size.height * 0.22)) + .padding(.top, topPadding) } } } @@ -106,7 +108,7 @@ private struct HandoffHudCard: View { return "Hand Off" case .running(let run): switch run.target.kind { - case .agent: + case .agent, .profile: return "Handing off to \(run.target.title)" case .briefOnly: return "Saving progress" @@ -180,17 +182,26 @@ private struct HandoffHudChooseView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - VStack(spacing: 4) { - ForEach(Array(store.targets.enumerated()), id: \.element.id) { index, target in - HandoffTargetRow( - target: target, - isSelected: index == store.selectedIndex - ) { - store.send(.setSelectedIndex(index)) + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 4) { + ForEach(Array(store.targets.enumerated()), id: \.element.id) { index, target in + HandoffTargetRow( + target: target, + isSelected: index == store.selectedIndex + ) { + store.send(.setSelectedIndex(index)) + } + .id(target.id) + } } + .padding(12) + } + .onChange(of: store.selectedIndex) { _, index in + guard store.targets.indices.contains(index) else { return } + proxy.scrollTo(store.targets[index].id) } } - .padding(12) Divider() @@ -214,7 +225,7 @@ private struct HandoffHudChooseView: View { guard store.targets.indices.contains(store.selectedIndex) else { return "Continue" } let target = store.targets[store.selectedIndex] switch target.kind { - case .agent: + case .agent, .profile: return "Hand Off to \(target.title)" case .briefOnly: return "Save Progress" @@ -269,7 +280,7 @@ private struct HandoffTargetRow: View { @ViewBuilder private var icon: some View { switch target.kind { - case .agent(let agent): + case .agent(let agent), .profile(_, let agent): if let source = CommandIconMap.iconForFirstToken(agent.iconLookupToken) { TabIconImage(rawName: source.storageString, pointSize: 18) } else { @@ -286,7 +297,7 @@ private struct HandoffTargetRow: View { private var rowHelp: String { switch target.kind { - case .agent: + case .agent, .profile: return "Hand this task to \(target.title) in a new tab" case .briefOnly: return "Save the agent's progress for a later hand-off without launching anything" @@ -350,7 +361,7 @@ private struct HandoffHudRunView: View { switch run.stage { case .requesting: switch run.target.kind { - case .agent: + case .agent, .profile: return "Asked \(sourceDisplayName) to write its briefing and hand off to \(run.target.title)" case .briefOnly: return "Asked \(sourceDisplayName) to write a briefing checkpoint" diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 47bdccf0f..76e93aebf 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -63,12 +63,19 @@ final class WorktreeTerminalManager { /// 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). - private func launchAgentProfile(_ plan: AgentProfileLaunchPlan, in worktree: Worktree) { - if state(for: worktree).launchAgentProfile(plan) != nil { + @discardableResult + func launchAgentProfile( + _ plan: AgentProfileLaunchPlan, + in worktree: Worktree, + context: AgentProfileLaunchContext = .profileDefault + ) -> AgentProfileLaunchResult? { + let result = state(for: worktree).launchAgentProfile(plan, context: context) + if result != nil { emit(.agentProfileLaunched(worktreeID: worktree.id, profileID: plan.profileID)) } else { emit(.agentProfileLaunchFailed(worktreeID: worktree.id, profileName: plan.profileName)) } + return result } private func handleTabCommand(_ command: TerminalClient.Command) -> Bool { diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index ea133b345..17a1d0720 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -53,6 +53,17 @@ struct AgentDetectionDiagnostic { let stabilized: AgentRawState? } +nonisolated enum AgentProfileLaunchContext: Equatable, Sendable { + case profileDefault + case handoffBackgroundTab(root: URL) +} + +nonisolated struct AgentProfileLaunchResult: Sendable { + let tabID: TerminalTabID + let surfaceID: UUID + let paneTitle: String +} + @MainActor @Observable final class WorktreeTerminalState { @@ -414,7 +425,10 @@ final class WorktreeTerminalState { /// environment patch, and records the profile identity on the new surface. /// Split placement degrades to a new tab when nothing is splittable. @discardableResult - func launchAgentProfile(_ plan: AgentProfileLaunchPlan) -> UUID? { + func launchAgentProfile( + _ plan: AgentProfileLaunchPlan, + context: AgentProfileLaunchContext = .profileDefault + ) -> AgentProfileLaunchResult? { if let home = plan.dedicatedHome { do { try AgentProfileHomeProvisioner.provision( @@ -432,32 +446,55 @@ final class WorktreeTerminalState { runtime: plan.runtime, dedicatedHome: plan.dedicatedHome ) - if plan.placement == .split, + let location: (tabID: TerminalTabID, surfaceID: UUID) + if context == .profileDefault, plan.placement == .split, let surfaceID = createSplitOnFocusedSurface( direction: plan.splitDirection, initialInput: plan.terminalInput, additionalEnvironment: plan.surfaceEnvironment ) { - launchProfilesBySurface[surfaceID] = identity - return surfaceID - } - let tabId = createTab( - TabCreation( - title: plan.profileName, - icon: "terminal", - isTitleLocked: false, - initialInput: runScriptInput(plan.terminalInput), - focusing: true, - inheritingFromSurfaceId: currentFocusedSurfaceId(), - context: GHOSTTY_SURFACE_CONTEXT_TAB, - workingDirectoryOverride: nil, - additionalEnvironment: plan.surfaceEnvironment + guard let tabID = tabID(containing: surfaceID) else { return nil } + location = (tabID, surfaceID) + } else { + let focusing: Bool + let selecting: Bool + let workingDirectory: URL? + switch context { + case .profileDefault: + focusing = true + selecting = true + workingDirectory = nil + case .handoffBackgroundTab(let root): + focusing = false + selecting = false + workingDirectory = root + } + let tabID = createTab( + TabCreation( + title: plan.profileName, + icon: "terminal", + isTitleLocked: false, + initialInput: runScriptInput(plan.terminalInput), + focusing: focusing, + selecting: selecting, + inheritingFromSurfaceId: currentFocusedSurfaceId(), + context: GHOSTTY_SURFACE_CONTEXT_TAB, + workingDirectoryOverride: workingDirectory, + additionalEnvironment: plan.surfaceEnvironment + ) ) + guard let tabID, let surfaceID = trees[tabID]?.root?.leftmostLeaf().id else { return nil } + location = (tabID, surfaceID) + } + launchProfilesBySurface[location.surfaceID] = identity + let fallbackTitle = + tabManager.tabs.first(where: { $0.id == location.tabID })?.displayTitle ?? plan.profileName + return AgentProfileLaunchResult( + tabID: location.tabID, + surfaceID: location.surfaceID, + paneTitle: paneTitle(surfaceID: location.surfaceID, fallbackTabTitle: fallbackTitle) ) - guard let tabId, let surfaceID = trees[tabId]?.root?.leftmostLeaf().id else { return nil } - launchProfilesBySurface[surfaceID] = identity - return surfaceID } @discardableResult diff --git a/supacodeTests/AgentProfileTests.swift b/supacodeTests/AgentProfileTests.swift index d69fc5a1a..a894b5dd3 100644 --- a/supacodeTests/AgentProfileTests.swift +++ b/supacodeTests/AgentProfileTests.swift @@ -183,6 +183,19 @@ struct AgentProfileTests { #expect(plan.previewText == plan.invocation.terminalInput) } + @Test func promptedPlanPlacesHandoffPromptAfterProfileArguments() throws { + var preset = profile(name: "Codex · Work") + preset.extraArguments = "-p work" + + let plan = try AgentProfileLaunchPlanner.plan( + for: preset, + homeBaseDirectory: URL(fileURLWithPath: "/base", isDirectory: true), + intent: .prompt("Continue from the handoff") + ) + + #expect(plan.invocation.arguments == ["-p", "work", "Continue from the handoff"]) + } + @Test func boundProfilePlanDerivesHomeFromUUIDInsideBase() throws { var bound = profile(name: "Codex · Work") bound.bindsDedicatedHome = true diff --git a/supacodeTests/AppFeatureHandoffTests.swift b/supacodeTests/AppFeatureHandoffTests.swift index 93ccb8394..19316e6fe 100644 --- a/supacodeTests/AppFeatureHandoffTests.swift +++ b/supacodeTests/AppFeatureHandoffTests.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import DependenciesTestSupport import Foundation import IdentifiedCollections +import Sharing import Testing @testable import supacode @@ -149,6 +150,53 @@ struct AppFeatureHandoffTests { } } + @Test(.dependencies) func openHandoffHudReadsRecommendedProfilesFromPersistedSettings() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let first = AgentProfile(name: "Claude · Personal", runtime: .claude) + let recommended = AgentProfile(name: "Codex · Work", runtime: .codex) + let storage = SettingsTestStorage() + let sourcePaneID = uuid(9) + let store = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.userGlobalSettings) var globalSettings + $globalSettings.withLock { $0.agentProfiles = [first, recommended] } + @Shared(.userRepositorySettings(root)) var repositorySettings + $repositorySettings.withLock { $0.defaultAgentProfileID = recommended.id } + return TestStore( + initialState: AppFeature.State( + repositories: makeWorkspaceState(root: root), + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.handoffSourceContext = { _ in + HandoffSourceContext( + sessionContext: HandoffStore.SessionContext( + agent: "codex", + paneID: sourcePaneID.uuidString, + paneTitle: "codex", + source: "terminal-scrollback", + confidence: "fallback", + excerptText: nil + ), + observation: nil, + session: nil + ) + } + } + } + store.exhaustivity = .off + + await store.send(.openHandoffHud) + + let hud = try #require(store.state.handoffHud) + #expect(hud.targets.first?.kind == .profile(recommended.id, runtime: .codex)) + #expect(Array(hud.targets.map(\.title).prefix(2)) == [recommended.name, first.name]) + } + @Test(.dependencies) func openHandoffHudWarnsWithoutDetectedAgent() async throws { let root = try makeTempRoot() defer { remove(root) } @@ -323,7 +371,11 @@ struct AppFeatureHandoffTests { await store.send(.handoffHud(.presented(.confirmSelection))) // The HUD asked the live agent to hand off itself — nothing launched yet. - #expect(injected.value.first?.contains("prowl handoff to claude --brief -") == true) + #expect( + injected.value.first?.contains( + "prowl handoff to claude --pane \(sourceSurfaceID.uuidString) --brief -" + ) == true + ) guard case .running(let run)? = store.state.handoffHud?.phase, run.stage == .requesting else { Issue.record("Expected requesting stage, got \(String(describing: store.state.handoffHud?.phase))") return diff --git a/supacodeTests/HandoffCommandHandlerTests.swift b/supacodeTests/HandoffCommandHandlerTests.swift index ad8e56fa1..44ffc2f2b 100644 --- a/supacodeTests/HandoffCommandHandlerTests.swift +++ b/supacodeTests/HandoffCommandHandlerTests.swift @@ -17,6 +17,8 @@ nonisolated private let validHandoffBriefing = """ ## Next Steps 1. Review the PR. """ +nonisolated private let handoffHandlerSourcePaneID = + UUID(uuidString: "A29DBA75-3B49-490B-912F-0B2A894F6262")! @MainActor struct HandoffCommandHandlerTests { @@ -34,6 +36,29 @@ struct HandoffCommandHandlerTests { private let fixedDate = Date(timeIntervalSince1970: 1_760_000_000) + private func profile( + id: AgentProfile.ID = UUID(), + name: String = "Codex · Work", + isEnabled: Bool = true, + model: String? = nil, + reasoningEffort: String? = nil, + bindsDedicatedHome: Bool = true + ) -> AgentProfile { + AgentProfile( + id: id, + name: name, + isEnabled: isEnabled, + runtime: .codex, + model: model, + reasoningEffort: reasoningEffort, + extraArguments: "-p work", + environmentOverrides: [ + AgentProfileEnvironmentOverride(name: "OPENAI_BASE_URL", value: "https://example.test/v1") + ], + bindsDedicatedHome: bindsDedicatedHome + ) + } + private func makeHandler( root: URL, outgoingAgent: String?, @@ -50,7 +75,7 @@ struct HandoffCommandHandlerTests { ), sessionContext: HandoffStore.SessionContext? = HandoffStore.SessionContext( agent: "codex", - paneID: "pane-0", + paneID: handoffHandlerSourcePaneID.uuidString, paneTitle: "codex", source: "terminal-scrollback", confidence: "fallback", @@ -61,9 +86,12 @@ struct HandoffCommandHandlerTests { ), resolveFailure: HandoffResolveError? = nil, launchSpy: (@MainActor (AgentStartRequest) -> Void)? = nil, + profiles: [AgentProfile] = [], + profileLaunchSpy: (@MainActor (AgentProfileLaunchPlan) -> Void)? = nil, forkSpy: (@Sendable (AgentResumeRequest, URL) async throws -> String)? = nil, + notificationSpy: (@MainActor (String) -> Void)? = nil, completionSpy: (@MainActor (HandoffCLICompletion) -> Void)? = nil, - requestClaim: ((UUID) -> Bool)? = nil + requestClaim: ((UUID, HandoffRequestExpectation) -> HandoffRequestClaimResult)? = nil ) -> HandoffCommandHandler { HandoffCommandHandler( @@ -76,7 +104,7 @@ struct HandoffCommandHandlerTests { worktreeID: "ws", worktreeName: "Workspace", rootPath: root.path(percentEncoded: false), - paneID: "pane-0", + paneID: handoffHandlerSourcePaneID.uuidString, outgoingAgent: outgoingAgent, outgoingLaunchObservation: outgoingLaunchObservation, outgoingSession: outgoingSession, @@ -89,15 +117,25 @@ struct HandoffCommandHandlerTests { launchSpy?(request) return launched }, + profileProvider: { profileID in + profiles.first { $0.id == profileID } + }, + profileLaunchProvider: { _, plan in + profileLaunchSpy?(plan) + return launched + }, forkProvider: { request, directory in guard let forkSpy else { return validHandoffBriefing } return try await forkSpy(request, directory) }, + notifyLaunch: { _, _, receiverDisplayName in + notificationSpy?(receiverDisplayName) + }, completionObserver: { completion in completionSpy?(completion) }, - requestAuthorizer: { requestID in - requestClaim?(requestID) ?? true + requestAuthorizer: { requestID, expectation in + requestClaim?(requestID, expectation) ?? .claimed }, now: { [fixedDate] in fixedDate } ) @@ -230,7 +268,7 @@ struct HandoffCommandHandlerTests { let handler = makeHandler( root: root, outgoingAgent: "codex", - requestClaim: { _ in false } + requestClaim: { _, _ in .unavailable } ) @@ -250,6 +288,43 @@ struct HandoffCommandHandlerTests { #expect(!FileManager.default.fileExists(atPath: root.appending(path: ".prowl").path(percentEncoded: false))) } + @Test func mismatchedHudRequestIsRejectedWithoutConsumingArtifacts() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let requestID = UUID() + let actual = LockIsolated(nil) + let handler = makeHandler( + root: root, + outgoingAgent: "codex", + requestClaim: { _, expectation in + actual.setValue(expectation) + return .mismatch + } + ) + + let response = await handler.handle( + envelope: envelope( + HandoffInput( + action: .toAgent, + toAgent: "claude", + brief: validHandoffBriefing, + requestID: requestID + ) + ) + ) + + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.invalidArgument) + #expect( + actual.value + == HandoffRequestExpectation( + sourcePaneID: handoffHandlerSourcePaneID, + operation: .handoff(target: .runtimeDefault(.claude)) + ) + ) + #expect(!FileManager.default.fileExists(atPath: root.appending(path: ".prowl").path(percentEncoded: false))) + } + @Test func briefAndNoBriefAreMutuallyExclusive() async throws { let root = try makeTempRoot() defer { remove(root) } @@ -478,11 +553,168 @@ struct HandoffCommandHandlerTests { #expect(log.contains("briefing=inline")) let completion = try #require(completions.value.first) #expect(completion.action == .toAgent) - #expect(completion.sourcePaneID == "pane-0") + #expect(completion.sourcePaneID == handoffHandlerSourcePaneID.uuidString) #expect(completion.toAgent == "claude") #expect(completion.launched?.paneID == "pane-1") } + @Test func profileTargetUsesFrozenProfileLaunchPlanAndReportsIdentity() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let selectedProfile = profile(model: "profile-model", reasoningEffort: "xhigh") + let plan = LockIsolated(nil) + let runtimeLaunchCalled = LockIsolated(false) + let notifiedReceiver = LockIsolated(nil) + let completions = LockIsolated<[HandoffCLICompletion]>([]) + let handler = makeHandler( + root: root, + outgoingAgent: "codex", + outgoingLaunchObservation: AgentLaunchObservation( + model: "outgoing-model", + executionMode: .unrestricted + ), + launchSpy: { _ in runtimeLaunchCalled.setValue(true) }, + profiles: [selectedProfile], + profileLaunchSpy: { plan.setValue($0) }, + notificationSpy: { notifiedReceiver.setValue($0) }, + completionSpy: { completion in completions.withValue { $0.append(completion) } } + ) + + let response = await handler.handle( + envelope: envelope( + HandoffInput( + action: .toAgent, + toProfileID: selectedProfile.id, + brief: validHandoffBriefing + ) + ) + ) + + #expect(response.ok) + #expect(runtimeLaunchCalled.value == false) + let launchPlan = try #require(plan.value) + #expect(launchPlan.profileID == selectedProfile.id) + #expect( + Array(launchPlan.invocation.arguments.prefix(6)) == [ + "--model", "profile-model", "-c", "model_reasoning_effort=xhigh", "-p", "work", + ]) + #expect(launchPlan.invocation.arguments.last?.contains(".prowl/handoff/current.md") == true) + #expect(launchPlan.surfaceEnvironment["PROWL_ENV_OPENAI_BASE_URL"] == "https://example.test/v1") + #expect(launchPlan.dedicatedHome != nil) + #expect(!launchPlan.terminalInput.contains("outgoing-model")) + + let payload = try #require(try response.data?.decode(as: HandoffCommandPayload.self)) + #expect(payload.toAgent == "codex") + #expect(payload.toProfileID == selectedProfile.id) + #expect(payload.toProfileName == selectedProfile.name) + let completion = try #require(completions.value.first) + #expect(completion.toProfileID == selectedProfile.id) + #expect(completion.toProfileName == selectedProfile.name) + #expect(completion.failureMessage == nil) + #expect(completion.artifactsReady) + #expect(notifiedReceiver.value == selectedProfile.name) + } + + @Test func profileNoLaunchResolvesIdentityWithoutPlanningOrLaunching() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let selectedProfile = profile() + let profileLaunchCalled = LockIsolated(false) + let handler = makeHandler( + root: root, + outgoingAgent: "codex", + profiles: [selectedProfile], + profileLaunchSpy: { _ in profileLaunchCalled.setValue(true) } + ) + + let response = await handler.handle( + envelope: envelope( + HandoffInput( + action: .toAgent, + toProfileID: selectedProfile.id, + launch: false, + brief: validHandoffBriefing + ) + ) + ) + + #expect(response.ok) + #expect(profileLaunchCalled.value == false) + let payload = try #require(try response.data?.decode(as: HandoffCommandPayload.self)) + #expect(payload.toAgent == "codex") + #expect(payload.toProfileID == selectedProfile.id) + #expect(payload.toProfileName == selectedProfile.name) + #expect(payload.launchedPane == nil) + } + + @Test func missingProfileFailsBeforeArtifactMutationAndCompletesClaimedRequest() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let missingProfileID = UUID() + let completions = LockIsolated<[HandoffCLICompletion]>([]) + let handler = makeHandler( + root: root, + outgoingAgent: "codex", + completionSpy: { completion in completions.withValue { $0.append(completion) } } + ) + + let response = await handler.handle( + envelope: envelope( + HandoffInput( + action: .toAgent, + toProfileID: missingProfileID, + brief: validHandoffBriefing, + requestID: UUID() + ) + ) + ) + + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.invalidArgument) + #expect(!FileManager.default.fileExists(atPath: root.appending(path: ".prowl").path(percentEncoded: false))) + let completion = try #require(completions.value.first) + #expect(completion.toProfileID == missingProfileID) + #expect(completion.failureMessage != nil) + #expect(completion.artifactsReady == false) + } + + @Test func profileLaunchFailureKeepsArtifactsAndReportsTerminalCompletion() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let selectedProfile = profile(name: "Codex\n\"Work\"", bindsDedicatedHome: false) + let completions = LockIsolated<[HandoffCLICompletion]>([]) + let handler = makeHandler( + root: root, + outgoingAgent: "codex", + launched: nil, + profiles: [selectedProfile], + completionSpy: { completion in completions.withValue { $0.append(completion) } } + ) + + let response = await handler.handle( + envelope: envelope( + HandoffInput( + action: .toAgent, + toProfileID: selectedProfile.id, + brief: validHandoffBriefing, + requestID: UUID() + ) + ) + ) + + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.handoffFailed) + #expect(HandoffStore(rootURL: root).hasCurrentArtifact) + let completion = try #require(completions.value.first) + #expect(completion.toProfileID == selectedProfile.id) + #expect(completion.failureMessage?.contains("Progress was saved") == true) + #expect(completion.artifactsReady) + let log = try String(contentsOf: HandoffStore(rootURL: root).logURL, encoding: .utf8) + #expect(log.contains("profile_id=\(selectedProfile.id.uuidString)")) + #expect(log.contains("profile=\"Codex 'Work'\"")) + #expect(!log.contains("https://example.test")) + } + @Test func toWithFailedForkDegradesToContextOnlyAndRemovesStaleBriefing() async throws { let root = try makeTempRoot() defer { remove(root) } diff --git a/supacodeTests/HandoffHudFeatureTests.swift b/supacodeTests/HandoffHudFeatureTests.swift index bc09a147e..e8785b6a0 100644 --- a/supacodeTests/HandoffHudFeatureTests.swift +++ b/supacodeTests/HandoffHudFeatureTests.swift @@ -3,6 +3,7 @@ import ConcurrencyExtras import DependenciesTestSupport import Foundation import IdentifiedCollections +import Sharing import Testing @testable import supacode @@ -143,6 +144,32 @@ struct HandoffHudFeatureTests { #expect(state.source.sourceSurfaceID == sourcePaneID) } + @Test func makeOrdersRecommendedProfilesBeforeRuntimeDefaults() throws { + let root = try makeTempRoot() + defer { remove(root) } + let first = AgentProfile(name: "Claude · Personal", runtime: .claude) + let recommended = AgentProfile(name: "Codex · Work", runtime: .codex) + let disabled = AgentProfile(name: "Disabled", isEnabled: false, runtime: .codex) + + let state = try #require( + HandoffHudFeature.State.make( + worktree: makeWorktree(root: root), + source: makeSourceContext(), + profiles: [first, recommended, disabled], + designatedProfileID: recommended.id + ) + ) + + #expect(state.selectedIndex == 0) + #expect( + state.targets.map(\.title) == [ + "Codex · Work", "Claude · Personal", "Claude Code", "Codex", "Only save progress, don't hand off", + ]) + #expect(state.targets[0].kind == .profile(recommended.id, runtime: .codex)) + #expect(state.targets[0].subtitle.contains("Recommended")) + #expect(state.targets[2].subtitle.hasPrefix("Runtime Default")) + } + @Test func makeWithMediumConfidenceSkipsForkRequest() throws { let root = try makeTempRoot() defer { remove(root) } @@ -237,12 +264,65 @@ struct HandoffHudFeatureTests { let request = try #require(injected.value.first) #expect(request.worktreeID == worktree.id) #expect(request.surfaceID == sourcePaneID) - #expect(request.text.contains("prowl handoff to claude --brief -")) + #expect(request.text.contains("prowl handoff to claude --pane \(sourcePaneID.uuidString) --brief -")) #expect(request.text.contains("\(HandoffInput.requestIDEnvironmentKey)=\(requestID.uuidString)")) #expect(request.text.contains("## Objective")) #expect(!request.text.contains("\n")) } + @Test(.dependencies) func confirmProfileInjectsStableIDAndExplicitSourcePane() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let worktree = makeWorktree(root: root) + let profile = AgentProfile(name: "Codex · Work", runtime: .codex) + let initial = try #require( + HandoffHudFeature.State.make( + worktree: worktree, + source: makeSourceContext(), + profiles: [profile] + ) + ) + let registered = LockIsolated(nil) + let injected = LockIsolated(nil) + + let store = TestStore(initialState: initial) { + HandoffHudFeature() + } withDependencies: { + $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) + $0.uuid = UUIDGenerator { requestID } + $0.handoffRequestClient = HandoffRequestClient( + register: { _, expectation in registered.setValue(expectation) }, + supersede: { _ in true } + ) + $0[TerminalClient.self].sendTextToSurface = { _, _, text in + injected.setValue(text) + return true + } + } + + await store.send(.confirmSelection) { + $0.phase = .running( + HandoffHudRun( + target: $0.targets[0], + startedAt: Date(timeIntervalSince1970: 1_760_000_000), + stage: .requesting, + requestID: requestID + ) + ) + } + + #expect( + registered.value + == HandoffRequestExpectation( + sourcePaneID: sourcePaneID, + operation: .handoff(target: .profile(profile.id)) + ) + ) + #expect(injected.value?.contains("--agent-profile-id \(profile.id.uuidString)") == true) + #expect(injected.value?.contains("--pane \(sourcePaneID.uuidString)") == true) + #expect(!((injected.value ?? "").contains(profile.name))) + } + @Test(.dependencies) func cliCompletionFromSourcePaneFinishesAndFocusesReceiver() async throws { let root = try makeTempRoot() defer { remove(root) } @@ -295,6 +375,114 @@ struct HandoffHudFeatureTests { #expect(focusSurfaceID.uuidString == launched.paneID) } + @Test(.dependencies) func profileLaunchFailureFinishesWithoutFocusing() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let worktree = makeWorktree(root: root) + let profile = AgentProfile(name: "Codex · Work", runtime: .codex) + let initial = try #require( + HandoffHudFeature.State.make( + worktree: worktree, + source: makeSourceContext(), + profiles: [profile] + ) + ) + let focused = LockIsolated(false) + let store = TestStore(initialState: initial) { + HandoffHudFeature() + } withDependencies: { + $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) + $0.uuid = UUIDGenerator { requestID } + $0[TerminalClient.self].sendTextToSurface = { _, _, _ in true } + $0[TerminalClient.self].focusSurface = { _, _ in + focused.setValue(true) + return true + } + } + + await store.send(.confirmSelection) { + $0.phase = .running( + HandoffHudRun( + target: $0.targets[0], + startedAt: Date(timeIntervalSince1970: 1_760_000_000), + stage: .requesting, + requestID: requestID + ) + ) + } + await store.send( + .cliCompleted( + HandoffCLICompletion( + action: .toAgent, + sourcePaneID: sourcePaneID.uuidString, + toAgent: "codex", + toProfileID: profile.id, + toProfileName: profile.name, + briefing: .inline, + launched: nil, + failureMessage: "Progress was saved, but Codex · Work could not be launched.", + artifactsReady: true, + requestID: requestID + ) + ) + ) { + $0.phase = .finished( + .failed(message: "Progress was saved, but Codex · Work could not be launched.") + ) + } + #expect(focused.value == false) + } + + @Test(.dependencies) func profileCompletionUsesLatestResolvedName() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let worktree = makeWorktree(root: root) + let profile = AgentProfile(name: "Codex · Work", runtime: .codex) + let initial = try #require( + HandoffHudFeature.State.make( + worktree: worktree, + source: makeSourceContext(), + profiles: [profile] + ) + ) + let launched = launchedPane(worktreeID: worktree.id) + let store = TestStore(initialState: initial) { + HandoffHudFeature() + } withDependencies: { + $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) + $0.uuid = UUIDGenerator { requestID } + $0[TerminalClient.self].sendTextToSurface = { _, _, _ in true } + $0[TerminalClient.self].focusSurface = { _, _ in true } + } + + await store.send(.confirmSelection) { + $0.phase = .running( + HandoffHudRun( + target: $0.targets[0], + startedAt: Date(timeIntervalSince1970: 1_760_000_000), + stage: .requesting, + requestID: requestID + ) + ) + } + await store.send( + .cliCompleted( + HandoffCLICompletion( + action: .toAgent, + sourcePaneID: sourcePaneID.uuidString, + toAgent: "codex", + toProfileID: profile.id, + toProfileName: "Codex · Renamed", + briefing: .inline, + launched: launched, + requestID: requestID + ) + ) + ) { + $0.phase = .finished(.handedOff(agentDisplayName: "Codex · Renamed")) + } + } + @Test(.dependencies) func cliCompletionFromOtherPaneOrActionIsIgnored() async throws { let root = try makeTempRoot() defer { remove(root) } @@ -404,7 +592,9 @@ struct HandoffHudFeatureTests { requestID: requestID) ) } - #expect(injected.value.first?.contains("prowl handoff save --brief -") == true) + #expect( + injected.value.first?.contains("prowl handoff save --pane \(sourcePaneID.uuidString) --brief -") == true + ) await store.send( .cliCompleted( @@ -444,7 +634,9 @@ struct HandoffHudFeatureTests { $0.date.now = startedAt $0.uuid = UUIDGenerator { requestID } $0.handoffRequestClient = HandoffRequestClient( - register: { requestRegistry.register($0) }, + register: { requestID, expectation in + requestRegistry.register(requestID, expectation: expectation) + }, supersede: { requestRegistry.supersede($0) } ) @@ -465,7 +657,15 @@ struct HandoffHudFeatureTests { HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .forking, requestID: requestID) ) } - #expect(!requestRegistry.claim(requestID)) + #expect( + requestRegistry.claim( + requestID, + actual: HandoffRequestExpectation( + sourcePaneID: sourcePaneID, + operation: .handoff(target: .runtimeDefault(.claude)) + ) + ) == .unavailable + ) await store.receive(\.fallbackBriefingCollected) { $0.phase = .running( HandoffHudRun(target: $0.targets[claudeIndex], startedAt: startedAt, stage: .finishing, requestID: requestID) @@ -542,6 +742,162 @@ struct HandoffHudFeatureTests { #expect(log.contains("briefing=none")) } + @Test(.dependencies) func profileFallbackUsesSharedLauncherAndFocusesExactSurface() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let worktree = makeWorktree(root: root) + let profile = AgentProfile( + name: "Codex · Work", + runtime: .codex, + model: "profile-model", + placement: .split, + extraArguments: "-p work", + environmentOverrides: [ + AgentProfileEnvironmentOverride(name: "OPENAI_API_KEY", value: "secret") + ] + ) + let initial = try #require( + HandoffHudFeature.State.make( + worktree: worktree, + source: makeSourceContext( + observation: AgentLaunchObservation(model: "outgoing-model", executionMode: .unrestricted) + ), + profiles: [profile] + ) + ) + let storage = SettingsTestStorage() + let launchedPlan = LockIsolated(nil) + let launchContext = LockIsolated(nil) + let launchedSurfaceID = UUID() + let focused = LockIsolated(nil) + + let store = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.userGlobalSettings) var settings + $settings.withLock { $0.agentProfiles = [profile] } + return TestStore(initialState: initial) { + HandoffHudFeature() + } withDependencies: { + $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) + $0.uuid = UUIDGenerator { requestID } + $0[TerminalClient.self].sendTextToSurface = { _, _, _ in true } + $0[TerminalClient.self].launchAgentProfile = { plan, launchedWorktree, context in + #expect(launchedWorktree == worktree) + launchedPlan.setValue(plan) + launchContext.setValue(context) + return AgentProfileLaunchResult( + tabID: TerminalTabID(), + surfaceID: launchedSurfaceID, + paneTitle: profile.name + ) + } + $0[TerminalClient.self].focusSurface = { _, surfaceID in + focused.setValue(surfaceID) + return true + } + } + } + + await store.send(.confirmSelection) { + $0.phase = .running( + HandoffHudRun( + target: $0.targets[0], + startedAt: Date(timeIntervalSince1970: 1_760_000_000), + stage: .requesting, + requestID: requestID + ) + ) + } + await store.send(.fallbackContextOnlyTapped) { + $0.phase = .running( + HandoffHudRun( + target: $0.targets[0], + startedAt: Date(timeIntervalSince1970: 1_760_000_000), + stage: .finishing, + requestID: requestID + ) + ) + } + await store.receive(\.fallbackFinished) { + $0.phase = .finished(.handedOff(agentDisplayName: profile.name)) + } + + let plan = try #require(launchedPlan.value) + #expect(plan.profileID == profile.id) + #expect(plan.invocation.arguments.contains("profile-model")) + #expect(plan.invocation.arguments.contains("work")) + #expect(!plan.terminalInput.contains("outgoing-model")) + #expect(plan.surfaceEnvironment["PROWL_ENV_OPENAI_API_KEY"] == "secret") + guard case .handoffBackgroundTab(let launchRoot) = launchContext.value else { + Issue.record("Expected a handoff background launch context") + return + } + #expect(launchRoot == root) + #expect(focused.value == launchedSurfaceID) + let log = try String(contentsOf: HandoffStore(rootURL: root).logURL, encoding: .utf8) + #expect(log.contains("profile_id=\(profile.id.uuidString)")) + #expect(!log.contains("secret")) + } + + @Test(.dependencies) func deletedProfileFallbackFailsBeforeArtifacts() async throws { + let root = try makeTempRoot() + defer { remove(root) } + let worktree = makeWorktree(root: root) + let profile = AgentProfile(name: "Deleted", runtime: .codex) + let initial = try #require( + HandoffHudFeature.State.make( + worktree: worktree, + source: makeSourceContext(), + profiles: [profile] + ) + ) + let storage = SettingsTestStorage() + let launched = LockIsolated(false) + let store = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + TestStore(initialState: initial) { + HandoffHudFeature() + } withDependencies: { + $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) + $0.uuid = UUIDGenerator { requestID } + $0[TerminalClient.self].sendTextToSurface = { _, _, _ in true } + $0[TerminalClient.self].launchAgentProfile = { _, _, _ in + launched.setValue(true) + return nil + } + } + } + + await store.send(.confirmSelection) { + $0.phase = .running( + HandoffHudRun( + target: $0.targets[0], + startedAt: Date(timeIntervalSince1970: 1_760_000_000), + stage: .requesting, + requestID: requestID + ) + ) + } + await store.send(.fallbackContextOnlyTapped) { + $0.phase = .running( + HandoffHudRun( + target: $0.targets[0], + startedAt: Date(timeIntervalSince1970: 1_760_000_000), + stage: .finishing, + requestID: requestID + ) + ) + } + await store.receive(\.runFailed) { + $0.phase = .finished(.failed(message: "The selected Agent Profile is missing or disabled.")) + } + + #expect(launched.value == false) + #expect(!FileManager.default.fileExists(atPath: root.appending(path: ".prowl").path(percentEncoded: false))) + } + @Test(.dependencies) func failedInjectionFallsBackAutomatically() async throws { let root = try makeTempRoot() defer { remove(root) } diff --git a/supacodeTests/HandoffRequestRegistryTests.swift b/supacodeTests/HandoffRequestRegistryTests.swift index e70375436..6aa75d33a 100644 --- a/supacodeTests/HandoffRequestRegistryTests.swift +++ b/supacodeTests/HandoffRequestRegistryTests.swift @@ -5,33 +5,111 @@ import Testing @MainActor struct HandoffRequestRegistryTests { - @Test func requestCanBeClaimedOnlyOnce() { + private let sourcePaneID = UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000001")! + private let otherPaneID = UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000002")! + private let profileID = UUID(uuidString: "BBBBBBBB-0000-0000-0000-000000000001")! + + private var profileHandoff: HandoffRequestExpectation { + HandoffRequestExpectation( + sourcePaneID: sourcePaneID, + operation: .handoff(target: .profile(profileID)) + ) + } + + @Test func exactRequestCanBeClaimedOnlyOnce() { let registry = HandoffRequestRegistry() let requestID = UUID() - registry.register(requestID) + registry.register(requestID, expectation: profileHandoff) + + #expect(registry.claim(requestID, actual: profileHandoff) == .claimed) + #expect(registry.claim(requestID, actual: profileHandoff) == .unavailable) + } + + @Test func mismatchDoesNotConsumePendingRequest() { + let registry = HandoffRequestRegistry() + let requestID = UUID() + registry.register(requestID, expectation: profileHandoff) + let wrongPane = HandoffRequestExpectation( + sourcePaneID: otherPaneID, + operation: profileHandoff.operation + ) + let wrongTarget = HandoffRequestExpectation( + sourcePaneID: sourcePaneID, + operation: .handoff(target: .runtimeDefault(.codex)) + ) - #expect(registry.claim(requestID)) - #expect(!registry.claim(requestID)) + #expect(registry.claim(requestID, actual: wrongPane) == .mismatch) + #expect(registry.claim(requestID, actual: wrongTarget) == .mismatch) + #expect(registry.claim(requestID, actual: profileHandoff) == .claimed) } - @Test func fallbackSupersedesPendingRequest() { + @Test func unknownAndSupersededRequestsAreUnavailable() { let registry = HandoffRequestRegistry() let requestID = UUID() - registry.register(requestID) + #expect(registry.claim(requestID, actual: profileHandoff) == .unavailable) + registry.register(requestID, expectation: profileHandoff) #expect(registry.supersede(requestID)) - #expect(!registry.claim(requestID)) + #expect(registry.claim(requestID, actual: profileHandoff) == .unavailable) } - @Test func fallbackCannotSupersedeClaimedRequest() { + @Test func duplicateRegistrationCannotReplaceOrReopenARequest() { let registry = HandoffRequestRegistry() let requestID = UUID() + let checkpoint = HandoffRequestExpectation( + sourcePaneID: sourcePaneID, + operation: .checkpoint + ) - registry.register(requestID) - #expect(registry.claim(requestID)) + registry.register(requestID, expectation: profileHandoff) + registry.register(requestID, expectation: checkpoint) + #expect(registry.claim(requestID, actual: checkpoint) == .mismatch) + #expect(registry.claim(requestID, actual: profileHandoff) == .claimed) #expect(!registry.supersede(requestID)) + registry.register(requestID, expectation: checkpoint) + #expect(registry.claim(requestID, actual: checkpoint) == .unavailable) + } + + @Test func profileInjectionCarriesUUIDAndExplicitSourcePane() { + let requestID = UUID(uuidString: "CCCCCCCC-0000-0000-0000-000000000001")! + + let instruction = HandoffInjection.instruction( + for: profileHandoff, + requestID: requestID + ) + + #expect(instruction.contains("prowl handoff to --agent-profile-id \(profileID.uuidString)")) + #expect(instruction.contains("--pane \(sourcePaneID.uuidString) --brief -")) + #expect(instruction.contains("\(HandoffInput.requestIDEnvironmentKey)=\(requestID.uuidString)")) + #expect(!instruction.contains("\n")) + } + + @Test func runtimeAndCheckpointInjectionsCarryExplicitSourcePane() { + let requestID = UUID(uuidString: "CCCCCCCC-0000-0000-0000-000000000001")! + let runtime = HandoffRequestExpectation( + sourcePaneID: sourcePaneID, + operation: .handoff(target: .runtimeDefault(.claude)) + ) + let checkpoint = HandoffRequestExpectation( + sourcePaneID: sourcePaneID, + operation: .checkpoint + ) + + let runtimeInstruction = HandoffInjection.instruction(for: runtime, requestID: requestID) + let checkpointInstruction = HandoffInjection.instruction(for: checkpoint, requestID: requestID) + + #expect( + runtimeInstruction.contains( + "prowl handoff to claude --pane \(sourcePaneID.uuidString) --brief -" + ) + ) + #expect( + checkpointInstruction.contains( + "prowl handoff save --pane \(sourcePaneID.uuidString) --brief -" + ) + ) } } diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 2aca99afd..4231ba951 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -47,6 +47,53 @@ struct WorktreeTerminalManagerTests { #expect(event == .setupScriptConsumed(worktreeID: worktree.id)) } + @Test func profileLaunchReturnsLocationAndEmitsSuccess() async throws { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let profileID = UUID() + let plan = makeAgentProfilePlan(profileID: profileID) + let stream = manager.eventStream() + + let result = try #require( + manager.launchAgentProfile( + plan, + in: worktree, + context: .handoffBackgroundTab(root: worktree.repositoryRootURL) + ) + ) + let event = await nextEvent(stream) { event in + if case .agentProfileLaunched = event { + return true + } + return false + } + defer { manager.state(for: worktree).closeAllSurfaces() } + + #expect(result.paneTitle == plan.profileName) + #expect(manager.state(for: worktree).tabID(containing: result.surfaceID) == result.tabID) + #expect(event == .agentProfileLaunched(worktreeID: worktree.id, profileID: profileID)) + } + + @Test func profileLaunchFailureReturnsNilAndEmitsFailure() async { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let plan = makeAgentProfilePlan( + dedicatedHome: URL(fileURLWithPath: "/tmp/prowl-test-outside-base/home", isDirectory: true) + ) + let stream = manager.eventStream() + + let result = manager.launchAgentProfile(plan, in: worktree) + let event = await nextEvent(stream) { event in + if case .agentProfileLaunchFailed = event { + return true + } + return false + } + + #expect(result == nil) + #expect(event == .agentProfileLaunchFailed(worktreeID: worktree.id, profileName: plan.profileName)) + } + @Test func syncPreferredFontSizeNoOpForMissingState() async { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let stream = manager.eventStream() @@ -639,6 +686,23 @@ struct WorktreeTerminalManagerTests { ) } + private func makeAgentProfilePlan( + profileID: UUID = UUID(), + dedicatedHome: URL? = nil + ) -> AgentProfileLaunchPlan { + AgentProfileLaunchPlan( + profileID: profileID, + profileName: "Codex · Work", + runtime: .codex, + invocation: AgentInvocation(executable: "codex", arguments: []), + commandEnvironmentTokens: [], + placement: .tab, + splitDirection: .right, + surfaceEnvironment: [:], + dedicatedHome: dedicatedHome + ) + } + @Test func busyAgentFoldsIntoTaskStatusAndEmits() throws { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() diff --git a/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift b/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift index c88921c78..3a1b28cd0 100644 --- a/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift +++ b/supacodeTests/WorktreeTerminalStateAgentProfileTests.swift @@ -22,6 +22,42 @@ struct WorktreeTerminalStateAgentProfileTests { #expect(state.launchProfilesBySurface.isEmpty) } + @Test func handoffLaunchCreatesAnUnselectedTabAtTheRequestedRoot() throws { + let state = makeState() + let originalTabID = try #require(state.createTab()) + let originalSurfaceID = try #require(state.focusedSurfaceId(in: originalTabID)) + let profileID = UUID() + let root = URL(fileURLWithPath: "/tmp/repo/handoff-root", isDirectory: true) + let plan = makePlan( + dedicatedHome: nil, + profileID: profileID, + placement: .split, + surfaceEnvironment: ["PROWL_ENV_API_KEY": "secret"] + ) + defer { state.closeAllSurfaces() } + + let result = try #require( + state.launchAgentProfile(plan, context: .handoffBackgroundTab(root: root)) + ) + + #expect(state.tabManager.tabs.count == 2) + #expect(state.tabManager.selectedTabId == originalTabID) + #expect(state.focusedSurfaceId(in: originalTabID) == originalSurfaceID) + #expect(result.tabID != originalTabID) + #expect(state.tabID(containing: result.surfaceID) == result.tabID) + #expect(result.paneTitle == plan.profileName) + #expect(state.surfaceView(for: result.surfaceID)?.launchWorkingDirectory == root) + #expect( + state.launchProfilesBySurface[result.surfaceID] + == WorktreeTerminalState.SurfaceLaunchProfile( + profileID: profileID, + name: plan.profileName, + runtime: plan.runtime, + dedicatedHome: nil + ) + ) + } + @Test func launchProfileNameOnlyAppliesToTheLaunchedRuntime() { let state = makeState() let surfaceID = UUID() @@ -70,16 +106,21 @@ struct WorktreeTerminalStateAgentProfileTests { ) } - private func makePlan(dedicatedHome: URL?) -> AgentProfileLaunchPlan { + private func makePlan( + dedicatedHome: URL?, + profileID: UUID = UUID(), + placement: AgentProfilePlacement = .tab, + surfaceEnvironment: [String: String] = [:] + ) -> AgentProfileLaunchPlan { AgentProfileLaunchPlan( - profileID: UUID(), + profileID: profileID, profileName: "Codex · Bound", runtime: .codex, invocation: AgentInvocation(executable: "codex", arguments: []), commandEnvironmentTokens: [], - placement: .tab, + placement: placement, splitDirection: .right, - surfaceEnvironment: [:], + surfaceEnvironment: surfaceEnvironment, dedicatedHome: dedicatedHome ) } From 7f4464e148c6763b2b42c8d75efaae2e3c8f488f Mon Sep 17 00:00:00 2001 From: Sunset Date: Sat, 1 Aug 2026 23:03:28 +0800 Subject: [PATCH 4/5] docs: document profile-aware handoff --- docs/.sync-meta.json | 4 +-- docs/components/agent-profiles.md | 21 ++++++++++++ docs/components/cli.md | 52 ++++++++++++++++++++++++++-- docs/components/handoff.md | 56 ++++++++++++++++++++++++------- skills/prowl-cli/SKILL.md | 28 +++++++++++++++- 5 files changed, 142 insertions(+), 19 deletions(-) diff --git a/docs/.sync-meta.json b/docs/.sync-meta.json index cbb8d9cfa..08dadd412 100644 --- a/docs/.sync-meta.json +++ b/docs/.sync-meta.json @@ -1,6 +1,6 @@ { "_comment": "Metadata for the docs/ manual. The last_synced_commit is the commit at which docs/ was last verified against the implementation; the sync-docs skill (.claude/skills/sync-docs/SKILL.md) diffs HEAD against it and updates this file. Committed to git on purpose so the baseline persists across sessions and machines. A dotfile + .json so a future docs website does not render it as a page.", - "last_synced_commit": "300aa699fb831f6415a4d7d4a63fc6442940fc29", + "last_synced_commit": "0140730199d0e91565cc75f79ba8f4ae7780317b", "last_synced_date": "2026-08-01", - "note": "Verified the Agent Profiles range (PRs #620-#640): feature docs were maintained in-branch; this sync only noted the agent-profile fields now sharing the custom-command JSON files in reference/settings-fields.md." + "note": "Documented profile-aware handoff across the HUD, direct CLI, Runtime Default compatibility, failure persistence, and Profile privacy boundaries." } diff --git a/docs/components/agent-profiles.md b/docs/components/agent-profiles.md index 5d65ce00e..00353a53d 100644 --- a/docs/components/agent-profiles.md +++ b/docs/components/agent-profiles.md @@ -37,6 +37,13 @@ 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. +- **Hand Off HUD** — enabled Profiles appear before Runtime Default receivers; + the repo's Recommended Profile is first and preselected, then the remaining + Profiles keep Settings order. A handoff uses the Profile's full launch + configuration with a takeover prompt, but always opens a new background tab + rooted at the source worktree — it deliberately ignores the Profile's normal + **Open In** split/tab placement. While the HUD is still waiting, it focuses + the exact receiver pane after success. A launch creates a **new** tab (or split, per placement) in the current worktree, running the agent interactively with no initial prompt. Prowl never @@ -58,6 +65,14 @@ sidebar remains available. Adding a profile opens the same editor immediately. Changing another Settings sidebar section leaves the editor and opens that section's root. +To create a Codex Profile backed by a native Codex config profile, choose +**Add Profile → Codex**, give it a Prowl display name, and put `-p work` (or +your native profile name) in **Extra Arguments**. There is no separate +Codex-profile field: this keeps all argv ordering and quoting in the Codex +runtime adapter. You can then choose the Prowl Profile by name from Hand Off; +the advanced direct CLI form is +`prowl handoff to --agent-profile-id --brief -`. + The editor's **Icon** preview opens an SF Symbol picker. A custom symbol appears where Prowl presents the launch preset: the Settings list, repository Default Agent Profile picker, toolbar Agents popover, and Command Palette. Clearing it @@ -102,6 +117,10 @@ appears in the typed command, shell history, or scrollback. Rules: panes intentionally run with your default environment. Re-launch through the Agents menu to get the profile's environment again. +Profile handoff uses this same launch path. The injected request, handoff JSON, +artifacts, and log carry only Profile identity/runtime — never override values, +Extra Arguments, home paths, or credentials. + ## Dedicated home (separate account) Toggling **Use Dedicated Home** (Advanced) gives the profile its own runtime @@ -132,6 +151,8 @@ when the picker says Standard; any other extra argument (including `--sandbox`/`--ask-for-approval`/`-c` overrides) shows a neutral "effective execution mode follows your extra arguments" note instead of claiming Standard — the semantics belong to your command line. +This is also where a Codex native config profile belongs: `-p work` is passed +unchanged before an optional handoff takeover prompt. The editor opens with a **Profile** section (name, agent, icon), followed by **Launch Preview** — the exact rendered invocation, including the env prefix for bound profiles, using the same rendering as the real launch — then a diff --git a/docs/components/cli.md b/docs/components/cli.md index beac4ff43..d74ca658e 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -255,9 +255,30 @@ for any runnable target. Two subcommands: ```bash prowl handoff to [target] [--brief -|--no-brief] [--note "…"] [--no-launch] +prowl handoff to --agent-profile-id \ + [--pane |--tab |--worktree ] [--brief -|--no-brief] [--note "…"] [--no-launch] prowl handoff save [target] [--brief -|--no-brief] [--note "…"] ``` +`handoff to` accepts **exactly one** receiver: either the runtime positional +(``) or `--agent-profile-id `. A Profile UUID resolves the latest +persisted, enabled [Agent Profile](agent-profiles.md) when the command runs. +The Profile form has no positional source because that would be ambiguous; +use `--pane`, `--tab`, or `--worktree` instead. With no selector, both forms +still use the calling pane. + +In normal app use, choose the Profile by name in the Hand Off HUD; Prowl +injects the UUID form automatically. For a direct CLI call, ask the user for +the Profile ID or list only non-secret identity fields with: + +```bash +jq -r '.agentProfiles[] | select(.isEnabled != false) | [.name, .id, .runtime] | @tsv' \ + ~/.prowl/global.onevcat.json +``` + +Never print or paste the full settings file: Profile environment override +values may contain credentials. + **Source resolution.** An explicit selector (`--pane p3`, `--tab t2`, `--worktree `, or the positional target) wins; otherwise the source is **the calling pane** — Prowl maps the `prowl` process's ancestry to the pane @@ -291,6 +312,20 @@ blocking it. saves and accepts the full detected-agent list: `pi`, `claude`, `codex`, `gemini`, `cursor-agent`, `cline`, `opencode`, `copilot`, `kimi`, `droid`, `amp`, `qodercli`, `qwen`, `grok`. +- **`to --agent-profile-id `** — runs the same archive/briefing/context + transition, then launches with the Profile's complete configuration: + runtime, model, effort, execution mode, Extra Arguments, launch-scoped + environment, Dedicated Home/account, and identity. Source model or + unrestricted state is not inherited. The handoff always creates a new + background tab rooted at the source and ignores the Profile's normal split + placement. A native Codex config profile is simply Profile Extra Arguments, + for example `-p work`. Missing, disabled, or unplannable Profiles fail before + artifacts change. `--no-launch` still resolves and reports the Profile but + does not plan, provision, create a pane, or update launch memory. + If Dedicated Home provisioning or surface creation fails after artifact + commit, the command returns `HANDOFF_FAILED` but keeps the archive and saved + progress, and records `launch=failed`; the error text explicitly says the + receiver was not launched. - **`save`** — a deferred-handoff checkpoint: installs a fresh briefing (archiving the replaced one) and regenerates `context.md`, with no destination and no launch. A context-only `save --no-brief` refreshes @@ -308,14 +343,25 @@ prowl handoff to codex --brief - <<'EOF' # self-handoff with inline briefin EOF prowl handoff save --brief - --note "eod checkpoint" <<'EOF' … EOF prowl handoff to claude --pane p7 --json # hand off a third pane (fork fallback) +prowl handoff to --agent-profile-id "$profile_id" --brief - <<'EOF' +# Handoff +## Objective +… +## Current State +… +## Next Steps +… +EOF ``` The outgoing agent is whatever Prowl detects in the source pane (see `pane.agent` in [`list`](#prowl-list)). Response payload (`prowl.cli.handoff.v2`) includes `action`, `artifact_path`, `outgoing_agent`, -`to_agent`, `repos`, `changed_file_count`, `archived_path`, `session_context`, -`briefing` (`inline` / `fork` / `none` / `failed`), `has_briefing`, and -`launched_pane`. `session_context` includes the generated excerpt path plus +`to_agent`, optional `to_profile_id` / `to_profile_name`, `repos`, +`changed_file_count`, `archived_path`, `session_context`, `briefing` (`inline` +/ `fork` / `none` / `failed`), `has_briefing`, and `launched_pane`. +`to_agent` remains the resolved runtime even for Profile targets. +`session_context` includes the generated excerpt path plus native `session_id` / `transcript_path` only when the source pane has unambiguous native-session evidence (the same identity exposed by `prowl agents`); ambiguous sessions are never forked. `current.md` exists iff diff --git a/docs/components/handoff.md b/docs/components/handoff.md index 47d15cb5b..e9a47ae95 100644 --- a/docs/components/handoff.md +++ b/docs/components/handoff.md @@ -125,14 +125,30 @@ exposed by `prowl agents`. Ambiguous sessions are omitted rather than guessed. ```bash prowl handoff to [target] [--brief -|--no-brief] [--note "…"] [--no-launch] +prowl handoff to --agent-profile-id \ + [--pane | --tab | --worktree ] \ + [--brief -|--no-brief] [--note "…"] [--no-launch] prowl handoff save [target] [--brief -|--no-brief] [--note "…"] ``` -- **`to `** runs the full transition and launches the receiver in a - background tab. Interactive launch is verified for `claude` and `codex`; - `--no-launch` still archives + saves and accepts every detected-agent - token (`pi`, `claude`, `codex`, `gemini`, `cursor-agent`, `cline`, - `opencode`, `copilot`, `kimi`, `droid`, `amp`, `qodercli`, `qwen`, `grok`). +- **`to `** selects a Runtime Default receiver and preserves the + existing portable source inheritance: an explicitly observed unrestricted + mode can carry across the verified Claude Code/Codex adapters, while model + identifiers stay within one runtime family. Interactive launch is verified + for `claude` and `codex`; `--no-launch` still archives + saves and accepts + every detected-agent token (`pi`, `claude`, `codex`, `gemini`, + `cursor-agent`, `cline`, `opencode`, `copilot`, `kimi`, `droid`, `amp`, + `qodercli`, `qwen`, `grok`). +- **`to --agent-profile-id `** selects an enabled + [Prowl Agent Profile](agent-profiles.md). The Profile is authoritative for + runtime, model, effort, execution mode, Extra Arguments, environment, + Dedicated Home/account, and pane identity; none of those values are + inherited from the source. For example, a Codex native config profile stays + ordinary Profile Extra Arguments: `-p work`. A Profile handoff always opens + a new background tab in the source worktree, ignoring that Profile's manual + **Open In** split/tab choice. `--no-launch` resolves and records the enabled + Profile but does not prepare a launch, provision its home, create a pane, or + update Last Launched Profile. - **`save`** is the deferred-handoff checkpoint: install a fresh briefing and regenerate context, with no destination and no launch. Use it when you stop for the day and the successor doesn't exist yet. A checkpoint never removes @@ -141,7 +157,9 @@ prowl handoff save [target] [--brief -|--no-brief] [--note "…"] ### Who is the source? - An explicit selector (`--pane p3`, `--tab t2`, `--worktree `, or the - positional target) always wins. + positional target) always wins. The positional source is available only in + the runtime form; to override the calling pane in the Profile form, use an + explicit selector flag. - Otherwise the source is **the calling pane**: Prowl resolves the `prowl` process's ancestry to the pane whose shell spawned it. An agent running `prowl handoff to …` inside its pane is therefore handing off **itself** — @@ -157,10 +175,12 @@ fork degrades to context-only rather than blocking a rescue. The receiving agent's kickoff prompt adapts: with a briefing it starts from `current.md`'s Next Steps; without one it orients from `context.md` and the -archive. When Prowl observed the outgoing launch, an explicitly observed -unrestricted execution mode carries over to the **destination launch only** -across the verified claude/codex adapters; model identifiers stay within the -same agent family. Full flag/payload reference: [cli](cli.md#prowl-handoff). +archive. For a Runtime Default receiver, when Prowl observed the outgoing +launch, an explicitly observed unrestricted execution mode carries over to the +**destination launch only** across the verified claude/codex adapters; model +identifiers stay within the same agent family. A Profile receiver instead uses +only its frozen Profile configuration. Full flag/payload reference: +[cli](cli.md#prowl-handoff). ## In the app: the Agents capsule and the Hand Off HUD @@ -177,13 +197,19 @@ the [Active Agents panel](active-agents.md), which targets the row's own pane. The HUD is a trigger and an observer for the same CLI transition: -1. **Choose** — pick the receiving agent (the current agent stays listed as a - fresh-session restart) or **Only save progress, don't hand off**. +1. **Choose** — enabled Agent Profiles appear first, with the current repo's + Recommended Profile selected by default; remaining Profiles keep Settings + order. Runtime Default Claude Code/Codex targets follow (the current agent + stays listed as a fresh-session restart), then **Only save progress, don't + hand off**. With no enabled Profiles, the original Runtime Default list is + unchanged. 2. **Ask the live agent** — Prowl types a one-line request into the source pane asking the agent to run `prowl handoff … --brief -` itself, then waits. The agent writes its briefing in its own words and the transition completes through the CLI service; the HUD observes the completion and - jumps you to the receiver. If the agent is busy, the request queues in its + jumps you to the exact receiver pane. If launch fails after the artifacts + were committed, the HUD says that progress was saved but the receiver was + not launched. If the agent is busy, the request queues in its input — the HUD says so. While waiting the panel is **non-modal**: the keyboard stays with the terminal (the request may trigger permission prompts you need to approve), and clicking outside collapses the panel — @@ -212,6 +238,10 @@ fork fallback is. session, so you can still read or roll back from it. - Keep secrets/tokens out of the briefing; `.prowl/handoff/` is self-ignoring and session excerpts belong in local state, not source control. +- Profile Extra Arguments, environment values, Dedicated Home paths, and + credentials never enter the injected request, CLI payload, handoff + artifacts, or transition log. Only Profile ID/name and resolved runtime are + recorded. ## Gotchas diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 8cd0bc9cc..efb4d1e51 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -311,7 +311,33 @@ prowl handoff to codex --brief - <<'EOF' EOF ``` -Write the briefing from your current working knowledge — required sections are `## Objective`, `## Current State`, and `## Next Steps`. The receiver launches in a background tab of the same worktree; your own session stays open. `prowl handoff save --brief -` writes the same briefing as a checkpoint without launching anyone. Use `--no-brief` only for an intentional context-only handoff, and an explicit `--pane` to hand off a pane other than your own. Details: `docs/components/handoff.md`. +To use a saved Prowl Agent Profile instead of the runtime default, ask the user +for its stable UUID and pass no runtime positional: + +```bash +prowl handoff to --agent-profile-id "$profile_id" --brief - <<'EOF' +# Handoff +## Objective +… +## Current State +… +## Next Steps +… +EOF +``` + +The runtime positional and `--agent-profile-id` are mutually exclusive. The +Profile form rejects a positional source; use `--pane`, `--tab`, or +`--worktree` when handing off another pane. In the app, prefer choosing the +Profile by name in the Hand Off HUD — it injects the UUID and exact source pane +for you. If the user asks you to discover IDs, project only `.name`, `.id`, and +`.runtime` with `jq`; never print or paste the full +`~/.prowl/global.onevcat.json`, because environment override values may contain +credentials. Put a native Codex config profile in Prowl Profile Extra +Arguments, for example `-p work`; do not model `--profile` as an environment +variable. + +Write the briefing from your current working knowledge — required sections are `## Objective`, `## Current State`, and `## Next Steps`. The receiver launches in a background tab of the same worktree; your own session stays open. A Profile handoff keeps its model, effort, mode, Extra Arguments, environment, Dedicated Home, and identity, while ignoring its manual split placement. `prowl handoff save --brief -` writes the same briefing as a checkpoint without launching anyone. Use `--no-brief` only for an intentional context-only handoff, and an explicit `--pane` to hand off a pane other than your own. Details: `docs/components/handoff.md`. ## Command Set From e4778acdabb700dc150a34f239424a5c70fac247 Mon Sep 17 00:00:00 2001 From: Sunset Date: Sat, 1 Aug 2026 23:04:30 +0800 Subject: [PATCH 5/5] docs: link profile-aware handoff PR --- docs-ai/053-agent-profiles/007-profile-aware-handoff.md | 2 +- docs-ai/053-agent-profiles/008-profile-aware-handoff-action.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-ai/053-agent-profiles/007-profile-aware-handoff.md b/docs-ai/053-agent-profiles/007-profile-aware-handoff.md index 99a588ecf..d25c252fd 100644 --- a/docs-ai/053-agent-profiles/007-profile-aware-handoff.md +++ b/docs-ai/053-agent-profiles/007-profile-aware-handoff.md @@ -4,7 +4,7 @@ | --- | --- | | **Date** | 2026-08-01 | | **Status** | Implemented (see [053.008](008-profile-aware-handoff-action.md)) | -| **Primary PRs** | TBD | +| **Primary PRs** | [#651](https://github.com/onevcat/Prowl/pull/651) | | **Related** | [053 plan](000-plan.md), [053.006](006-launch-scoped-environment.md), [047.004](../047-cross-agent-handoff/004-inline-handoff-redesign.md), [047.005](../047-cross-agent-handoff/005-hud-request-ownership.md), [049 Agents HUD](../049-agents-toolbar-entry/000-plan.md), [048 runtime adapters](../048-agent-runtime-adapters/000-plan.md), [handoff manual](../../docs/components/handoff.md) | ## Context diff --git a/docs-ai/053-agent-profiles/008-profile-aware-handoff-action.md b/docs-ai/053-agent-profiles/008-profile-aware-handoff-action.md index aaa0d38bb..be9afa7cc 100644 --- a/docs-ai/053-agent-profiles/008-profile-aware-handoff-action.md +++ b/docs-ai/053-agent-profiles/008-profile-aware-handoff-action.md @@ -4,7 +4,7 @@ | --- | --- | | **Date** | 2026-08-01 | | **Status** | Implemented | -| **Primary PRs** | TBD | +| **Primary PRs** | [#651](https://github.com/onevcat/Prowl/pull/651) | | **Design** | [053.007](007-profile-aware-handoff.md) | ## Result