@henryqw/pi-subagent 27.6.3 → 27.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,13 +25,14 @@ Configure the profiles that your Roles use in `/task-models`. Direct writer term
25
25
 
26
26
  Ask Main: “Have a scout trace sign-in without editing files.” Main calls `delegate_task` and stays available until progress and results arrive.
27
27
 
28
- Commands and UI are for you. Tools and the packaged skill are for Main, except `git_read`, which is only for Role-declared children.
28
+ Commands, prompts, and UI are for you. Tools and the packaged skill are for Main, except `git_read`, which is only for Role-declared children.
29
29
 
30
30
  | Surface | Type | Purpose |
31
31
  | --- | --- | --- |
32
- | `/subagent` | command | Browse direct and isolated work, including requests whose parent checkout was removed. Stop direct workers, inspect isolated work, send follow-ups, or delete owned isolated worktrees after confirmation. Without interactive UI, only reports. |
32
+ | `/subagent` | command | Browse direct and isolated work, including requests whose parent checkout was removed. Stop direct workers, inspect isolated work, send follow-ups, or delete owned isolated worktrees after confirmation. **Select multiple…** applies one action to many rows. Without interactive UI, only reports. |
33
33
  | `/subagent roles` | command | List Role sources, resources, resolved model and thinking level, route errors, and file errors. Read-only; requires no Herdr pane. |
34
- | `/subagent prune` | command | Review remaining worker and integration branches and worktrees. Remove merged resources after confirmation. Requires a running Herdr server; without interactive UI, only reports. |
34
+ | `/subagent prune` | command | Review remaining worker and integration branches and worktrees. Select the items to remove; merged items are selected first. Some blocked items can also be selected, after a separate confirmation. Requires a running Herdr server; without interactive UI, only reports. |
35
+ | `/review [target] [focus]` | prompt | Tell Main to review a change with parallel `reviewer` tasks and give one ship verdict. See [Review a change](#review-a-change). |
35
36
  | `/subagent recover` | command | Classify interrupted isolated work in the canonical repository. Send Main request IDs, blockers, and next actions. Requires no UI or Herdr pane. |
36
37
  | `delegate_task` | tool | Start authorized shared-checkout work or a durable isolated checked graph. With `dryRun: true`, check the request and show the resolved plan without a launch. |
37
38
  | `subagent_status` | tool | Read saved isolated state, exact identities, failed review verdicts, and allowed continuation. Does not replay work. Supports structured output for `codemode`. |
@@ -90,6 +91,21 @@ The launch returns a task ID and first Herdr tab, not the answer. One follow-up
90
91
 
91
92
  To continue a finished worker, add `"continueFrom": "D7.1"` with the same `role`. This opens a new tab with the saved conversation and a new reference. Continue the newest reference from the original checkout and workspace. For eligibility and stop behavior, see [Continue or stop one direct worker](./docs/orchestration.md#continue-or-stop-one-direct-worker).
92
93
 
94
+ ### Review a change
95
+
96
+ Run `/review` to review uncommitted changes against `HEAD`. To review other work, give one target:
97
+
98
+ | Target | Reviewed change |
99
+ | --- | --- |
100
+ | `A..B` | From `A` to `B` |
101
+ | `feature-branch` | From the merge-base with `main` to the branch |
102
+ | `a1b2c3d` | The commit against its first parent |
103
+ | `#42` | The pull request head against its merge-base with the pull request base |
104
+
105
+ Text after the target adds a review focus, for example `/review #42 cache invalidation`. If the first word is not a target, Main reviews uncommitted changes and uses all text as the focus. Main changes the target to exact local commit IDs. Reviewers can read only local Git. So, for a pull request, Main fetches the head and base from the pull request repository to `refs/pi-review/`. Main groups changed files by location, starts one read-only `reviewer` task for each group, and merges the findings.
106
+
107
+ The verdict is **Ship** only if each reviewer task succeeded and answered exactly `PASS`. Otherwise, the verdict is **Do not ship**, with the findings in priority order. If the checkout changed during a review task, the verdict is also **Do not ship**, and you must run the review again. Main does not fix findings. If another `/review` prompt template loads first, Pi keeps that template and reports a name collision.
108
+
93
109
  ### Isolated workflow
94
110
 
95
111
  ![Isolated workers commit checked candidates; Main stages ready work while siblings run, advances dependents, validates the combined tip, and explicitly promotes](./docs/orchestration-architecture.svg)
@@ -143,6 +159,10 @@ Each row starts with a status symbol: `●` active, `✓` completed, `⚠` needs
143
159
 
144
160
  Requests whose parent checkout was removed show in the **Orphaned · parent checkout removed** group from any checkout of the same repository. Every isolated request, including an orphaned request, has **Close and delete worktrees (discard work)**. Select it to review owned resources and confirm deletion. **This permanently deletes committed, uncommitted, and ignored content in the listed checkouts.** Cancel makes no change.
145
161
 
162
+ Select **Select multiple…** to apply one action to many rows. Select a row to mark it `[x]` or clear it, or use **Select all** or **Clear**, then select **Continue**. The next menu shows only the actions that apply. **Close and delete worktrees (discard work)** applies to isolated requests. **Close/cancel-and-release ALL owned workers** applies to direct workflows that can close. Each request shows its review, then one confirmation covers all of them.
163
+
164
+ The command reports the number of selected rows that the action skipped.
165
+
146
166
  Only shown resources can be removed; live jobs, uncertain ownership, or other Herdr panes can block removal. Agents and tools cannot trigger this action. See [Close and delete isolated worktrees](./docs/orchestration.md#close-and-delete-isolated-worktrees) for guards and partial cleanup.
147
167
 
148
168
  Inspection needs a Herdr pane, the configured `scout` Role, and the `fast` route. If it cannot start, the request stays unchanged; Main can use `subagent_status` to read state. See [Inspect and revise active work](./docs/orchestration.md#inspect-and-revise-active-work) for inspection and follow-up details.
@@ -192,6 +212,12 @@ Role Markdown files in `~/.pi/agent/config/pi-subagent/` require frontmatter and
192
212
 
193
213
  The package ships `implementer`, `reviewer`, and `scout`; same-name user Roles override built-ins. Roles define capabilities, not access or isolation. Unknown frontmatter keys invalidate that file and report its path and key. Other valid Roles load; duplicate names become unavailable. Delegation to an invalid file's claimed Role fails with that file's error. Inspect `/subagent roles`.
194
214
 
215
+ The built-in reviewer answers exactly `PASS` when it finds no actionable correctness risk. Otherwise, each finding starts with a priority and a confidence (`high`, `medium`, or `low`), for example `[P1] confidence: high`. Then it gives file and line evidence, the trigger, the impact, and the smallest fix. Each finding blocks approval.
216
+
217
+ - `P0`: breaks release or operation on ordinary input.
218
+ - `P1`: wrong behavior under a realistic condition.
219
+ - `P2`: wrong behavior in a narrow edge case with limited impact.
220
+
195
221
  The built-in reviewer and scout include `git_read`; existing user reviewers and scouts must add it to `tools`. No additional extension or Bash access is necessary. See [Reviewer Git inspection](./docs/orchestration.md#reviewer-git-inspection) for operations, parameters, limits, and Git requirements.
196
222
 
197
223
  Children load only declared resources and required adapters. Roles must list their model provider extension. `codemode` requires both `builtin:codemode` in `extensions` and `codemode` in `tools`. Missing Skills, tools, MCP servers, Roles, or routes fail before productive work. Main-only delegation/recovery tools and `ask_question` stay unavailable to children.
@@ -234,6 +260,6 @@ Unverified tab allocations retain evidence but offer no Close action. Reconcile
234
260
 
235
261
  After coordinator interruption, restart Pi and run `/subagent recover` in the canonical repository. Use `subagent_status` for exact evidence; never take over a live productive lease or replay uncertain prompts, merges, or promotion. See [Recover after interruption](./docs/orchestration.md#recover-after-interruption) and [Recovery and state](./docs/orchestration.md#recovery-and-state).
236
262
 
237
- Run `/subagent prune` for merged leftovers. Removing a checkout deletes its ignored files only after a separate confirmation naming those entries. Dirty, conflicted, changed, or uncertain resources require deliberate inspection. See [Prune leftover worktrees](./docs/orchestration.md#prune-leftover-worktrees) before removal.
263
+ Run `/subagent prune` for leftovers; merged items are selected first. Removing a checkout deletes its ignored files only after a separate confirmation naming those entries. If you select an item that an unfinished request owns, the command closes and deletes that whole request after its review and confirmation. **If you select unowned work with unique commits or changes, a separate confirmation permits its permanent deletion.** Uncertain resources, such as locked checkouts or checkouts that other Herdr panes use, cannot be selected. See [Prune leftover worktrees](./docs/orchestration.md#prune-leftover-worktrees) before removal.
238
264
 
239
- For aborted requests with retained resources, inspect status, resolve blockers, and repeat `subagent_abort` to reconcile cleanup without replaying work. Release rejected candidates and superseded generations explicitly. The extension never pushes, opens pull requests, publishes, deploys, or silently falls back to Main. It force-deletes recoverable work only after the user confirms **Close and delete worktrees (discard work)** in `/subagent`.
265
+ For aborted requests with retained resources, inspect status, resolve blockers, and repeat `subagent_abort` to reconcile cleanup without replaying work. Release rejected candidates and superseded generations explicitly. The extension never pushes, opens pull requests, publishes, deploys, or silently falls back to Main. It force-deletes recoverable work only after the user confirms **Close and delete worktrees (discard work)** in `/subagent` or a force removal in `/subagent prune`.
package/dist/prune.d.ts CHANGED
@@ -1,4 +1,20 @@
1
1
  import { type GitRunner } from "./git-process.ts";
2
+ /** Blockers that a separately confirmed force removal of an unowned item may override; every other kind is hard. */
3
+ export declare const FORCEABLE_PRUNE_BLOCKERS: readonly ["unmerged", "changes", "operation", "submodules"];
4
+ export type PruneBlockerKind = typeof FORCEABLE_PRUNE_BLOCKERS[number] | "owner" | "lease" | "hard";
5
+ export interface PruneBlocker {
6
+ kind: PruneBlockerKind;
7
+ text: string;
8
+ }
9
+ /** Saved identity of a readable request that owns a resource name. */
10
+ export interface PruneRequest {
11
+ id: string;
12
+ name: string;
13
+ status: string;
14
+ directory: string;
15
+ root: string;
16
+ orphaned: boolean;
17
+ }
2
18
  /** One reviewed pi-subagent branch, with its registered checkout when one exists. */
3
19
  export interface PruneItem {
4
20
  branch: string;
@@ -7,12 +23,21 @@ export interface PruneItem {
7
23
  prunable: boolean;
8
24
  /** Why the item is prunable, or every exact blocker. */
9
25
  reason: string;
26
+ blockers: readonly PruneBlocker[];
27
+ /** The only, readable request that owns the item. */
28
+ owner?: PruneRequest;
29
+ /** How a blocked item can still go after separate confirmation: discard of its whole owning request, or
30
+ * force removal when every blocker is forceable. Absent for prunable and hard-blocked items. */
31
+ override?: "discard" | "force";
10
32
  /** Ignored entries as Git collapses them; removing the checkout deletes them. */
11
33
  ignored: readonly string[];
12
34
  }
13
35
  export interface PruneOwnership {
14
- /** The request or state that may still own a resource name (`subagent-<hex>`), if any. */
15
- owner(name: string): string | undefined;
36
+ /** Every request or state that may still own a resource name (`subagent-<hex>`); unreadable state has no request. */
37
+ owners(name: string): readonly {
38
+ label: string;
39
+ request?: PruneRequest;
40
+ }[];
16
41
  /** The live productive lease that blocks pruning, if any. */
17
42
  lease?: string;
18
43
  }
@@ -44,9 +69,10 @@ export declare function inspectPruneCandidates(root: string, mainCwd: string, gu
44
69
  /**
45
70
  * Re-verify each reviewed prunable item immediately before removing its checkout, then delete its
46
71
  * branch only at the reviewed tip. Ignored entries are deleted only when `allowIgnored` and all of
47
- * them were in the review. Returns the repository checkouts that still exist.
72
+ * them were in the review. With `force`, a reviewed forceable item is removed with `--force` while
73
+ * every fresh blocker is forceable and of a reviewed kind. Returns the repository checkouts that still exist.
48
74
  */
49
- export declare function removePruneCandidates(root: string, mainCwd: string, reviewed: readonly PruneItem[], allowIgnored: boolean, guards: PruneGuards, run?: GitRunner): Promise<{
75
+ export declare function removePruneCandidates(root: string, mainCwd: string, reviewed: readonly PruneItem[], allowIgnored: boolean, force: boolean, guards: PruneGuards, run?: GitRunner): Promise<{
50
76
  removals: PruneRemoval[];
51
77
  checkouts: string[];
52
78
  }>;
package/dist/prune.js CHANGED
@@ -6,6 +6,9 @@ import { runProcess } from "./process.js";
6
6
  import { inspectWorktreeDirty, OWNED_BRANCH_PATTERN, WORKTREES_DIRNAME } from "./worktree.js";
7
7
  const HERDR_TIMEOUT_MS = 30_000;
8
8
  const IN_PROGRESS_MARKERS = ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "REBASE_HEAD", "BISECT_LOG", "rebase-merge", "rebase-apply", "sequencer"];
9
+ /** Blockers that a separately confirmed force removal of an unowned item may override; every other kind is hard. */
10
+ export const FORCEABLE_PRUNE_BLOCKERS = ["unmerged", "changes", "operation", "submodules"];
11
+ const forceable = (kind) => FORCEABLE_PRUNE_BLOCKERS.includes(kind);
9
12
  export async function requireGit(run, args, cwd) {
10
13
  const result = await run(args, cwd);
11
14
  if (result.code !== 0)
@@ -62,14 +65,15 @@ async function classify(snap, branch, mainCwd, ownership, panes, run, root) {
62
65
  // A detached or switched owned checkout no longer names the branch; find it by its owned path too.
63
66
  const checkout = snap.checkouts.find((item) => item.branch === branch) ?? snap.checkouts.find((item) => item.path === ownedPath);
64
67
  const blockers = [];
68
+ const block = (kind, text) => blockers.push({ kind, text });
65
69
  if (checkout && checkout.branch !== branch)
66
- blockers.push(`owned checkout is ${checkout.branch ? `on branch ${checkout.branch}` : "detached"}; inspect it manually`);
70
+ block("hard", `owned checkout is ${checkout.branch ? `on branch ${checkout.branch}` : "detached"}; inspect it manually`);
67
71
  let ignored = [];
68
- const owner = ownership.owner(name);
69
- if (owner)
70
- blockers.push(owner);
72
+ const owners = ownership.owners(name);
73
+ for (const { label } of owners)
74
+ block("owner", label);
71
75
  if (ownership.lease)
72
- blockers.push(ownership.lease);
76
+ block("lease", ownership.lease);
73
77
  let merged;
74
78
  for (const target of snap.targets) {
75
79
  const result = await run(["merge-base", "--is-ancestor", tip, target.head], root);
@@ -81,44 +85,49 @@ async function classify(snap, branch, mainCwd, ownership, panes, run, root) {
81
85
  throw new Error(`git merge-base failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`);
82
86
  }
83
87
  if (!merged)
84
- blockers.push(`branch has commits not in ${snap.targets.map(({ label }) => label).join(" or ")}`);
88
+ block("unmerged", `branch has commits not in ${snap.targets.map(({ label }) => label).join(" or ")}`);
85
89
  if (checkout) {
86
90
  const path = checkout.path;
87
91
  if (path !== ownedPath)
88
- blockers.push("checkout is not at its owned .worktrees path");
92
+ block("hard", "checkout is not at its owned .worktrees path");
89
93
  if (checkout.locked)
90
- blockers.push("checkout is locked");
94
+ block("hard", "checkout is locked");
91
95
  if (checkout.missing || !existsSync(path))
92
- blockers.push("checkout directory is missing; inspect it, then run git worktree prune");
96
+ block("hard", "checkout directory is missing; inspect it, then run git worktree prune");
93
97
  else {
94
98
  const real = realpathSync.native(path);
95
99
  if (within(real, realpathSync.native(mainCwd)))
96
- blockers.push("Main runs inside the checkout");
100
+ block("hard", "Main runs inside the checkout");
97
101
  const gitDir = await requireGit(run, ["rev-parse", "--absolute-git-dir"], path);
98
102
  const markers = IN_PROGRESS_MARKERS.filter((marker) => existsSync(join(gitDir, marker)));
99
103
  if (markers.length)
100
- blockers.push(`Git operation in progress (${markers.join(", ")})`);
104
+ block("operation", `Git operation in progress (${markers.join(", ")})`);
101
105
  const dirty = await inspectWorktreeDirty(path, run, false);
102
106
  if (dirty.failure)
103
- blockers.push(`checkout could not be inspected: ${dirty.failure}`);
107
+ block("hard", `checkout could not be inspected: ${dirty.failure}`);
104
108
  else if (dirty.dirty)
105
- blockers.push("checkout has tracked or untracked changes");
109
+ block("changes", "checkout has tracked or untracked changes");
106
110
  else if (dirty.initializedSubmodules)
107
- blockers.push("checkout has initialized submodules; Git cannot remove it without force, so remove it manually");
111
+ block("submodules", "checkout has initialized submodules; Git cannot remove it without force");
108
112
  if (typeof panes === "string")
109
- blockers.push(`Herdr unavailable (${panes}); cannot prove no pane uses the checkout`);
113
+ block("hard", `Herdr unavailable (${panes}); cannot prove no pane uses the checkout`);
110
114
  else {
111
115
  const used = panes.filter((pane) => pane.cwds.some((cwd) => within(path, cwd) || within(real, cwd)));
112
116
  if (used.length)
113
- blockers.push(`Herdr pane ${used.map(({ paneId }) => paneId).join(", ")} works inside the checkout`);
117
+ block("hard", `Herdr pane ${used.map(({ paneId }) => paneId).join(", ")} works inside the checkout`);
114
118
  }
115
119
  ignored = (await requireGit(run, ["status", "--porcelain=v1", "-z", "--ignored=matching", "--untracked-files=normal"], path))
116
120
  .split("\0").filter((entry) => entry.startsWith("!! ")).map((entry) => entry.slice(3));
117
121
  }
118
122
  }
123
+ // The owning request's discard review owns its other blockers; a lease or any second owner keeps the item.
124
+ const owner = owners.length === 1 ? owners[0].request : undefined;
125
+ const override = !blockers.length ? undefined : owner && !blockers.some(({ kind }) => kind === "lease") ? "discard"
126
+ : blockers.every(({ kind }) => forceable(kind)) ? "force" : undefined;
119
127
  return {
120
- branch, tip, ...(checkout ? { path: checkout.path } : {}), prunable: !blockers.length, ignored,
121
- reason: blockers.length ? blockers.join("; ") : `merged into ${merged}; ${checkout ? "clean checkout" : "no checkout"}`,
128
+ branch, tip, ...(checkout ? { path: checkout.path } : {}), prunable: !blockers.length, ignored, blockers,
129
+ ...(owner ? { owner } : {}), ...(override ? { override } : {}),
130
+ reason: blockers.length ? blockers.map(({ text }) => text).join("; ") : `merged into ${merged}; ${checkout ? "clean checkout" : "no checkout"}`,
122
131
  };
123
132
  }
124
133
  const existing = (snap) => snap.checkouts.filter((item) => !item.missing && existsSync(item.path)).map(({ path }) => path);
@@ -142,14 +151,17 @@ export async function inspectPruneCandidates(root, mainCwd, guards, run = runGit
142
151
  items.push(await classify(snap, branch, mainCwd, ownership, panes, run, root));
143
152
  return items;
144
153
  }
145
- async function removeOne(root, mainCwd, reviewed, allowIgnored, guards, run) {
154
+ async function removeOne(root, mainCwd, reviewed, allowIgnored, force, guards, run) {
146
155
  const skip = (reason) => ({ outcome: "skipped", item: reviewed, reason });
147
156
  const fail = (reason) => ({ outcome: "failed", item: reviewed, reason });
148
157
  const { snap, ownership, panes } = await evidence(root, mainCwd, guards, run);
149
158
  if (snap.branches.get(reviewed.branch) !== reviewed.tip)
150
159
  return skip("branch was deleted or moved since review");
151
160
  const fresh = await classify(snap, reviewed.branch, mainCwd, ownership, panes, run, root);
152
- if (!fresh.prunable)
161
+ // Force applies only to an item reviewed as forceable, and only while no blocker of a new or hard kind appears.
162
+ const forced = force && reviewed.override === "force";
163
+ if (forced ? !fresh.blockers.every(({ kind }) => forceable(kind) && reviewed.blockers.some((item) => item.kind === kind))
164
+ : !fresh.prunable)
153
165
  return skip(fresh.reason);
154
166
  if (fresh.path !== reviewed.path)
155
167
  return skip("checkout registration changed since review");
@@ -159,8 +171,8 @@ async function removeOne(root, mainCwd, reviewed, allowIgnored, guards, run) {
159
171
  if (fresh.path) {
160
172
  if (stale())
161
173
  return skip("session or branch changed before removal; reopen /subagent prune");
162
- // Git removes ignored content without --force; the checks above approved it.
163
- const removed = await run(["worktree", "remove", fresh.path], root);
174
+ // Git removes ignored content without --force; the checks above approved it. Force also deletes reviewed changes.
175
+ const removed = await run(["worktree", "remove", ...(forced ? ["--force"] : []), fresh.path], root);
164
176
  if (removed.code !== 0)
165
177
  return fail(`git worktree remove failed: ${removed.stderr.trim().slice(0, 200)}`);
166
178
  if (existsSync(fresh.path))
@@ -176,13 +188,14 @@ async function removeOne(root, mainCwd, reviewed, allowIgnored, guards, run) {
176
188
  /**
177
189
  * Re-verify each reviewed prunable item immediately before removing its checkout, then delete its
178
190
  * branch only at the reviewed tip. Ignored entries are deleted only when `allowIgnored` and all of
179
- * them were in the review. Returns the repository checkouts that still exist.
191
+ * them were in the review. With `force`, a reviewed forceable item is removed with `--force` while
192
+ * every fresh blocker is forceable and of a reviewed kind. Returns the repository checkouts that still exist.
180
193
  */
181
- export async function removePruneCandidates(root, mainCwd, reviewed, allowIgnored, guards, run = runGit) {
194
+ export async function removePruneCandidates(root, mainCwd, reviewed, allowIgnored, force, guards, run = runGit) {
182
195
  const removals = [];
183
196
  for (const item of reviewed) {
184
197
  try {
185
- removals.push(await removeOne(root, mainCwd, item, allowIgnored, guards, run));
198
+ removals.push(await removeOne(root, mainCwd, item, allowIgnored, force, guards, run));
186
199
  }
187
200
  catch (error) {
188
201
  removals.push({ outcome: "failed", item, reason: (error instanceof Error ? error.message : String(error)).slice(0, 300) });
package/dist/runner.js CHANGED
@@ -678,19 +678,23 @@ export class IsolatedRunner {
678
678
  async pruneOwnership(checkouts) {
679
679
  const owners = [];
680
680
  let lease;
681
+ const request = ({ state, path }, orphaned) => ({ id: state.request.id, name: state.request.goal,
682
+ status: inventoryStatus(state), directory: dirname(path), root: state.root, orphaned });
681
683
  const orphans = checkouts[0] ? (await this.store.listOrphans(realpathSync.native(checkouts[0]))).states : [];
682
- for (const { state } of orphans) {
684
+ for (const handle of orphans) {
685
+ const { state } = handle;
683
686
  if (!terminal(state))
684
- owners.push({ label: `owned by orphaned ${state.status} request ${state.request.id}; close and delete it from /subagent`, text: JSON.stringify(state) });
687
+ owners.push({ label: `owned by orphaned ${state.status} request ${state.request.id}; close and delete it from /subagent`, text: JSON.stringify(state), request: request(handle, true) });
685
688
  }
686
689
  for (const checkout of checkouts) {
687
690
  const root = realpathSync.native(checkout);
688
691
  if (!lease && await this.store.hasProductiveRunLease(root))
689
692
  lease = `productive lease is held by a live run in ${root}`;
690
693
  const { states, invalidIds } = await this.store.list(root);
691
- for (const { state } of states) {
694
+ for (const handle of states) {
695
+ const { state } = handle;
692
696
  if (!terminal(state))
693
- owners.push({ label: `owned by ${state.status} request ${state.request.id}`, text: JSON.stringify(state) });
697
+ owners.push({ label: `owned by ${state.status} request ${state.request.id}`, text: JSON.stringify(state), request: request(handle, false) });
694
698
  }
695
699
  for (const id of invalidIds) {
696
700
  let text;
@@ -701,7 +705,8 @@ export class IsolatedRunner {
701
705
  owners.push({ label: `unreadable state ${id} in ${root} may own it`, text });
702
706
  }
703
707
  }
704
- return { ...(lease ? { lease } : {}), owner: (name) => owners.find(({ text }) => text === undefined || text.includes(name))?.label };
708
+ return { ...(lease ? { lease } : {}), owners: (name) => owners.filter(({ text }) => text === undefined || text.includes(name))
709
+ .map(({ label, request }) => ({ label, ...(request ? { request } : {}) })) };
705
710
  }
706
711
  /** After pruning, reconcile aborted requests that recorded a removed resource through the guarded abort path. */
707
712
  async reconcilePrunedAborts(checkouts, names) {
@@ -402,6 +402,8 @@ Each resource is checked again before removal. A live productive lease or local
402
402
 
403
403
  Main cannot run inside a checkout selected for deletion. A session change stops further removal. Only this request's recorded `pi-subagent/subagent-<24hex>` branches and their exact primary `.worktrees/` paths can be removed. The primary checkout is never removed.
404
404
 
405
+ To close and delete many requests, select **Select multiple…** in `/subagent`, select the requests, then select **Continue** and **Close and delete worktrees (discard work)**. The command shows the review of each request. Then one confirmation covers the unblocked resource groups of all of them. The same menu can close all owned workers of each selected direct workflow that can close. The command reports the number of selected rows that the action skipped.
406
+
405
407
  The report lists removed, skipped, and failed resources. After complete cleanup, the request is `aborted`, candidates are `released`, and integration generations are superseded with completed cleanup. Partial cleanup remains visible as retained work. Reopen the request to review the remaining resources. Abort, prune, and release keep their existing preservation rules.
406
408
 
407
409
  ### Prune leftover worktrees
@@ -410,13 +412,22 @@ Run `/subagent prune` to clean up leftover worker and integration checkouts unde
410
412
 
411
413
  - Its branch tip is already in Main's HEAD or in the local `origin` default-branch ref. The command does not fetch.
412
414
  - Its checkout has no tracked or untracked changes. No merge, rebase, cherry-pick, revert, or bisect is in progress.
413
- - No unfinished isolated request, in any checkout of this repository, records it. An unreadable state file blocks every item that it mentions. An unfinished orphaned request, whose parent checkout was removed, also blocks its items. To remove them, open that request in `/subagent` and use **Close and delete worktrees (discard work)**.
415
+ - No unfinished isolated request, in any checkout of this repository, records it. An unreadable state file blocks every item that it mentions. An unfinished orphaned request, whose parent checkout was removed, also blocks its items.
414
416
  - No productive run holds a repository lease.
415
417
  - No Herdr pane works inside the checkout. If the command cannot inspect Herdr, it refuses every checkout.
416
418
 
417
- One confirmation removes every listed removable item. When the command removes a checkout, it also deletes the ignored files of the checkout, such as `node_modules` or `dist`. So the command includes a checkout with ignored files only after a second confirmation. That confirmation names each ignored entry, and its approval covers all contents of each named entry.
419
+ After the review, the command shows a selection list. Removable items are selected (`[x]`). Blocked items that the command can still remove are not selected; each one shows its short ID and its blockers. Select a row to change it, or use **Select all** or **Clear**. Select **Continue** to remove the selected removable items, or **Cancel** to make no change. Items with other blockers are not in the list.
420
+
421
+ You can select these blocked items:
422
+
423
+ - **An item that one readable unfinished request owns**, orphaned or not. The row shows `delete whole request <id>`. The command does not remove the item alone. It shows the [Close and delete](#close-and-delete-isolated-worktrees) review of the whole owning request. After one confirmation, it closes the request and deletes its unblocked resources, as in `/subagent`. That flow also closes the request's own workers and Herdr panes. When you select items of different requests, one confirmation covers all of them.
424
+ - **An unowned item whose blockers are only unique commits, tracked or untracked changes, a Git operation in progress, or initialized submodules.** The row shows `force`. A separate confirmation names each item and its blockers. **This permanently deletes those commits and changes.** Before removal, the command checks the item again. It keeps the item if a new kind of blocker appears. Then it uses `git worktree remove --force` and deletes the branch only at the reviewed tip.
425
+
426
+ These blockers always keep an item: a productive lease, unreadable state, more than one owner, Main inside the checkout, a locked checkout, a checkout that is not at its owned path or that is detached or on another branch, a missing checkout directory, Herdr that cannot be inspected, and Herdr panes inside an unowned checkout.
427
+
428
+ When the command removes a checkout, it also deletes the ignored files of the checkout, such as `node_modules` or `dist`. So the command includes a checkout with ignored files only after a second confirmation. That confirmation names each ignored entry, and its approval covers all contents of each named entry.
418
429
 
419
- If a top-level ignored entry is new or changed, the command skips the checkout. If the full list of ignored entries of a checkout does not fit in that dialog, the command excludes the checkout. The command blocks checkouts with initialized submodules. It also blocks owned checkouts that are detached or on another branch. Remove them manually.
430
+ If a top-level ignored entry is new or changed, the command skips the checkout. If the full list of ignored entries of a checkout does not fit in that dialog, the command excludes the checkout. Remove owned checkouts that are detached or on another branch manually.
420
431
 
421
432
  The list shows removable items first. The command never cuts a long list silently. The list ends with `+N more not shown`, where `N` counts items, and the command does not remove the items that it did not show in that run. Run `/subagent prune` again to review them.
422
433
 
@@ -16,4 +16,10 @@ Review the supplied candidate read-only. Use only supplied requirements and name
16
16
 
17
17
  Report only actionable correctness risks introduced by the change, not style preferences, speculative hypotheticals, or unrelated pre-existing issues. Run no tests or commands outside `git_read`. Never edit, write, commit, push, mutate Git or manage worktrees, or invoke external LLM APIs, SDKs, agent harnesses, or model CLIs.
18
18
 
19
- Output exactly `PASS` when there are no findings. Otherwise output findings only, ordered by severity, with file:line evidence, impact, and the smallest valid fix. Any finding blocks approval; never combine `PASS` with findings. Stop when the supplied evidence is covered.
19
+ Output exactly `PASS` when there are no findings. Otherwise output findings only, ordered by priority, then confidence. Start each finding with its priority and confidence, for example `[P1] confidence: high`, then give file:line evidence, the trigger, the impact, and the smallest valid fix:
20
+
21
+ - `P0`: breaks release or operation on ordinary input, such as data loss, a security bypass, or a crash.
22
+ - `P1`: wrong behavior under a realistic condition, such as a race, a missed error path, or a broken caller.
23
+ - `P2`: wrong behavior in a narrow edge case with limited impact.
24
+
25
+ Confidence is `high`, `medium`, or `low` that the defect is real; name the affected code path even when low. Any finding blocks approval; never combine `PASS` with findings. Stop when the supplied evidence is covered.
@@ -522,7 +522,7 @@ export interface IsolatedSurface {
522
522
  prunePlan(cwd: string): Promise<PrunePlan>;
523
523
  discardPlan(cwd: string, request: IsolatedRequest): Promise<DiscardPlan>;
524
524
  discard(cwd: string, plan: DiscardPlan, current: () => boolean): Promise<CleanupReport>;
525
- prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, current: () => boolean): Promise<CleanupReport>;
525
+ prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, force: boolean, current: () => boolean): Promise<CleanupReport>;
526
526
  inspect(root: string, requestId: string): Promise<readonly string[]>;
527
527
  canFollowup(root: string, requestId: string, taskId: string): boolean;
528
528
  enqueue(root: string, requestId: string, taskId: string, text: string, current: () => boolean): string;
@@ -896,10 +896,10 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
896
896
  const root = await lookupRoot(cwd);
897
897
  return { root, items: await inspectPruneCandidates(root, cwd, pruneGuards(root)) };
898
898
  },
899
- async prune(cwd, items, allowIgnored, current) {
899
+ async prune(cwd, items, allowIgnored, force, current) {
900
900
  requireCurrent(current);
901
901
  const root = await lookupRoot(cwd);
902
- const { removals, checkouts } = await removePruneCandidates(root, cwd, items, allowIgnored,
902
+ const { removals, checkouts } = await removePruneCandidates(root, cwd, items, allowIgnored, force,
903
903
  { ...pruneGuards(root), current: () => current() && !sessionClosed });
904
904
  const removed = removals.filter(({ outcome }) => outcome === "removed");
905
905
  const reconciled = removed.length
@@ -1,6 +1,7 @@
1
1
  import { getMarkdownTheme, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
3
3
  import type { DiscardPlan } from "../dist/discard.js";
4
+ import type { PruneItem } from "../dist/prune.js";
4
5
  import { compactCost } from "./result-transport.ts";
5
6
 
6
7
  /** Saved before launch for user matching only; never ownership or mutation authority. */
@@ -59,17 +60,7 @@ export interface IsolatedInventory {
59
60
  invalidIds: readonly string[];
60
61
  }
61
62
 
62
- /** One pi-subagent branch and its checkout as reviewed by /subagent prune. */
63
- export interface PruneItem {
64
- branch: string;
65
- tip: string;
66
- path?: string;
67
- prunable: boolean;
68
- /** Why the item is prunable, or every exact blocker. */
69
- reason: string;
70
- /** Ignored entries that removing the checkout deletes. */
71
- ignored: readonly string[];
72
- }
63
+ export type { PruneItem };
73
64
  export interface PrunePlan {
74
65
  root: string;
75
66
  items: readonly PruneItem[];
@@ -87,8 +78,8 @@ export interface SubagentCommandAdapter {
87
78
  prunePlan(cwd: string): Promise<PrunePlan>;
88
79
  discardPlan(cwd: string, request: IsolatedRequest): Promise<DiscardPlan>;
89
80
  discard(cwd: string, plan: DiscardPlan, current: () => boolean): Promise<CleanupReport>;
90
- /** Re-verify and remove reviewed prunable items; returns a bounded report. */
91
- prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, current: () => boolean): Promise<CleanupReport>;
81
+ /** Re-verify and remove reviewed prunable items, and with `force` reviewed forceable items; returns a bounded report. */
82
+ prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, force: boolean, current: () => boolean): Promise<CleanupReport>;
92
83
  /** Read-only Role catalog report with routes and per-file load errors. */
93
84
  roles(ctx: ExtensionContext): RolesReport;
94
85
  inspectInTab(root: string, requestId: string, ctx: ExtensionContext, current: () => boolean): Promise<{ ref: string; tabId: string; name: string; sessionFile: string }>;
@@ -209,6 +200,7 @@ const glyph = (status: string) => TONES[statusTone(status)].glyph;
209
200
  const cleanupReport = ({ text, failed, skipped }: CleanupReport): Report => textReport(failed ? "error" : skipped ? "warning" : "success", text);
210
201
 
211
202
  const PRUNE_IGNORED_CONFIRM_CODE_POINTS = 4_000;
203
+ const shortId = ({ branch }: PruneItem) => branch.replace(/^pi-subagent\/subagent-/, "").slice(0, 8);
212
204
  const NOT_SHOWN = "they are not removed in this run; run /subagent prune again after this run.";
213
205
 
214
206
  /** Fit whole item lines within `max` code points; never cut silently, and report exactly which items were shown. */
@@ -246,7 +238,7 @@ function pruneReview({ root, items }: PrunePlan): { report: Report; shown: Prune
246
238
  const section: Section = { heading: `${title} (${group.length})`, tone: group[0]!.prunable ? "success" : "error", rows: [] };
247
239
  return Array.from({ length: Math.ceil(group.length / 10) }, (_, index) => {
248
240
  const chunk = group.slice(index * 10, index * 10 + 10);
249
- return { section, index, items: chunk, row: { text: chunk.map(({ branch }) => branch.replace(/^pi-subagent\/subagent-/, "").slice(0, 8)).join(", "), code: true } };
241
+ return { section, index, items: chunk, row: { text: chunk.map(shortId).join(", "), code: true } };
250
242
  });
251
243
  });
252
244
  const prunable = items.filter((item) => item.prunable).length;
@@ -309,6 +301,31 @@ async function choose<T>(ctx: ExtensionContext, title: string, items: readonly C
309
301
  return index < 0 ? undefined : items[index]!.value;
310
302
  }
311
303
 
304
+ /** Multi-select on single-choice `select`: each pick toggles one row until Continue. Returns chosen values in
305
+ * item order, or undefined on Cancel or a changed session. Only selectable rows are offered. */
306
+ async function chooseMany<T>(ctx: ExtensionContext, title: string, items: readonly Choice<T>[], preselected: readonly T[],
307
+ valid: () => boolean): Promise<T[] | undefined> {
308
+ type Pick = { kind: "toggle"; value: T } | { kind: "all" } | { kind: "clear" } | { kind: "continue" } | { kind: "cancel" };
309
+ const chosen = new Set(preselected);
310
+ for (;;) {
311
+ const picked = await choose<Pick>(ctx, title, [
312
+ ...items.map((item) => ({ label: `${chosen.has(item.value) ? "[x]" : "[ ]"} ${item.label}`, value: { kind: "toggle" as const, value: item.value } })),
313
+ { label: "Select all", value: { kind: "all" } }, { label: "Clear", value: { kind: "clear" } },
314
+ { label: `Continue (${chosen.size} selected)`, value: { kind: "continue" } }, { label: "Cancel", value: { kind: "cancel" } },
315
+ ]);
316
+ if (!valid() || !picked || picked.kind === "cancel") return undefined;
317
+ if (picked.kind === "continue") return items.map(({ value }) => value).filter((value) => chosen.has(value));
318
+ if (picked.kind === "all") for (const { value } of items) chosen.add(value);
319
+ else if (picked.kind === "clear") chosen.clear();
320
+ else if (!chosen.delete(picked.value)) chosen.add(picked.value);
321
+ }
322
+ }
323
+
324
+ // The owner is named once, so clipping keeps the other blockers.
325
+ const pruneLabel = (item: PruneItem) => item.override === "discard"
326
+ ? [`${shortId(item)} · delete whole request ${item.owner!.id} (${item.owner!.status})`, ...item.blockers.filter(({ kind }) => kind !== "owner").map(({ text }) => text)].join(" · ")
327
+ : `${shortId(item)} · ${item.prunable ? "" : "force · "}${item.reason}`;
328
+
312
329
  export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentCommandAdapter): void {
313
330
  pi.registerEntryRenderer<Report>(REPORT_ENTRY, (entry, _options, theme) => entry.data && reportComponent(entry.data, theme, 1));
314
331
  pi.registerMessageRenderer(RECOVERY_MESSAGE, (message, { outputPad }, theme) => reportComponent(textReport("info", typeof message.content === "string"
@@ -356,6 +373,43 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
356
373
  ctx.ui.notify("Session or branch changed; reopen /subagent in the current context.", "warning");
357
374
  return false;
358
375
  };
376
+ /** Review each request's discard plan, then one confirmation covers every shown unblocked group.
377
+ * `whole` states that a request is deleted although only some of its resources were selected. Returns false when stale. */
378
+ const discardRequests = async (requests: readonly IsolatedRequest[], whole = false): Promise<boolean> => {
379
+ const plans: DiscardPlan[] = [];
380
+ for (const request of requests) {
381
+ try {
382
+ const plan = await adapter.discardPlan(ctx.cwd, request);
383
+ const reviewed = discardReview(plan);
384
+ if (!valid()) return false;
385
+ deliver(ctx, reviewed.report);
386
+ const items = reviewed.shown.filter((item) => !item.blockers.length);
387
+ if (items.length) plans.push({ ...plan, items });
388
+ } catch (error) {
389
+ if (!valid()) return false;
390
+ ctx.ui.notify(`Close and delete failed for ${request.id}: ${errorText(error)}`, "error");
391
+ }
392
+ }
393
+ if (!plans.length) return true;
394
+ const groups = plans.reduce((sum, plan) => sum + plan.items.length, 0);
395
+ const confirmed = await ctx.ui.confirm("Close and delete worktrees (discard work)", clean(`${whole
396
+ ? `This closes and deletes each whole owning request (${plans.map(({ requestId }) => requestId).join(", ")}), not only the selected worktrees. ` : ""}`
397
+ + `Stop the listed workers and delete the ${groups} unblocked resource group(s) shown above${plans.length > 1 ? ` across ${plans.length} requests` : ""}? Committed, uncommitted, and ignored content in the listed checkouts is permanently deleted. Their listed branches, Herdr tabs, and workspaces are removed. Items not shown or blocked are kept.`,
398
+ PRUNE_IGNORED_CONFIRM_CODE_POINTS));
399
+ if (!valid()) return false;
400
+ if (!confirmed) return true;
401
+ for (const plan of plans) {
402
+ try {
403
+ const report = await adapter.discard(ctx.cwd, plan, current);
404
+ if (!valid()) return false;
405
+ deliver(ctx, cleanupReport(report));
406
+ } catch (error) {
407
+ if (!valid()) return false;
408
+ ctx.ui.notify(`Close and delete failed for ${plan.requestId}: ${errorText(error)}`, "error");
409
+ }
410
+ }
411
+ return true;
412
+ };
359
413
  if (command === "prune") {
360
414
  const plan = await adapter.prunePlan(ctx.cwd);
361
415
  const reviewed = pruneReview(plan);
@@ -365,10 +419,20 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
365
419
  }
366
420
  if (!valid()) return;
367
421
  deliver(ctx, reviewed.report);
368
- const prunable = reviewed.shown.filter((item) => item.prunable);
369
- if (!prunable.length) return;
370
- const confirmed = await ctx.ui.confirm("Prune subagent worktrees", `Remove all ${prunable.length} prunable item(s) listed above? Each is re-verified first; unique commits, tracked or untracked changes, and unlisted items are never removed.`);
371
- if (!valid() || !confirmed) return;
422
+ const options = reviewed.shown.filter((item) => item.prunable || item.override);
423
+ if (!options.length) return;
424
+ // Continue confirms prunable items; each is re-verified first. Blocked items need their own confirmation below.
425
+ const picked = await chooseMany(ctx, "Prune subagent worktrees · prunable items are selected", options.map((item) => ({ label: pruneLabel(item), value: item })),
426
+ options.filter((item) => item.prunable), valid);
427
+ if (!picked?.length) return;
428
+ const forceable = picked.filter((item) => item.override === "force");
429
+ const forceList = fitItems(`Force remove ${forceable.length} blocked item(s)? This permanently deletes their unique commits, tracked and untracked changes, in-progress Git operations, and initialized submodules, as listed. Each is re-verified first and kept if a new kind of blocker appears:`,
430
+ forceable, (item) => `${shortId(item)}: ${item.reason}`, PRUNE_IGNORED_CONFIRM_CODE_POINTS);
431
+ const force = forceList.shown.length > 0 && await ctx.ui.confirm("Force remove blocked worktrees",
432
+ clean(forceList.text.replace(`Force remove ${forceable.length} blocked`, `Force remove ${forceList.shown.length} blocked`), PRUNE_IGNORED_CONFIRM_CODE_POINTS, true));
433
+ if (!valid()) return;
434
+ if (forceable.length && !force) ctx.ui.notify(`Kept ${forceable.length} blocked item(s); force removal was not approved.`, "info");
435
+ const prunable = [...picked.filter((item) => item.prunable), ...force ? forceList.shown : []];
372
436
  const ignored = prunable.filter((item) => item.ignored.length);
373
437
  // The header uses the full count's width so the shown count always fits.
374
438
  const ignoredList = fitItems(`Also remove ${ignored.length} checkout(s) with ignored files? This permanently deletes these ignored entries and everything inside them:`,
@@ -381,11 +445,19 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
381
445
  const kept = ignored.length - (allowIgnored ? approvable.length : 0);
382
446
  if (kept) ctx.ui.notify(`Kept ${kept} checkout(s) with ignored content; their deletion was not approved.`, "info");
383
447
  const selected = prunable.filter((item) => !item.ignored.length || (allowIgnored && approvable.includes(item)));
384
- if (!selected.length) return;
385
- try {
386
- const report = await adapter.prune(ctx.cwd, selected, allowIgnored, current);
387
- if (valid()) deliver(ctx, cleanupReport(report));
388
- } catch (error) { if (valid()) ctx.ui.notify(`Prune failed: ${errorText(error)}`, "error"); }
448
+ if (selected.length) {
449
+ try {
450
+ const report = await adapter.prune(ctx.cwd, selected, allowIgnored, force, current);
451
+ if (!valid()) return;
452
+ deliver(ctx, cleanupReport(report));
453
+ } catch (error) {
454
+ if (!valid()) return;
455
+ ctx.ui.notify(`Prune failed: ${errorText(error)}`, "error");
456
+ }
457
+ }
458
+ // Discard owns request-owned items: it closes the request's own workers and panes, then removes its resources.
459
+ const owners = new Map(picked.flatMap((item) => item.override === "discard" ? [[`${item.owner!.directory}\0${item.owner!.id}`, item.owner!] as const] : []));
460
+ await discardRequests([...owners.values()].map((owner) => ({ ...owner, tasks: [] })), true);
389
461
  return;
390
462
  }
391
463
  if (!ctx.hasUI) {
@@ -412,18 +484,24 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
412
484
  if (!valid()) return;
413
485
  if (unavailable) ctx.ui.notify(`Isolated inventory unavailable (${unavailable}). Direct branch recovery remains available; check the canonical Git checkout/configuration.`, "warning");
414
486
  if (inventory?.invalidIds.length) for (const id of inventory.invalidIds) ctx.ui.notify(`Unreadable isolated state ID: ${JSON.stringify(id)}. Preserve this file; inspect the state store before making changes.`, "warning");
415
- type Selection = { kind: "direct"; task: DirectTask } | { kind: "isolated"; request: IsolatedRequest; root: string } | { kind: "refresh" } | { kind: "history" } | { kind: "close" };
487
+ type Selection = { kind: "direct"; task: DirectTask } | { kind: "isolated"; request: IsolatedRequest; root: string } | { kind: "refresh" } | { kind: "history" } | { kind: "many" } | { kind: "close" };
416
488
  // A header row selects Refresh; rows sort by status priority within their group.
417
489
  const group = <T extends { status: string }>(title: string, items: readonly T[], choice: (item: T) => Choice<Selection>): Choice<Selection>[] =>
418
490
  items.length ? [{ label: `── ${title} (${items.length}) ──`, value: { kind: "refresh" } },
419
491
  ...[...items].sort((a, b) => priority(a.status) - priority(b.status)).map(choice)] : [];
420
492
  const requests = inventory?.requests ?? [];
421
493
  const isolatedChoice = (request: IsolatedRequest): Choice<Selection> => ({ label: ` ${glyph(request.status)} ${isolatedLabel(request)}`, value: { kind: "isolated", request, root: inventory!.root } });
494
+ // One row per workflow; new records keep opaque IDs in details. Status is last so clipping preserves the reference.
495
+ const directChoice = (task: DirectTask): Choice<Selection> => ({ label: ` ${glyph(task.status)} ${directLabel(task)} · ${task.status}${costSuffix(task.cost)}${task.workers.length || task.name === task.id ? "" : ` · ${task.id}`}`, value: { kind: "direct", task } });
496
+ const groups = [
497
+ group("Direct", direct.filter((task) => history || !completed(task.status)), directChoice),
498
+ group("Isolated", requests.filter((request) => !request.orphaned && (history || !completed(request.status) || /retained/i.test(request.status))), isolatedChoice),
499
+ group("Orphaned · parent checkout removed", requests.filter((request) => request.orphaned), isolatedChoice),
500
+ ].flat();
501
+ const rows = groups.filter(({ value }) => value.kind === "direct" || value.kind === "isolated");
422
502
  const selections: Choice<Selection>[] = [
423
- // One row per workflow; new records keep opaque IDs in details. Status is last so clipping preserves the reference.
424
- ...group("Direct", direct.filter((task) => history || !completed(task.status)), (task) => ({ label: ` ${glyph(task.status)} ${directLabel(task)} · ${task.status}${costSuffix(task.cost)}${task.workers.length || task.name === task.id ? "" : ` · ${task.id}`}`, value: { kind: "direct", task } })),
425
- ...group("Isolated", requests.filter((request) => !request.orphaned && (history || !completed(request.status) || /retained/i.test(request.status))), isolatedChoice),
426
- ...group("Orphaned · parent checkout removed", requests.filter((request) => request.orphaned), isolatedChoice),
503
+ ...groups,
504
+ ...(rows.length ? [{ label: "Select multiple…", value: { kind: "many" as const } }] : []),
427
505
  { label: "Refresh", value: { kind: "refresh" } },
428
506
  { label: history ? "Active work" : "Completed / history (including retained work)", value: { kind: "history" } },
429
507
  { label: "Close", value: { kind: "close" } },
@@ -433,6 +511,34 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
433
511
  if (!valid() || !selected || selected.kind === "close") return;
434
512
  if (selected.kind === "refresh") continue;
435
513
  if (selected.kind === "history") { history = !history; continue; }
514
+ if (selected.kind === "many") {
515
+ const picked = await chooseMany(ctx, "Subagent · select work", rows.map(({ label, value }) => ({ label: label.trim(), value })), [], valid);
516
+ if (!picked?.length) { if (!valid()) return; continue; }
517
+ const isolated = picked.flatMap((item) => item.kind === "isolated" ? [item.request] : []);
518
+ const closable = picked.flatMap((item) => item.kind === "direct" && item.task.canClose ? [item.task] : []);
519
+ type Batch = "discard" | "close" | "back";
520
+ const action = await choose<Batch>(ctx, `Subagent · ${picked.length} selected`, [
521
+ ...(isolated.length ? [{ label: `Close and delete worktrees (discard work) of ${isolated.length} isolated request(s)`, value: "discard" as const }] : []),
522
+ ...(closable.length ? [{ label: `Close/cancel-and-release ALL owned workers in ${closable.length} direct workflow(s) (preserve files and commits)`, value: "close" as const }] : []),
523
+ { label: "Back / refresh", value: "back" },
524
+ ]);
525
+ if (!valid()) return;
526
+ if (!action || action === "back") continue;
527
+ const skipped = picked.length - (action === "discard" ? isolated.length : closable.length);
528
+ if (skipped) ctx.ui.notify(`Skipped ${skipped} selected item(s) that this action does not apply to.`, "info");
529
+ if (action === "discard") { if (!await discardRequests(isolated)) return; continue; }
530
+ for (const task of closable) {
531
+ try {
532
+ const report = await adapter.closeDirect(task, current);
533
+ if (!valid()) return;
534
+ deliver(ctx, textReport("success", report));
535
+ } catch (error) {
536
+ if (!valid()) return;
537
+ ctx.ui.notify(`Admission retained for ${clean(directLabel(task))}: ${errorText(error)}. Inspect the exact tab/session; retry Close/cancel-and-release after resolving termination.`, "error");
538
+ }
539
+ }
540
+ continue;
541
+ }
436
542
  if (selected.kind === "direct") {
437
543
  const { task } = selected;
438
544
  const label = clean(directLabel(task));
@@ -475,21 +581,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
475
581
  if (!valid() || !action) return;
476
582
  if (action === "back") break;
477
583
  if (action === "discard") {
478
- try {
479
- const plan = await adapter.discardPlan(ctx.cwd, request);
480
- const reviewed = discardReview(plan);
481
- if (!valid()) return;
482
- deliver(ctx, reviewed.report);
483
- const items = reviewed.shown.filter((item) => !item.blockers.length);
484
- if (!items.length) continue;
485
- const confirmed = await ctx.ui.confirm("Close and delete worktrees (discard work)",
486
- `Stop the listed workers and delete the ${items.length} unblocked resource group(s) shown above? Committed, uncommitted, and ignored content in the listed checkouts is permanently deleted. Their listed branches, Herdr tabs, and workspaces are removed. Items not shown or blocked are kept.`);
487
- if (!valid()) return;
488
- if (confirmed) {
489
- const report = await adapter.discard(ctx.cwd, { ...plan, items }, current);
490
- if (valid()) deliver(ctx, cleanupReport(report));
491
- }
492
- } catch (error) { if (valid()) ctx.ui.notify(`Close and delete failed: ${errorText(error)}`, "error"); }
584
+ if (!await discardRequests([request])) return;
493
585
  break;
494
586
  }
495
587
  if (action === "inspect") {
@@ -346,7 +346,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
346
346
  prunePlan: (cwd) => isolatedSurface.prunePlan(cwd),
347
347
  discardPlan: (cwd, request) => isolatedSurface.discardPlan(cwd, request),
348
348
  discard: (cwd, plan, current) => isolatedSurface.discard(cwd, plan, current),
349
- prune: (cwd, items, allowIgnored, current) => isolatedSurface.prune(cwd, items, allowIgnored, current),
349
+ prune: (cwd, items, allowIgnored, force, current) => isolatedSurface.prune(cwd, items, allowIgnored, force, current),
350
350
  async inspectInTab(root, id, ctx, current) {
351
351
  const notices = await isolatedSurface.inspect(root, id);
352
352
  if (!current()) throw new Error("Session or branch changed before status inspection.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "27.6.3",
3
+ "version": "27.8.0",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -19,6 +19,7 @@
19
19
  "extensions",
20
20
  "docs",
21
21
  "examples",
22
+ "prompts",
22
23
  "example.png",
23
24
  "README.md",
24
25
  "CONTEXT.md",
@@ -63,6 +64,9 @@
63
64
  "skills": [
64
65
  "./skills"
65
66
  ],
67
+ "prompts": [
68
+ "./prompts"
69
+ ],
66
70
  "image": "https://raw.githubusercontent.com/HenryQW/pi-harness/main/extensions/pi-subagent/example.png"
67
71
  },
68
72
  "dependencies": {
@@ -0,0 +1,20 @@
1
+ ---
2
+ description: Review a change with parallel reviewer Subagents and give one ship verdict.
3
+ argument-hint: "[A..B | branch | commit | #PR] [focus]"
4
+ ---
5
+
6
+ Review this target with parallel `reviewer` Subagents: $@
7
+
8
+ Do not edit files, fix findings, commit, or push.
9
+
10
+ 1. Resolve the first argument to exact local commit IDs with Git. Treat the remaining text as extra review focus. If the first argument is not `#N` and does not resolve as a ref or range, use the empty target and treat all text as focus.
11
+ - Empty: uncommitted staged, unstaged, and untracked changes against `HEAD`.
12
+ - `A..B`: base `A`, tip `B`.
13
+ - Branch: base is `git merge-base main <branch>`, tip is the branch.
14
+ - Commit: base is its first parent, tip is the commit.
15
+ - `#N`: run `gh pr view N --json url,baseRefName`. The base repository URL is `url` without `/pull/N`. Run `git fetch <repository URL> +refs/heads/<baseRefName>:refs/pi-review/pr-N-base +refs/pull/N/head:refs/pi-review/pr-N`. Base is the merge-base of the two refs; tip is `refs/pi-review/pr-N`. Reviewers read only local Git, so fetch before delegation.
16
+ If the target is ambiguous or has no changes, stop and report.
17
+ 2. List the changed files, and mark untracked files. Group them by locality: same directory or module, related behavior, and tests with their implementation. Use one group for a small change and at most 5 groups.
18
+ 3. Start one `delegate_task` call with `mode: "direct"` and one `tasks` entry per group: `role: "reviewer"`, a short unique `name` (at most 29 characters), `access: "read"`, and a `task` that gives the base and tip commit IDs, its exact file list, and the criteria. For uncommitted changes, give `HEAD` <ID> instead, and tell the reviewer to combine `git_read` diff in `staged` and `working` modes and to read each marked untracked file whole with `read`. Criteria: report actionable correctness risks that the change introduces in these files, with the reviewer finding format; read other files only as context; output exactly `PASS` when there are no findings. Add the extra focus, if any.
19
+ 4. When the result arrives, merge the findings: remove duplicates that share a root cause and keep the highest priority, then sort by priority (`P0`, `P1`, `P2`) and confidence. Keep file:line evidence and fixes.
20
+ 5. Give one verdict. **Ship** only if every reviewer task succeeded and answered exactly `PASS`. Otherwise **Do not ship**, with the merged findings and any failed or incomplete reviewer task. If any result reports `checkout changed during task`, the verdict is **Do not ship**; rerun the review.