@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 +31 -5
- package/dist/prune.d.ts +30 -4
- package/dist/prune.js +38 -25
- package/dist/runner.js +10 -5
- package/docs/orchestration.md +14 -3
- package/examples/roles/reviewer.md +7 -1
- package/extensions/isolated.ts +3 -3
- package/extensions/subagent-command.ts +135 -43
- package/extensions/subagent.ts +1 -1
- package/package.json +5 -1
- package/prompts/review.md +20 -0
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.
|
|
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
|

|
|
@@ -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
|
|
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
|
-
/**
|
|
15
|
-
|
|
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.
|
|
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
|
-
|
|
70
|
+
block("hard", `owned checkout is ${checkout.branch ? `on branch ${checkout.branch}` : "detached"}; inspect it manually`);
|
|
67
71
|
let ignored = [];
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
const owners = ownership.owners(name);
|
|
73
|
+
for (const { label } of owners)
|
|
74
|
+
block("owner", label);
|
|
71
75
|
if (ownership.lease)
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
+
block("hard", "checkout is not at its owned .worktrees path");
|
|
89
93
|
if (checkout.locked)
|
|
90
|
-
|
|
94
|
+
block("hard", "checkout is locked");
|
|
91
95
|
if (checkout.missing || !existsSync(path))
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
+
block("operation", `Git operation in progress (${markers.join(", ")})`);
|
|
101
105
|
const dirty = await inspectWorktreeDirty(path, run, false);
|
|
102
106
|
if (dirty.failure)
|
|
103
|
-
|
|
107
|
+
block("hard", `checkout could not be inspected: ${dirty.failure}`);
|
|
104
108
|
else if (dirty.dirty)
|
|
105
|
-
|
|
109
|
+
block("changes", "checkout has tracked or untracked changes");
|
|
106
110
|
else if (dirty.initializedSubmodules)
|
|
107
|
-
|
|
111
|
+
block("submodules", "checkout has initialized submodules; Git cannot remove it without force");
|
|
108
112
|
if (typeof panes === "string")
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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 } : {}),
|
|
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) {
|
package/docs/orchestration.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
package/extensions/isolated.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
369
|
-
if (!
|
|
370
|
-
|
|
371
|
-
|
|
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 (
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
424
|
-
...
|
|
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
|
-
|
|
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") {
|
package/extensions/subagent.ts
CHANGED
|
@@ -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.
|
|
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.
|