@henryqw/pi-subagent 27.4.5 → 27.5.1

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
@@ -29,7 +29,7 @@ Commands and UI are for you. Tools and the packaged skill are for Main, except `
29
29
 
30
30
  | Surface | Type | Purpose |
31
31
  | --- | --- | --- |
32
- | `/subagent` | command | Browse direct and isolated work. Stop or close owned direct workers, inspect isolated status, send follow-ups, or edit pending instructions. Requires interactive TUI or RPC. |
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. |
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
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. |
35
35
  | `/subagent recover` | command | Classify orphaned isolated work in the canonical repository. Send Main request IDs, blockers, and next actions. Requires no UI or Herdr pane. |
@@ -131,6 +131,10 @@ The extension sends follow-ups when a candidate becomes ready, a worker needs at
131
131
 
132
132
  Open `/subagent` to browse direct work on this session branch and isolated requests in the current canonical Git checkout, including other sessions. **Refresh** reloads saved status without recovery or mutation. Invalid state IDs stay preserved with a warning. Direct recovery remains available outside Git or without isolated inventory.
133
133
 
134
+ Requests whose parent checkout was removed show as **Isolated · orphaned** from any checkout of the same repository. Select **Close and delete worktrees (discard work)** to review owned resources and confirm deletion. **This permanently deletes committed, uncommitted, and ignored content in the listed checkouts.** Cancel makes no change.
135
+
136
+ 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.
137
+
134
138
  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.
135
139
 
136
140
  See [Status widgets](./docs/orchestration.md#status-widgets) for reference numbering, token counts, and activity metrics, and [Choose a delegation mode](./docs/orchestration.md#choose-a-delegation-mode) for routing guidance.
@@ -146,7 +150,7 @@ Package-owned: `~/.pi/agent/config/pi-subagent/config.json`. Missing files silen
146
150
  | `maxSubagents` | Concurrent direct workers and ephemeral children | Safe integer ≥ 1 | `5` |
147
151
  | `maxTurns` | Provider turns per child | Safe integer ≥ 1 | `50` |
148
152
  | `maxTokens` | Optional tokens per child | Safe integer ≥ 1 | Unlimited |
149
- | `maxCorrections` | Same-worker automatic corrections per isolated request | Safe integer ≥ 0 | `1` |
153
+ | `maxCorrections` | Same-worker corrections per isolated request, including Main's `revise` and `retry` corrections | Safe integer ≥ 0 | `1` |
150
154
  | `timeout.idleMinutes` | Direct worker and ephemeral child idle timeout | Positive; within Node's timer range | `10` |
151
155
  | `timeout.maxMinutes` | Ephemeral child hard runtime | Greater than idle; within Node's timer range | `30` |
152
156
  | `roleDefaults` | Shared resources for built-in and user Roles | Object with `extensions`, `tools`, or `skills` arrays; same validation as Role fields | — |
@@ -0,0 +1,51 @@
1
+ import { type GitRunner } from "./git-process.ts";
2
+ import type { ChangesetTaskAttempt, ChangesetTaskState, IntegrationGeneration, RunState, TextTaskAttempt } from "./schema.ts";
3
+ import type { RunStateHandle } from "./store.ts";
4
+ import { type WorktreeInfo } from "./worktree.ts";
5
+ import type { PruneGuards } from "./prune.ts";
6
+ export interface DiscardItem {
7
+ key: string;
8
+ identity: string;
9
+ path?: string;
10
+ branch?: string;
11
+ tip?: string;
12
+ checkout: boolean;
13
+ commits?: number;
14
+ dirty?: boolean;
15
+ ignored: string[];
16
+ hosts: string[];
17
+ blockers: string[];
18
+ }
19
+ export interface DiscardPlan {
20
+ root: string;
21
+ directory: string;
22
+ requestId: string;
23
+ items: DiscardItem[];
24
+ }
25
+ export interface DiscardGuards {
26
+ panes: PruneGuards["panes"];
27
+ blocked(): Promise<string | undefined>;
28
+ current(): boolean;
29
+ }
30
+ export interface DiscardResource {
31
+ key: string;
32
+ worktree?: Omit<WorktreeInfo, "baseCommit"> & {
33
+ baseCommit?: string;
34
+ };
35
+ textAttempt?: TextTaskAttempt;
36
+ task?: ChangesetTaskState;
37
+ attempt?: ChangesetTaskAttempt;
38
+ generation?: IntegrationGeneration;
39
+ }
40
+ export type DiscardRemoval = {
41
+ key: string;
42
+ outcome: "removed" | "skipped" | "failed";
43
+ reason: string;
44
+ };
45
+ export declare function discardResources(state: RunState): DiscardResource[];
46
+ export declare function primaryCheckout(root: string, run?: GitRunner): Promise<string>;
47
+ /** Read-only evidence. A resource is one worker attempt or integration generation, including its host resources. */
48
+ export declare function inspectDiscardResource(primary: string, mainCwd: string, resource: DiscardResource, guards: DiscardGuards, run?: GitRunner): Promise<DiscardItem>;
49
+ export declare function inspectDiscard(primary: string, mainCwd: string, state: RunState, guards: DiscardGuards): Promise<DiscardItem[]>;
50
+ /** Called only after the command's confirmation. Never exposed as an agent tool. */
51
+ export declare function removeDiscardResources(handle: RunStateHandle, primary: string, mainCwd: string, reviewed: readonly DiscardItem[], guards: DiscardGuards, close: (resource: DiscardResource, check: () => Promise<void>) => Promise<void>, run?: GitRunner): Promise<DiscardRemoval[]>;
@@ -0,0 +1,224 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { basename, dirname, join, sep } from "node:path";
3
+ import { runGit } from "./git-process.js";
4
+ import { inspectIndexFlags, OWNED_BRANCH_PATTERN, WORKTREES_DIRNAME } from "./worktree.js";
5
+ export function discardResources(state) {
6
+ return [
7
+ ...state.tasks.flatMap((task) => task.kind === "changeset" ? task.attempts.map((attempt, index) => ({
8
+ key: `task/${task.taskId}/${index + 1}`, task, attempt,
9
+ worktree: attempt.allocations.filter((item) => item.kind === "worktree").at(-1)?.worktree,
10
+ })) : task.attempts.flatMap((attempt) => attempt.cleanup ? [{
11
+ key: `task/${task.taskId}/${attempt.number}`, textAttempt: attempt,
12
+ worktree: { path: attempt.cleanup.path, cwd: attempt.cleanup.path, branch: attempt.cleanup.branch,
13
+ repoRoot: dirname(dirname(attempt.cleanup.path)) },
14
+ }] : [])),
15
+ ...state.integration.generations.map((generation) => ({ key: `integration/${generation.number}`, generation, worktree: generation.worktree })),
16
+ ];
17
+ }
18
+ async function git(args, root, run) {
19
+ const result = await run(args, root);
20
+ if (result.code !== 0)
21
+ throw new Error(`git ${args[0]} failed: ${result.stderr.trim().slice(0, 300)}`);
22
+ return result.stdout.trimEnd();
23
+ }
24
+ export async function primaryCheckout(root, run = runGit) {
25
+ const first = (await git(["worktree", "list", "--porcelain", "-z"], root, run)).split("\0")[0];
26
+ if (!first?.startsWith("worktree "))
27
+ throw new Error("Primary Git checkout is unavailable.");
28
+ return realpathSync.native(first.slice(9));
29
+ }
30
+ const within = (root, path) => path === root || path.startsWith(`${root}${sep}`);
31
+ const identity = (resource) => JSON.stringify([resource.worktree, resource.attempt?.allocations]);
32
+ /** Read-only evidence. A resource is one worker attempt or integration generation, including its host resources. */
33
+ export async function inspectDiscardResource(primary, mainCwd, resource, guards, run = runGit) {
34
+ const { worktree, attempt } = resource;
35
+ const allocations = attempt?.allocations ?? [];
36
+ const item = {
37
+ key: resource.key, identity: identity(resource), checkout: false, ignored: [], blockers: [],
38
+ hosts: allocations.filter((allocation) => allocation.kind !== "worktree").map((allocation) => JSON.stringify(allocation)),
39
+ };
40
+ const blocked = await guards.blocked();
41
+ if (blocked)
42
+ item.blockers.push(blocked);
43
+ if (allocations.some((allocation) => allocation.status === "allocating" || allocation.status === "unknown")) {
44
+ item.blockers.push("Allocation ownership is uncertain; inspect the recorded resources.");
45
+ }
46
+ if (!worktree)
47
+ return item;
48
+ item.path = worktree.path;
49
+ item.branch = worktree.branch;
50
+ if (!OWNED_BRANCH_PATTERN.test(worktree.branch) || worktree.repoRoot !== primary
51
+ || worktree.path !== join(primary, WORKTREES_DIRNAME, basename(worktree.branch))) {
52
+ item.blockers.push("Checkout or branch is not at this repository's owned path.");
53
+ return item;
54
+ }
55
+ const ref = `refs/heads/${worktree.branch}`;
56
+ const branches = (await git(["for-each-ref", "--format=%(refname) %(objectname) %(symref)", ref], primary, run)).split("\n");
57
+ const branch = branches.find((line) => line.startsWith(`${ref} `))?.split(" ");
58
+ item.tip = branch?.[1];
59
+ if (branch?.[2])
60
+ item.blockers.push("Owned branch is a symbolic reference; inspect it manually.");
61
+ if (item.tip && worktree.baseCommit)
62
+ item.commits = Number(await git(["rev-list", "--count", `${worktree.baseCommit}..${item.tip}`], primary, run));
63
+ const records = (await git(["worktree", "list", "--porcelain", "-z"], primary, run)).split("\0\0");
64
+ const registered = records.filter((record) => record.split("\0").includes(`branch refs/heads/${worktree.branch}`));
65
+ const owned = records.find((record) => record.startsWith(`worktree ${worktree.path}\0`));
66
+ if (registered.some((record) => !record.startsWith(`worktree ${worktree.path}\0`)))
67
+ item.blockers.push("Branch is checked out at another path.");
68
+ item.checkout = existsSync(worktree.path);
69
+ const cleanup = attempt?.cleanup ?? resource.generation?.cleanup;
70
+ if (item.checkout && (resource.textAttempt?.cleanup?.outcome === "pruned" || cleanup?.some((step) => step.kind === "worktree" && step.status === "completed"))
71
+ || item.tip && (resource.textAttempt?.cleanup?.outcome === "pruned" || cleanup?.some((step) => step.kind === "branch" && step.status === "completed"))) {
72
+ item.blockers.push("A resource exists after completed cleanup; new ownership is unproved.");
73
+ }
74
+ if (allocations.filter((allocation) => allocation.kind === "worktree").at(-1)?.status === "absent"
75
+ && (item.checkout || item.tip))
76
+ item.blockers.push("Allocation was recorded absent; ownership of the existing resource is unproved.");
77
+ if (owned && !item.checkout)
78
+ item.blockers.push("Checkout registration remains but its directory is missing; inspect it manually.");
79
+ if (!item.checkout)
80
+ return item;
81
+ if (!owned || !owned.split("\0").includes(`branch refs/heads/${worktree.branch}`))
82
+ item.blockers.push("Checkout registration changed or checkout is detached.");
83
+ if (owned?.split("\0").some((field) => field === "locked" || field.startsWith("locked ")))
84
+ item.blockers.push("Checkout is locked.");
85
+ const real = realpathSync.native(worktree.path);
86
+ if (real !== worktree.path)
87
+ item.blockers.push("Owned checkout path is redirected.");
88
+ if (within(real, realpathSync.native(mainCwd)))
89
+ item.blockers.push("Main runs inside the checkout.");
90
+ const status = (await git(["--no-optional-locks", "status", "--porcelain=v1", "-z", "--ignored=matching", "--untracked-files=normal", "--ignore-submodules=none"], worktree.path, run)).split("\0").filter(Boolean);
91
+ item.ignored = status.filter((entry) => entry.startsWith("!! ")).map((entry) => entry.slice(3));
92
+ item.dirty = status.some((entry) => !entry.startsWith("!! "));
93
+ if (!item.dirty) {
94
+ const flags = await inspectIndexFlags(worktree.path, run);
95
+ if (flags.failure || flags.hidden) {
96
+ delete item.dirty;
97
+ item.blockers.push(flags.failure ?? "Index flags hide tracked files; dirty state cannot be proved.");
98
+ }
99
+ }
100
+ const submodules = await git(["submodule", "status", "--recursive"], worktree.path, run);
101
+ if (submodules.split("\n").some((line) => line && !line.startsWith("-"))) {
102
+ item.blockers.push("Initialized submodules require separate inspection of their commits and ignored files.");
103
+ }
104
+ try {
105
+ const ownPanes = allocations.flatMap((allocation) => allocation.status !== "owned" ? []
106
+ : allocation.kind === "workspace" ? [allocation.rootPaneId]
107
+ : allocation.kind === "worker_tab" || allocation.kind === "agent" ? [allocation.paneId] : []);
108
+ const foreign = (await guards.panes()).filter((pane) => !ownPanes.includes(pane.paneId)
109
+ && pane.cwds.some((cwd) => within(real, cwd) || within(worktree.path, cwd)));
110
+ if (foreign.length)
111
+ item.blockers.push(`Other Herdr panes use this checkout: ${foreign.map((pane) => pane.paneId).join(", ")}`);
112
+ }
113
+ catch (error) {
114
+ item.blockers.push(`Herdr cannot be inspected: ${error instanceof Error ? error.message : String(error)}`);
115
+ }
116
+ return item;
117
+ }
118
+ export async function inspectDiscard(primary, mainCwd, state, guards) {
119
+ const items = [];
120
+ for (const resource of discardResources(state)) {
121
+ try {
122
+ const item = await inspectDiscardResource(primary, mainCwd, resource, guards);
123
+ const complete = resource.textAttempt?.cleanup?.outcome === "pruned"
124
+ || (resource.attempt?.cleanup ?? resource.generation?.cleanup)?.every((step) => step.status === "completed");
125
+ if (!complete || item.checkout || item.tip || item.blockers.length)
126
+ items.push(item);
127
+ }
128
+ catch (error) {
129
+ items.push({ key: resource.key, identity: identity(resource), path: resource.worktree?.path,
130
+ branch: resource.worktree?.branch, checkout: false, ignored: [], hosts: [], blockers: [`Inspection failed: ${String(error)}`] });
131
+ }
132
+ }
133
+ return items;
134
+ }
135
+ /** Called only after the command's confirmation. Never exposed as an agent tool. */
136
+ export async function removeDiscardResources(handle, primary, mainCwd, reviewed, guards, close, run = runGit) {
137
+ const results = [];
138
+ const removals = [];
139
+ for (const item of reviewed) {
140
+ const resource = discardResources(handle.state).find((resource) => resource.key === item.key);
141
+ if (!resource || identity(resource) !== item.identity || item.blockers.length) {
142
+ results.push({ key: item.key, outcome: "skipped", reason: "Ownership changed or the reviewed item is blocked." });
143
+ continue;
144
+ }
145
+ let checkoutRemoved = false;
146
+ const verify = async () => {
147
+ if (!guards.current())
148
+ throw new Error("Session changed; reopen /subagent.");
149
+ const fresh = await inspectDiscardResource(primary, mainCwd, resource, guards, run);
150
+ if (fresh.blockers.length)
151
+ throw new Error(fresh.blockers.join("; "));
152
+ if (fresh.tip !== item.tip)
153
+ throw new Error("Branch was deleted or moved since review; branch kept.");
154
+ if (fresh.checkout !== (checkoutRemoved ? false : item.checkout))
155
+ throw new Error("Checkout changed since review.");
156
+ if (fresh.ignored.some((entry) => !item.ignored.includes(entry)))
157
+ throw new Error("Ignored entries changed since review.");
158
+ const blocked = await guards.blocked();
159
+ if (blocked)
160
+ throw new Error(blocked);
161
+ if (!guards.current())
162
+ throw new Error("Session changed; reopen /subagent.");
163
+ };
164
+ try {
165
+ await verify();
166
+ }
167
+ catch (error) {
168
+ results.push({ key: item.key, outcome: "skipped", reason: String(error) });
169
+ continue;
170
+ }
171
+ const failed = async (error) => {
172
+ const pending = (resource.attempt?.cleanup ?? resource.generation?.cleanup)?.find((step) => step.status === "running");
173
+ if (pending) {
174
+ pending.failure = `User-confirmed discard failed: ${String(error)}`.slice(0, 500);
175
+ await handle.save();
176
+ }
177
+ results.push({ key: item.key, outcome: "failed", reason: `${checkoutRemoved ? "Checkout removed; " : ""}${String(error)}` });
178
+ };
179
+ try {
180
+ await close(resource, verify);
181
+ }
182
+ catch (error) {
183
+ await failed(error);
184
+ continue;
185
+ }
186
+ // Close all reviewed workers and host resources before any Git checkout is removed.
187
+ removals.push(async () => {
188
+ try {
189
+ const pending = [{ kind: "worktree", status: "pending" }, { kind: "branch", status: "pending" }];
190
+ const steps = resource.attempt?.cleanup ?? (resource.generation ? resource.generation.cleanup ??= pending : pending);
191
+ for (const kind of ["worktree", "branch"]) {
192
+ const step = steps.find((step) => step.kind === kind);
193
+ await verify();
194
+ if (step.status === "completed")
195
+ continue;
196
+ step.status = "running";
197
+ delete step.failure;
198
+ await handle.save();
199
+ await verify();
200
+ if (kind === "worktree" && item.checkout) {
201
+ await git(["worktree", "remove", "--force", item.path], primary, run);
202
+ checkoutRemoved = true;
203
+ }
204
+ else if (kind === "branch" && item.tip) {
205
+ await git(["update-ref", "--no-deref", "-d", `refs/heads/${item.branch}`, item.tip], primary, run);
206
+ }
207
+ step.status = "completed";
208
+ if (resource.textAttempt)
209
+ resource.textAttempt.cleanup = kind === "branch"
210
+ ? { outcome: "pruned", path: item.path, branch: item.branch }
211
+ : { outcome: "recovery", path: item.path, branch: item.branch, note: "User confirmed discard: checkout removed; branch cleanup pending." };
212
+ await handle.save();
213
+ }
214
+ results.push({ key: item.key, outcome: "removed", reason: "Reviewed resources removed; cleanup saved." });
215
+ }
216
+ catch (error) {
217
+ await failed(error);
218
+ }
219
+ });
220
+ }
221
+ for (const remove of removals)
222
+ await remove();
223
+ return results;
224
+ }
package/dist/runner.d.ts CHANGED
@@ -1,9 +1,10 @@
1
+ import { type DiscardGuards, type DiscardPlan } from "./discard.ts";
1
2
  import { type EphemeralSubagentExecutor } from "./ephemeral.ts";
2
3
  import type { PruneOwnership } from "./prune.ts";
3
4
  import { IntegrationGit } from "./integration-git.ts";
4
5
  import { type AllocationKind, type CheckCommand, type ChangesetTaskRequest, type CleanupKind, type ExecuteRequest, type ExecutionPolicySnapshot, type HostAllocationIntent, type HostAllocationPlan, type ModelClass, type ReviewEvidence, type ResumeRequest, type RunState, type TaskAttempt, type TaskRequest, type TaskState, type WorktreeAllocationIntent, type WorktreeAllocationPlan, type WorkspaceIdentity } from "./schema.ts";
5
6
  import { FileRunStore } from "./store.ts";
6
- /** One durable follow-up per attempt; the runner itself never commits, stages, stashes, or discards. */
7
+ /** One durable follow-up per attempt; this instruction permits a worker commit, never discarded work. */
7
8
  export declare const COMMIT_NUDGE_INSTRUCTION = "Your turn ended with uncommitted changes in your worktree. Commit the intended changes now so the worktree is clean, or explain in your reply why they must not be committed. Do not discard work.";
8
9
  export declare const CLEANUP_SAFETY_BUDGET_MS = 30000;
9
10
  export declare const TERMINATION_SAFETY_BUDGET_MS = 15000;
@@ -305,6 +306,8 @@ export declare class IsolatedRunner {
305
306
  id: string;
306
307
  name: string;
307
308
  status: string;
309
+ directory: string;
310
+ root: string;
308
311
  tasks: Array<{
309
312
  id: string;
310
313
  name: string;
@@ -346,6 +349,25 @@ export declare class IsolatedRunner {
346
349
  private releaseSuperseded;
347
350
  private cleanupPromoted;
348
351
  private requireExactIntegration;
352
+ listOrphanRequests(cwd: string): Promise<{
353
+ invalidIds: string[];
354
+ requests: {
355
+ id: string;
356
+ name: string;
357
+ status: string;
358
+ directory: string;
359
+ root: string;
360
+ orphaned: boolean;
361
+ tasks: never[];
362
+ }[];
363
+ }>;
364
+ private discardGuards;
365
+ discardPlan(cwd: string, id: string, directory: string | undefined, guards: DiscardGuards): Promise<DiscardPlan>;
366
+ /** Command-only explicit discard. No tool calls this method. */
367
+ discard(cwd: string, plan: DiscardPlan, guards: DiscardGuards): Promise<import("./discard.ts").DiscardRemoval[]>;
368
+ private terminateAbortedAttempt;
369
+ private cleanupAbortedHost;
370
+ private closeDiscardResource;
349
371
  abort(id: string, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
350
372
  /** Read-only proof that every retained candidate checkout and staged integration checkout is already gone. */
351
373
  private retainedWorkAbsent;
package/dist/runner.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { readFileSync, realpathSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { inspectDiscard, primaryCheckout, removeDiscardResources } from "./discard.js";
2
4
  import { utf8Prefix } from "./ephemeral.js";
3
5
  import { createChildWorktree, finalizeChildWorktree, inspectWorktreeDirty } from "./worktree.js";
4
6
  import { runGit } from "./git-process.js";
@@ -11,7 +13,7 @@ const CHANGESET_TASK_PROMPT_MAX_BYTES = 96 * 1024;
11
13
  const CORRECTION_DELIVERY_RESERVE_BYTES = 1024;
12
14
  const MAX_WORKER_PROMPTS = 32;
13
15
  const MAX_QUEUED_FOLLOWUPS = 16;
14
- /** One durable follow-up per attempt; the runner itself never commits, stages, stashes, or discards. */
16
+ /** One durable follow-up per attempt; this instruction permits a worker commit, never discarded work. */
15
17
  export const COMMIT_NUDGE_INSTRUCTION = "Your turn ended with uncommitted changes in your worktree. Commit the intended changes now so the worktree is clean, or explain in your reply why they must not be committed. Do not discard work.";
16
18
  export const CLEANUP_SAFETY_BUDGET_MS = 30_000;
17
19
  export const TERMINATION_SAFETY_BUDGET_MS = 15_000;
@@ -422,6 +424,14 @@ function correctionEligible(request, attempt) {
422
424
  function terminal(state) {
423
425
  return state.status === "completed" || state.status === "final_failed" || state.status === "superseded" || state.status === "aborted";
424
426
  }
427
+ function inventoryStatus(state) {
428
+ const retained = state.tasks.some((task) => task.kind === "changeset"
429
+ ? task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed"))
430
+ : task.attempts.some((attempt) => attempt.cleanup && attempt.cleanup.outcome !== "pruned"))
431
+ || state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")
432
+ || (generation.worktree && generation.cleanup?.every((step) => step.status === "completed") !== true));
433
+ return ["completed", "aborted"].includes(state.status) && retained ? `${state.status} · retained` : state.status;
434
+ }
425
435
  /** Build one bounded changeset assignment. Call this before acquiring its Role launch. */
426
436
  export function buildChangesetTaskPrompt(input) {
427
437
  if (input.kind === "correction"
@@ -551,14 +561,9 @@ export class IsolatedRunner {
551
561
  }
552
562
  async listRequests(root) {
553
563
  const { states: entries, invalidIds } = await this.store.list(root);
554
- return { invalidIds, states: entries.map(({ state }) => state), requests: entries.map(({ state }) => ({
555
- id: state.request.id, name: state.request.goal,
556
- status: ["completed", "aborted"].includes(state.status) && (state.tasks.some((task) => task.kind === "changeset"
557
- ? task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed"))
558
- : task.attempts.some((attempt) => attempt.cleanup && attempt.cleanup.outcome !== "pruned"))
559
- || state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")
560
- || (generation.worktree && generation.cleanup?.every((step) => step.status === "completed") !== true)))
561
- ? `${state.status} · retained` : state.status,
564
+ return { invalidIds, states: entries.map(({ state }) => state), requests: entries.map(({ state, path }) => ({
565
+ id: state.request.id, name: state.request.goal, directory: dirname(path), root: state.root,
566
+ status: inventoryStatus(state),
562
567
  tasks: state.request.tasks.map((task) => ({
563
568
  id: task.id, name: task.requirements, kind: task.kind,
564
569
  status: state.tasks.find((item) => item.taskId === task.id).status,
@@ -1657,6 +1662,136 @@ export class IsolatedRunner {
1657
1662
  if (!sameIdentity(main, generation.expectedMain))
1658
1663
  throw new Error("Main changed or became dirty before promotion.");
1659
1664
  }
1665
+ async listOrphanRequests(cwd) {
1666
+ const primary = await primaryCheckout(cwd);
1667
+ const { states, invalidIds } = await this.store.listOrphans(primary);
1668
+ return { invalidIds, requests: states.map((handle) => ({
1669
+ id: handle.state.request.id, name: handle.state.request.goal, status: inventoryStatus(handle.state),
1670
+ directory: dirname(handle.path), root: handle.state.root, orphaned: true, tasks: [],
1671
+ })) };
1672
+ }
1673
+ discardGuards(directory, root, guards) {
1674
+ return { ...guards, blocked: async () => {
1675
+ if (this.productiveLifecycles.has(root))
1676
+ return "A local productive job is active.";
1677
+ if (await this.store.hasProductiveRunLeaseInDirectory(directory))
1678
+ return "A live productive lease owns this request root.";
1679
+ return await guards.blocked();
1680
+ } };
1681
+ }
1682
+ async discardPlan(cwd, id, directory, guards) {
1683
+ const primary = await primaryCheckout(cwd);
1684
+ directory ??= this.store.stateDirectory(realpathSync.native(cwd));
1685
+ const handle = await this.store.loadFromDirectory(directory, id);
1686
+ return { directory, root: handle.state.root, requestId: id, items: await inspectDiscard(primary, cwd, handle.state, this.discardGuards(directory, handle.state.root, guards)) };
1687
+ }
1688
+ /** Command-only explicit discard. No tool calls this method. */
1689
+ async discard(cwd, plan, guards) {
1690
+ const primary = await primaryCheckout(cwd);
1691
+ return await this.store.withDirectoryLock(plan.directory, async () => {
1692
+ const handle = await this.store.loadFromDirectory(plan.directory, plan.requestId);
1693
+ const state = handle.state;
1694
+ if (state.root !== plan.root)
1695
+ throw new Error("Request root changed since review.");
1696
+ const checked = this.discardGuards(plan.directory, state.root, guards);
1697
+ const blocked = await checked.blocked();
1698
+ if (blocked || !checked.current())
1699
+ throw new Error(blocked ?? "Session changed; reopen /subagent.");
1700
+ const removals = await removeDiscardResources(handle, primary, cwd, plan.items, checked, async (resource, verify) => {
1701
+ await verify();
1702
+ state.status = "aborted";
1703
+ state.accepted = false;
1704
+ delete state.acceptedAt;
1705
+ if (state.final.status === "passed") {
1706
+ state.final.status = "superseded";
1707
+ state.final.failure = "User confirmed discard from /subagent.";
1708
+ }
1709
+ delete state.recovery;
1710
+ state.updatedAt = this.coordinatorRuntime.now();
1711
+ // Invalidate readiness before termination or supersession; rejected candidates retain exact accounting.
1712
+ for (const candidate of state.integration.candidates) {
1713
+ candidate.decision = "rejected";
1714
+ const task = changesetTaskState(state, candidate.taskId);
1715
+ if (candidate.attempt !== task.attempts.length)
1716
+ continue;
1717
+ if (candidate.worker !== "retained") {
1718
+ task.status = "rejected";
1719
+ delete task.failure;
1720
+ }
1721
+ else
1722
+ this.attention(task, "User confirmed discard of this request.");
1723
+ }
1724
+ for (const generation of state.integration.generations) {
1725
+ if (generation.status === "superseded")
1726
+ continue;
1727
+ generation.status = "superseded";
1728
+ generation.supersededFrom = "discarded";
1729
+ generation.failure = "User confirmed discard from /subagent.";
1730
+ delete generation.promotion;
1731
+ delete generation.supersededPromotion;
1732
+ delete generation.combinedTip;
1733
+ delete generation.checks;
1734
+ delete generation.review;
1735
+ }
1736
+ await handle.save();
1737
+ await this.closeDiscardResource(handle, resource, verify);
1738
+ });
1739
+ for (const candidate of state.integration.candidates) {
1740
+ const task = changesetTaskState(state, candidate.taskId);
1741
+ const attempt = task.attempts[candidate.attempt - 1];
1742
+ if (candidate.decision !== "rejected" || attempt.termination?.status !== "terminated"
1743
+ || attempt.cleanup.some((step) => step.status !== "completed"))
1744
+ continue;
1745
+ candidate.worker = "released";
1746
+ if (attempt === task.attempts.at(-1)) {
1747
+ task.status = "rejected";
1748
+ delete task.failure;
1749
+ }
1750
+ }
1751
+ await handle.save();
1752
+ return removals;
1753
+ });
1754
+ }
1755
+ async terminateAbortedAttempt(handle, task, attempt, signal, deadline = this.coordinatorRuntime.now() + TERMINATION_SAFETY_BUDGET_MS) {
1756
+ if (!allocationByKind(attempt, "agent")?.agentName || attempt.termination?.status === "terminated")
1757
+ return;
1758
+ await this.proveTermination(handle, task, attempt, this.terminationCandidate(attempt), async () => await handle.save(), signal, deadline);
1759
+ }
1760
+ async cleanupAbortedHost(handle, task, attempt, kind, scope) {
1761
+ const result = await scope.call((context) => this.hostRuntime.cleanupHost({
1762
+ requestId: handle.state.request.id, kind, task: changesetTaskRequest(handle.state, task.taskId), attempt,
1763
+ }, context));
1764
+ if (result.outcome === "blocked")
1765
+ throw new Error(result.failure);
1766
+ }
1767
+ async closeDiscardResource(handle, resource, verify) {
1768
+ const { task, attempt } = resource;
1769
+ if (!task || !attempt)
1770
+ return;
1771
+ await verify();
1772
+ await this.terminateAbortedAttempt(handle, task, attempt);
1773
+ if (allocationByKind(attempt, "agent") && attempt.termination?.status !== "terminated")
1774
+ throw new Error("Worker termination remains unproved.");
1775
+ const scope = new DeadlineScope(this.coordinatorRuntime.now() + CLEANUP_SAFETY_BUDGET_MS, () => this.coordinatorRuntime.now());
1776
+ try {
1777
+ for (const step of attempt.cleanup) {
1778
+ if (step.kind !== "worker_tab" && step.kind !== "workspace" || step.status === "completed")
1779
+ continue;
1780
+ await verify();
1781
+ step.status = "running";
1782
+ delete step.failure;
1783
+ await handle.save();
1784
+ await verify();
1785
+ if (allocationByKind(attempt, step.kind))
1786
+ await this.cleanupAbortedHost(handle, task, attempt, step.kind, scope);
1787
+ step.status = "completed";
1788
+ await handle.save();
1789
+ }
1790
+ }
1791
+ finally {
1792
+ scope.close();
1793
+ }
1794
+ }
1660
1795
  async abort(id, root, outerSignal) {
1661
1796
  root = realpathSync.native(root);
1662
1797
  return await this.store.withLock(root, async (lifecycle) => {
@@ -1695,10 +1830,8 @@ export class IsolatedRunner {
1695
1830
  if (task.kind !== "changeset")
1696
1831
  continue;
1697
1832
  for (const attempt of task.attempts) {
1698
- if (!allocationByKind(attempt, "agent")?.agentName || attempt.termination?.status === "terminated")
1699
- continue;
1700
1833
  try {
1701
- await this.proveTermination(handle, task, attempt, this.terminationCandidate(attempt), async () => await handle.save(), outerSignal, safetyDeadline);
1834
+ await this.terminateAbortedAttempt(handle, task, attempt, outerSignal, safetyDeadline);
1702
1835
  }
1703
1836
  catch (error) {
1704
1837
  this.attention(task, errorText(error));
@@ -1767,12 +1900,7 @@ export class IsolatedRunner {
1767
1900
  delete step.failure;
1768
1901
  await handle.save();
1769
1902
  if ((kind === "worker_tab" || kind === "workspace") && allocationByKind(attempt, kind)) {
1770
- const result = await scope.call((context) => this.hostRuntime.cleanupHost({
1771
- requestId: state.request.id, kind,
1772
- task: changesetTaskRequest(state, task.taskId), attempt,
1773
- }, context));
1774
- if (result.outcome === "blocked")
1775
- throw new Error(result.failure);
1903
+ await this.cleanupAbortedHost(handle, task, attempt, kind, scope);
1776
1904
  }
1777
1905
  else if ((kind === "worktree" || kind === "branch") && worker && expected) {
1778
1906
  const result = await scope.call((context) => this.integrationGit.release(state.root, worker, expected, kind, context.signal));
package/dist/schema.d.ts CHANGED
@@ -1430,7 +1430,7 @@ declare const IntegrationGenerationSchema: Type.TObject<{
1430
1430
  status: Type.TUnion<[Type.TLiteral<"pending">, Type.TLiteral<"running">, Type.TLiteral<"completed">]>;
1431
1431
  failure: Type.TOptional<Type.TString>;
1432
1432
  }>>>;
1433
- supersededFrom: Type.TOptional<Type.TUnion<[Type.TLiteral<"staging">, Type.TLiteral<"conflict">, Type.TLiteral<"validation_failed">, Type.TLiteral<"ready">, Type.TLiteral<"promotion_failed">]>>;
1433
+ supersededFrom: Type.TOptional<Type.TUnion<[Type.TLiteral<"staging">, Type.TLiteral<"conflict">, Type.TLiteral<"validation_failed">, Type.TLiteral<"ready">, Type.TLiteral<"promotion_failed">, Type.TLiteral<"discarded">]>>;
1434
1434
  failure: Type.TOptional<Type.TString>;
1435
1435
  }>;
1436
1436
  export declare const IntegrationStateSchema: Type.TObject<{
@@ -1670,7 +1670,7 @@ export declare const IntegrationStateSchema: Type.TObject<{
1670
1670
  status: Type.TUnion<[Type.TLiteral<"pending">, Type.TLiteral<"running">, Type.TLiteral<"completed">]>;
1671
1671
  failure: Type.TOptional<Type.TString>;
1672
1672
  }>>>;
1673
- supersededFrom: Type.TOptional<Type.TUnion<[Type.TLiteral<"staging">, Type.TLiteral<"conflict">, Type.TLiteral<"validation_failed">, Type.TLiteral<"ready">, Type.TLiteral<"promotion_failed">]>>;
1673
+ supersededFrom: Type.TOptional<Type.TUnion<[Type.TLiteral<"staging">, Type.TLiteral<"conflict">, Type.TLiteral<"validation_failed">, Type.TLiteral<"ready">, Type.TLiteral<"promotion_failed">, Type.TLiteral<"discarded">]>>;
1674
1674
  failure: Type.TOptional<Type.TString>;
1675
1675
  }>>;
1676
1676
  refresh: Type.TOptional<Type.TObject<{
@@ -2231,7 +2231,7 @@ declare const RunStateSchema: Type.TObject<{
2231
2231
  status: Type.TUnion<[Type.TLiteral<"pending">, Type.TLiteral<"running">, Type.TLiteral<"completed">]>;
2232
2232
  failure: Type.TOptional<Type.TString>;
2233
2233
  }>>>;
2234
- supersededFrom: Type.TOptional<Type.TUnion<[Type.TLiteral<"staging">, Type.TLiteral<"conflict">, Type.TLiteral<"validation_failed">, Type.TLiteral<"ready">, Type.TLiteral<"promotion_failed">]>>;
2234
+ supersededFrom: Type.TOptional<Type.TUnion<[Type.TLiteral<"staging">, Type.TLiteral<"conflict">, Type.TLiteral<"validation_failed">, Type.TLiteral<"ready">, Type.TLiteral<"promotion_failed">, Type.TLiteral<"discarded">]>>;
2235
2235
  failure: Type.TOptional<Type.TString>;
2236
2236
  }>>;
2237
2237
  refresh: Type.TOptional<Type.TObject<{
package/dist/schema.js CHANGED
@@ -387,7 +387,7 @@ const IntegrationGenerationSchema = Type.Object({
387
387
  cleanup: Type.Optional(Type.Array(CleanupStepSchema, { minItems: 2, maxItems: 2 })),
388
388
  supersededFrom: Type.Optional(Type.Union([
389
389
  Type.Literal("staging"), Type.Literal("conflict"), Type.Literal("validation_failed"),
390
- Type.Literal("ready"), Type.Literal("promotion_failed"),
390
+ Type.Literal("ready"), Type.Literal("promotion_failed"), Type.Literal("discarded"),
391
391
  ])),
392
392
  failure: OptionalRuntimeTextSchema,
393
393
  }, { additionalProperties: false });
@@ -978,6 +978,9 @@ export function parseRunState(value) {
978
978
  const state = value;
979
979
  const request = parseExecuteRequest(state.request);
980
980
  parseIntegrationState(state.integration, request);
981
+ if (state.status !== "aborted" && state.integration.generations.some((generation) => generation.supersededFrom === "discarded")) {
982
+ throw new Error("User-discarded generations require an aborted request.");
983
+ }
981
984
  const refresh = state.integration.refresh;
982
985
  if (refresh && (refresh.generation === 1 && !sameIdentity(refresh.from, state.requestStartMain)
983
986
  || (state.integration.generations.at(-1)?.status !== "promoted" && !sameIdentity(refresh.to, state.main)))) {
package/dist/store.d.ts CHANGED
@@ -41,6 +41,16 @@ export declare class FileRunStore {
41
41
  withLock<T>(root: string, operation: (lifecycle: LifecycleLock) => Promise<T>, options?: LifecycleLockOptions): Promise<T>;
42
42
  create(state: RunState): Promise<RunStateHandle>;
43
43
  load(root: string, id: string): Promise<RunStateHandle>;
44
+ /** Address saved state without resolving a parent checkout that may no longer exist. */
45
+ private validateDirectory;
46
+ loadFromDirectory(directory: string, id: string): Promise<RunStateHandle>;
47
+ hasProductiveRunLeaseInDirectory(directory: string): Promise<boolean>;
48
+ withDirectoryLock<T>(directory: string, operation: () => Promise<T>): Promise<T>;
49
+ /** Missing roots only; repoRoot records the stable primary checkout, not the removed parent. */
50
+ listOrphans(primary: string): Promise<{
51
+ states: RunStateHandle[];
52
+ invalidIds: string[];
53
+ }>;
44
54
  private stateEntries;
45
55
  /** Read only version and request status; never migrate or interpret v4 worker evidence. */
46
56
  assertLegacyAdmissionSafe(root: string): Promise<void>;
package/dist/store.js CHANGED
@@ -5,6 +5,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
5
5
  import { extensionConfigDir, readTextFileBounded, writePrivateTextFileAtomically } from "@henryqw/pi-config-store";
6
6
  import { check, lock } from "proper-lockfile";
7
7
  import { parseRunState, RUN_STATE_VERSION } from "./schema.js";
8
+ import { discardResources } from "./discard.js";
8
9
  const INITIAL_STATE_MAX_BYTES = 2 * 1024 * 1024;
9
10
  /*
10
11
  * create() caps the complete initial serialization at 2 MiB. Loaded requests
@@ -117,7 +118,7 @@ export class FileRunStore {
117
118
  return join(this.stateDirectory(root), "productive-run");
118
119
  }
119
120
  async assertSafeDestination(root, destination) {
120
- if (isWithin(realpathSync.native(root), await canonicalPlannedPath(destination))) {
121
+ if (isWithin(await canonicalPlannedPath(root), await canonicalPlannedPath(destination))) {
121
122
  throw new Error("Pi Subagent state directory must be outside the Git workspace.");
122
123
  }
123
124
  }
@@ -216,6 +217,91 @@ export class FileRunStore {
216
217
  }
217
218
  return this.handle(state, path);
218
219
  }
220
+ /** Address saved state without resolving a parent checkout that may no longer exist. */
221
+ async validateDirectory(directory) {
222
+ const parent = join(extensionConfigDir("pi-subagent", this.agentDir), "state");
223
+ if (dirname(directory) !== parent || !/^[a-f0-9]{64}$/.test(basename(directory))
224
+ || !(await lstat(directory)).isDirectory() || await realpath(directory) !== join(await realpath(parent), basename(directory))) {
225
+ throw new Error("Invalid Pi Subagent state directory.");
226
+ }
227
+ }
228
+ async loadFromDirectory(directory, id) {
229
+ await this.validateDirectory(directory);
230
+ if (basename(id) !== id || !id || id === "." || id === "..")
231
+ throw new Error("Invalid request ID.");
232
+ const path = join(directory, `${id}.json`);
233
+ if (!(await lstat(path)).isFile())
234
+ throw new Error("State is not a regular file.");
235
+ const state = parseRunState(JSON.parse(await readTextFileBounded(path, STATE_MAX_BYTES)));
236
+ if (state.request.id !== id || !isAbsolute(state.root)
237
+ || createHash("sha256").update(state.root).digest("hex") !== basename(directory)) {
238
+ throw new Error("pi-subagent state identity does not match its directory and filename.");
239
+ }
240
+ await this.assertSafeDestination(state.root, path);
241
+ return this.handle(state, path);
242
+ }
243
+ async hasProductiveRunLeaseInDirectory(directory) {
244
+ await this.validateDirectory(directory);
245
+ const path = join(directory, "productive-run");
246
+ return await check(path, { realpath: false, stale: LOCK_OPTIONS.stale, lockfilePath: `${path}.lock` });
247
+ }
248
+ async withDirectoryLock(directory, operation) {
249
+ await this.validateDirectory(directory);
250
+ const path = join(directory, "lifecycle.lock");
251
+ const release = await lock(path, { ...LIFECYCLE_LOCK_OPTIONS, lockfilePath: `${path}.lock` });
252
+ try {
253
+ return await operation();
254
+ }
255
+ finally {
256
+ await release();
257
+ }
258
+ }
259
+ /** Missing roots only; repoRoot records the stable primary checkout, not the removed parent. */
260
+ async listOrphans(primary) {
261
+ const parent = join(extensionConfigDir("pi-subagent", this.agentDir), "state");
262
+ let directories;
263
+ try {
264
+ directories = await readdir(parent, { withFileTypes: true });
265
+ }
266
+ catch (error) {
267
+ if (isMissing(error))
268
+ return { states: [], invalidIds: [] };
269
+ throw error;
270
+ }
271
+ const states = [];
272
+ const invalidIds = [];
273
+ for (const entry of directories) {
274
+ const directory = join(parent, entry.name);
275
+ try {
276
+ await this.validateDirectory(directory);
277
+ for (const file of await readdir(directory)) {
278
+ if (!file.endsWith(".json"))
279
+ continue;
280
+ try {
281
+ const handle = await this.loadFromDirectory(directory, file.slice(0, -5));
282
+ try {
283
+ await lstat(handle.state.root);
284
+ continue;
285
+ }
286
+ catch (error) {
287
+ if (!isMissing(error))
288
+ throw error;
289
+ }
290
+ const worktrees = discardResources(handle.state).flatMap((resource) => resource.worktree ? [resource.worktree] : []);
291
+ if (worktrees.length && worktrees.every((item) => item.repoRoot === primary))
292
+ states.push(handle);
293
+ }
294
+ catch {
295
+ invalidIds.push(join(directory, file));
296
+ }
297
+ }
298
+ }
299
+ catch {
300
+ invalidIds.push(directory);
301
+ }
302
+ }
303
+ return { states, invalidIds };
304
+ }
219
305
  async stateEntries(root) {
220
306
  try {
221
307
  return (await readdir(this.stateDirectory(root), { withFileTypes: true }))
@@ -251,6 +251,8 @@ After readiness, use `subagent_stage revise` with the exact candidate, generatio
251
251
 
252
252
  To select work by label, open `/subagent` without arguments. The menu shows direct records on this session branch and isolated requests in the current canonical Git checkout. This includes requests launched in other sessions. Select an isolated request. Then select **Inspect status and recovery**.
253
253
 
254
+ The menu also shows **Isolated · orphaned** requests whose saved parent checkout no longer exists. You can open this view from any checkout of the same repository. The saved worker or integration path identifies the primary repository. Orphaned requests have the same **Close and delete worktrees (discard work)** action, but no inspection or follow-up actions. The extension reads their original state directories; it does not recreate the removed parent checkout. Unreadable files stay preserved and are reported.
255
+
254
256
  This launches a Herdr tab without focus, with the configured `scout` Role on the configured `fast` route. The tab runs a read-access task with the resources of the Role. Its prompt contains bounded saved status, including retained paths, but not the raw launch environment or session transcripts. The analysis stays in that tab and does not queue a Main turn. The tab shows in the direct list with its own reference and name, such as `D9.1 · scout · Inspect <request ID>`. The extension records the tab and session file for recovery, even if the launch becomes uncertain.
255
257
 
256
258
  Before any stage or recovery action, the read-only `subagent_status` tool stays Main's authority for exact identities. If Herdr, the `scout` Role, or the fast route is not available, inspection fails and does not change the request. Main can then use `subagent_status`.
@@ -357,6 +359,20 @@ If another productive run holds the lease, the command only reads status and rep
357
359
 
358
360
  Main uses `subagent_status` for exact identities. Main uses the recovery tools in the interface table above for deliberate decisions. The command never stages or promotes. Direct Herdr tab recovery stays in `/subagent`.
359
361
 
362
+ ### Close and delete isolated worktrees
363
+
364
+ Open `/subagent`, select an isolated request, then select **Close and delete worktrees (discard work)**. This is a user action, not an agent tool. Without interactive UI, `/subagent` only reports requests.
365
+
366
+ The read-only review groups resources by worker attempt or integration generation. It shows each recorded checkout path, branch, tip, commits ahead of the recorded base, dirty state, ignored entries, and Herdr resources. Text attempts do not save their base; their commit count is shown as unknown. The review also includes owned branches with no checkout. If a complete resource group does not fit, the report gives the number of groups not shown. Those groups are not removed.
367
+
368
+ One confirmation permits permanent deletion of committed, uncommitted, and ignored content in the listed checkouts. It also permits closure of the listed Herdr tabs and workspaces. Cancel makes no change. The command uses the existing abort termination and host cleanup paths before forced checkout removal. It deletes a branch only at its reviewed tip. It saves each completed cleanup step.
369
+
370
+ Each resource is checked again before removal. A live productive lease or local job blocks the action. Uncertain allocations, changed branch tips, locked or redirected checkouts, and other Herdr panes inside a checkout block that resource. Initialized submodules and index flags that hide clean-state evidence also require separate inspection. If Herdr cannot be inspected, checkouts stay in place.
371
+
372
+ 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.
373
+
374
+ 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.
375
+
360
376
  ### Prune leftover worktrees
361
377
 
362
378
  Run `/subagent prune` to clean up leftover worker and integration checkouts under `.worktrees/`. Their branches have the name `pi-subagent/subagent-` plus 24 hex characters. The command also cleans up such branches that have no checkout. The review lists each item with its path, branch, and tip. For each item, it also shows why the command can remove the item, or it shows every exact blocker. The command can remove an item only when all of these conditions are true:
@@ -38,7 +38,8 @@ import {
38
38
  } from "../dist/schema.js";
39
39
  import { FileRunStore } from "../dist/store.js";
40
40
  import { workerOutputForMain } from "./result-transport.ts";
41
- import type { IsolatedInventory, PrunePlan } from "./subagent-command.ts";
41
+ import type { IsolatedInventory, IsolatedRequest, PrunePlan } from "./subagent-command.ts";
42
+ import type { DiscardPlan } from "../dist/discard.js";
42
43
 
43
44
  const LOOKUP_ROOT_TIMEOUT_MS = 5_000;
44
45
  const PUBLIC_EVIDENCE_MAX_BYTES = 512;
@@ -471,6 +472,8 @@ export interface IsolatedSurface {
471
472
  inventory(cwd: string, current?: () => boolean): Promise<IsolatedInventory>;
472
473
  recover(cwd: string): Promise<string>;
473
474
  prunePlan(cwd: string): Promise<PrunePlan>;
475
+ discardPlan(cwd: string, request: IsolatedRequest): Promise<DiscardPlan>;
476
+ discard(cwd: string, plan: DiscardPlan, current: () => boolean): Promise<string>;
474
477
  prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, current: () => boolean): Promise<string>;
475
478
  inspect(root: string, requestId: string): Promise<readonly string[]>;
476
479
  canFollowup(root: string, requestId: string, taskId: string): boolean;
@@ -790,7 +793,8 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
790
793
  try { renderWorkspaceWidget(latestContext(), workspaceRowsByRequest); }
791
794
  catch (error) { console.error("Pi Subagent workspace widget update failed.", error); }
792
795
  }
793
- return { root, requests, invalidIds };
796
+ const orphans = await getComponents().runner.listOrphanRequests(root);
797
+ return { root, requests: [...requests, ...orphans.requests], invalidIds: [...new Set([...invalidIds, ...orphans.invalidIds])] };
794
798
  },
795
799
  async recover(cwd) {
796
800
  const root = await lookupRoot(cwd);
@@ -823,6 +827,22 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
823
827
  ];
824
828
  return boundedReport(lines, "recovery report lines omitted; use subagent_status for exact request evidence.");
825
829
  },
830
+ async discardPlan(cwd, request) {
831
+ return await getComponents().runner.discardPlan(cwd, request.id, request.directory, {
832
+ panes: () => listHerdrPanes(cwd), current: () => !sessionClosed,
833
+ blocked: async () => jobOwners.has(`${request.root}\0${request.id}`) ? "A local job is active for this request." : undefined,
834
+ });
835
+ },
836
+ async discard(cwd, plan, current) {
837
+ requireCurrent(current);
838
+ const removals = await getComponents().runner.discard(cwd, plan, {
839
+ panes: () => listHerdrPanes(cwd), current: () => current() && !sessionClosed,
840
+ blocked: async () => jobOwners.has(`${plan.root}\0${plan.requestId}`) ? "A local job is active for this request." : undefined,
841
+ });
842
+ return boundedReport([`Close and delete ${plan.requestId}:`,
843
+ ...removals.map((item) => `${item.outcome}: ${item.key}: ${item.reason}`),
844
+ ], "removal report lines omitted; reopen /subagent to inspect retained resources.");
845
+ },
826
846
  async prunePlan(cwd) {
827
847
  const root = await lookupRoot(cwd);
828
848
  return { root, items: await inspectPruneCandidates(root, cwd, pruneGuards(root)) };
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { DiscardPlan } from "../dist/discard.js";
2
3
 
3
4
  /** Saved before launch for user matching only; never ownership or mutation authority. */
4
5
  export interface DirectDisplay {
@@ -39,6 +40,10 @@ export interface IsolatedTask {
39
40
  }
40
41
  export interface IsolatedRequest {
41
42
  id: string;
43
+ /** Saved state address for a removed parent checkout. */
44
+ directory?: string;
45
+ root?: string;
46
+ orphaned?: boolean;
42
47
  name: string;
43
48
  status: string;
44
49
  tasks: readonly IsolatedTask[];
@@ -75,6 +80,8 @@ export interface SubagentCommandAdapter {
75
80
  recover(cwd: string): Promise<string>;
76
81
  /** Read-only review of pi-subagent worktrees and branches in the canonical repository. */
77
82
  prunePlan(cwd: string): Promise<PrunePlan>;
83
+ discardPlan(cwd: string, request: IsolatedRequest): Promise<DiscardPlan>;
84
+ discard(cwd: string, plan: DiscardPlan, current: () => boolean): Promise<string>;
78
85
  /** Re-verify and remove reviewed prunable items; returns a bounded report. */
79
86
  prune(cwd: string, items: readonly PruneItem[], allowIgnored: boolean, current: () => boolean): Promise<string>;
80
87
  /** Read-only Role catalog report with routes and per-file load errors. */
@@ -99,11 +106,11 @@ const PRUNE_IGNORED_CONFIRM_CODE_POINTS = 4_000;
99
106
  const NOT_SHOWN = "they are not removed in this run; run /subagent prune again after this run.";
100
107
 
101
108
  /** Fit whole item lines within `max` code points; never cut silently, and report exactly which items were shown. */
102
- function fitItems<T>(header: string, items: readonly T[], line: (item: T) => string, max: number): { text: string; shown: T[] } {
109
+ function fitItems<T>(header: string, items: readonly T[], line: (item: T) => string, max: number, omitted = NOT_SHOWN): { text: string; shown: T[] } {
103
110
  const lines = [header];
104
111
  const shown: T[] = [];
105
112
  let size = [...header].length;
106
- const reserve = [...NOT_SHOWN].length + 32;
113
+ const reserve = [...omitted].length + 32;
107
114
  for (const item of items) {
108
115
  const text = line(item);
109
116
  const length = [...text].length + 1;
@@ -112,7 +119,7 @@ function fitItems<T>(header: string, items: readonly T[], line: (item: T) => str
112
119
  size += length;
113
120
  shown.push(item);
114
121
  }
115
- if (shown.length < items.length) lines.push(`+${items.length - shown.length} more not shown; ${NOT_SHOWN}`);
122
+ if (shown.length < items.length) lines.push(`+${items.length - shown.length} more not shown; ${omitted}`);
116
123
  return { text: lines.join("\n"), shown };
117
124
  }
118
125
 
@@ -126,6 +133,18 @@ function pruneReview({ root, items }: PrunePlan): { text: string; shown: PruneIt
126
133
  ROLES_REPORT_CODE_POINTS);
127
134
  }
128
135
 
136
+ function discardReview(plan: DiscardPlan) {
137
+ if (!plan.items.length) return { text: `No remaining owned resources for ${plan.requestId}.`, shown: [] };
138
+ return fitItems(`Close and delete request ${plan.requestId}:`, plan.items, (item) => clean([
139
+ `${item.key}: ${item.blockers.length ? `blocked: ${item.blockers.join("; ")}` : "can delete"}`,
140
+ `path: ${item.path ?? "none"}; checkout: ${item.checkout ? "yes" : "no"}; branch: ${item.branch ?? "none"}; tip: ${item.tip ?? "none"}`,
141
+ `commits ahead of recorded base: ${item.commits ?? "unknown"}; dirty: ${item.dirty === undefined ? "not inspected" : item.dirty ? "yes" : "no"}`,
142
+ `ignored entries (including all content inside them): ${item.ignored.length ? item.ignored.join(", ") : "none"}`,
143
+ `Herdr resources: ${item.hosts.length ? item.hosts.join("; ") : "none"}`,
144
+ ].join("\n"), Number.MAX_SAFE_INTEGER, true), ROLES_REPORT_CODE_POINTS,
145
+ "they are not removed; reopen this request to review them.");
146
+ }
147
+
129
148
  type Choice<T> = { label: string; value: T };
130
149
  async function choose<T>(ctx: ExtensionContext, title: string, items: readonly Choice<T>[]): Promise<T | undefined> {
131
150
  // Numeric suffixes are UI-only. Never parse display text into an identity.
@@ -147,7 +166,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
147
166
  else pi.sendMessage({ customType: "pi-subagent-roles", content: report, display: true });
148
167
  return;
149
168
  }
150
- if (!command && !ctx.hasUI) throw new Error("/subagent requires interactive UI (TUI or RPC); use the subagent agent tools in non-interactive mode.");
169
+
151
170
  const manager = ctx.sessionManager;
152
171
  const session = manager.getSessionId();
153
172
  const file = manager.getSessionFile();
@@ -207,6 +226,14 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
207
226
  } catch (error) { if (valid()) ctx.ui.notify(`Prune failed: ${errorText(error)}`, "error"); }
208
227
  return;
209
228
  }
229
+ if (!ctx.hasUI) {
230
+ const inventory = await adapter.isolated(ctx.cwd, current);
231
+ const report = fitItems("Isolated requests (report only):", inventory.requests,
232
+ (request) => clean(`${request.orphaned ? "orphaned · " : ""}${request.name} · ${request.status} · ${request.id}`, 500), ROLES_REPORT_CODE_POINTS,
233
+ "open /subagent with interactive UI to inspect them.");
234
+ pi.sendMessage({ customType: "pi-subagent-discard", content: `${report.text}\nUnreadable state files preserved: ${inventory.invalidIds.length}.\nRemoval requires interactive confirmation.`, display: true });
235
+ return;
236
+ }
210
237
  let history = false;
211
238
  for (;;) {
212
239
  if (!valid()) return;
@@ -223,7 +250,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
223
250
  const selections: Choice<Selection>[] = [
224
251
  // One row per workflow; new records keep opaque IDs in details. Status is last so clipping preserves the reference.
225
252
  ...direct.filter((task) => history || !completed(task.status)).map((task) => ({ label: `Direct · ${directLabel(task)} · ${task.status}${task.workers.length || task.name === task.id ? "" : ` · ${task.id}`}`, value: { kind: "direct" as const, task } })),
226
- ...(inventory?.requests ?? []).filter((request) => history || !completed(request.status) || /retained/i.test(request.status)).map((request) => ({ label: `Isolated · ${request.name} · ${request.status} · ${request.id}`, value: { kind: "isolated" as const, request, root: inventory!.root } })),
253
+ ...(inventory?.requests ?? []).filter((request) => request.orphaned || history || !completed(request.status) || /retained/i.test(request.status)).map((request) => ({ label: `Isolated · ${request.orphaned ? "orphaned · " : ""}${request.name} · ${request.status} · ${request.id}`, value: { kind: "isolated" as const, request, root: inventory!.root } })),
227
254
  { label: "Refresh", value: { kind: "refresh" as const } },
228
255
  { label: history ? "Active work" : "Completed / history (including retained work)", value: { kind: "history" as const } },
229
256
  { label: "Close", value: { kind: "close" as const } },
@@ -275,16 +302,35 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
275
302
  const { root, request } = selected;
276
303
  for (;;) {
277
304
  if (!valid()) return;
278
- type Action = "inspect" | "send" | "edit" | "back";
305
+ type Action = "inspect" | "send" | "edit" | "discard" | "back";
279
306
  const eligible = request.tasks.filter((task) => task.kind === "changeset" && adapter.canFollowup(root, request.id, task.id));
280
307
  const actions: Choice<Action>[] = [
281
- { label: "Inspect status and recovery", value: "inspect" },
308
+ ...(!request.orphaned ? [{ label: "Inspect status and recovery", value: "inspect" as const }] : []),
309
+ { label: "Close and delete worktrees (discard work)", value: "discard" },
282
310
  ...(eligible.length ? [{ label: "Send follow-up instructions", value: "send" as const }, { label: "Edit queued instructions (withdraw ALL pending; Cancel leaves withdrawn)", value: "edit" as const }] : []),
283
311
  { label: "Back / refresh", value: "back" },
284
312
  ];
285
313
  const action = await choose(ctx, `Isolated · ${clean(request.name)} · ${request.id}`, actions);
286
314
  if (!valid() || !action) return;
287
315
  if (action === "back") break;
316
+ if (action === "discard") {
317
+ try {
318
+ const plan = await adapter.discardPlan(ctx.cwd, request);
319
+ const reviewed = discardReview(plan);
320
+ if (!valid()) return;
321
+ ctx.ui.notify(reviewed.text, "warning");
322
+ const items = reviewed.shown.filter((item) => !item.blockers.length);
323
+ if (!items.length) continue;
324
+ const confirmed = await ctx.ui.confirm("Close and delete worktrees (discard work)",
325
+ `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.`);
326
+ if (!valid()) return;
327
+ if (confirmed) {
328
+ const report = await adapter.discard(ctx.cwd, { ...plan, items }, current);
329
+ if (valid()) ctx.ui.notify(clean(report, ROLES_REPORT_CODE_POINTS, true), "info");
330
+ }
331
+ } catch (error) { if (valid()) ctx.ui.notify(`Close and delete failed: ${errorText(error)}`, "error"); }
332
+ break;
333
+ }
288
334
  if (action === "inspect") {
289
335
  try {
290
336
  const tab = await adapter.inspectInTab(root, request.id, ctx, current);
@@ -331,6 +331,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
331
331
  isolated: isolatedSurface.inventory,
332
332
  recover: (cwd) => isolatedSurface.recover(cwd),
333
333
  prunePlan: (cwd) => isolatedSurface.prunePlan(cwd),
334
+ discardPlan: (cwd, request) => isolatedSurface.discardPlan(cwd, request),
335
+ discard: (cwd, plan, current) => isolatedSurface.discard(cwd, plan, current),
334
336
  prune: (cwd, items, allowIgnored, current) => isolatedSurface.prune(cwd, items, allowIgnored, current),
335
337
  async inspectInTab(root, id, ctx, current) {
336
338
  const notices = await isolatedSurface.inspect(root, id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "27.4.5",
3
+ "version": "27.5.1",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -32,10 +32,10 @@ To build on a finished direct worker, add `continueFrom` with its reference (suc
32
32
 
33
33
  Give the request a unique ID and goal. Give each task a unique ID, kind, Role, model class, bounded requirements, deliverable, `dependsOn`, and `contextFrom`. Changesets require focused task checks, and a graph containing changesets requires final checks. The root must have a `package.json` test script; `pnpm test` is added to final checks if missing and runs against the combined tip before Main promotion. Text context may name only completed text tasks, preserves declared order, and implies a scheduling dependency; do not repeat an edge in `dependsOn` and `contextFrom`.
34
34
 
35
- Every changeset advances automatically after preliminary checks pass. While a locally owned changeset task is actively working, the user may open `/subagent` to queue bounded same-worker revisions or withdraw all still-pending instructions into the native editor and submit one replacement. Cancellation after withdrawal leaves the originals removed; already-claimed instructions cannot be recalled. Each revision must produce a new clean commit and rerun preliminary checks. If a worker's turn ends with uncommitted changes, the runner sends it one commit reminder per attempt; this uses no correction, and a worktree still dirty afterwards needs attention. When no queued revision remains, the runner seals and durably records the exact ready candidate without waiting for sibling workers. There is no guaranteed post-completion editing window, and late follow-ups fail visibly.
35
+ Every changeset becomes ready automatically after preliminary checks pass. While a locally owned changeset task is actively working, the user may open `/subagent` to queue bounded same-worker revisions or withdraw all still-pending instructions into the native editor and submit one replacement. Cancellation after withdrawal leaves the originals removed; already-claimed instructions cannot be recalled. Each revision must produce a new clean commit and rerun preliminary checks. If a worker's turn ends with uncommitted changes, the runner sends it one commit reminder per attempt; this uses no correction, and a worktree still dirty afterwards needs attention. When no queued revision remains, the runner seals and durably records the exact ready candidate without waiting for sibling workers. There is no guaranteed post-completion editing window, and late follow-ups fail visibly.
36
36
 
37
37
  A ready candidate does not integrate automatically. Each checked worker triggers a follow-up while its siblings continue; inspect `subagent_status` and immediately use `subagent_stage` with the exact `generation`, `taskId`, `attempt`, candidate `tip`, and `expectedTip` to select and merge it into the owned integration checkout. Stage and resolve are available during the active wave; wait for the wave to settle before rejecting or revising. Resolve conflicts in the integration checkout and verify with `resolve`; never edit Main to resolve them. For dependents, `subagent_integrate advance` acknowledges a durable background wave, then reports checked candidates as they finish. When selected work is staged and the wave settles, use `subagent_integrate validate` on the exact `combinedTip`: it acknowledges saved intent, runs full root checks and optional final judgment in the session-owned background, then sends a follow-up. Acknowledgement is not passing evidence; `promote` requires passing evidence and unchanged Main. One Main-authored committed correction to a definitively failed combined validation can be recorded with `correct`, then revalidated. Only proven promotion triggers selected-worker termination and verified cleanup. Failures, ambiguity, conflicts, interruption, child-limit exhaustion, or Main drift retain work and never waive checks or identity guards. The extension never pushes, opens a pull request, publishes, or deploys.
38
38
 
39
- After a coordinator interruption in the canonical repository, ask the user to run `/subagent recover` once: it acquires the productive lease, classifies orphaned interruptions, preserves invalid files and retained work, and sends Main one follow-up with blockers and next actions. A live productive lease is read-only and cannot be taken over. Recovery never replays prompts, merges, promotion, or cleanup. For leftover merged worker or integration checkouts, ask the user to run `/subagent prune`; it removes only reviewed, confirmed, merged, clean, unowned items. Use `subagent_status` to inspect exact durable state without mutation, including candidate identities, generations, and retained resources. `subagent_resume` handles only the reported retry/verify/finalize continuation; it cannot replace Main's stage/integrate decisions or reset policy/corrections. For a retry that sends a same-worker correction, pass `instruction` to name the exact fixes, including the review or check findings the worker must address; the correction prompt carries only a short saved failure. Follow-ups show a short preview of a failed review verdict; `subagent_status` returns it in full. `subagent_stage reject` or `revise` freezes a generation containing the candidate and invalidates dependent attempts; restage chosen work in a new generation. If Main advances cleanly before promotion, use `subagent_integrate refresh` with exact old/new Main and combined-tip identities; restage immutable candidates into the new generation and rerun combined validation. Dirty or divergent Main blocks refresh. For interrupted validation, prove old coordinator/check processes stopped, then guarded `reconcile` classifies the exact generation; never resume a live lease. A `validation_failed` generation without checks, review, or a prior combined correction can `refresh` with identical `expectedMain`/`newMain`, the old exact combined `expectedTip`, and the next generation number. Main and the retained integration tip must be exact and clean. Restage immutable candidates explicitly and rerun all final gates in the new checkout; never replay old uncertain checks, infer passing evidence, or reset correction allowances. At most two unreleased integration worktrees may coexist. Rejected candidates and superseded generations remain retained until Main explicitly calls `subagent_integrate release` with the exact generation and tip; for a rejected worker include its task ID and attempt after releasing all generations using it. Dirty/conflicted or unproved resources block release without force deletion. Impossible fresh dependent work after rejection needs a new request. `subagent_abort` cannot discard retained candidates or integration generations; release them first. For workers without candidates, abort cleans only proven unchanged, clean owned resources. If cleanup is deferred or blocked, inspect status and repeat abort after resolving the blocker. Dirty or committed work and uncertain allocations remain for manual recovery. Old v4 state is rejected (current state is v5), not migrated. Status, abort, process I/O, termination, and verified post-promotion cleanup keep finite safety budgets.
39
+ After a coordinator interruption in the canonical repository, ask the user to run `/subagent recover` once; if another productive run holds the lease, wait and run it again. It acquires the productive lease, classifies orphaned interruptions, preserves invalid files and retained work, and sends Main one follow-up with blockers and next actions. A live productive lease is read-only and cannot be taken over. Recovery never replays prompts, merges, promotion, or cleanup. For leftover merged worker or integration checkouts, ask the user to run `/subagent prune`; it removes only reviewed, confirmed, merged, clean, unowned items. Use `subagent_status` to inspect exact durable state without mutation, including candidate identities, generations, and retained resources. `subagent_resume` handles only the reported retry/verify/finalize continuation; it cannot replace Main's stage/integrate decisions or reset policy/corrections. For a retry that sends a same-worker correction, pass `instruction` to name the exact fixes, including the review or check findings the worker must address; the correction prompt carries only a short saved failure. Follow-ups show a short preview of a failed review verdict; `subagent_status` returns it in full. `subagent_stage reject` or `revise` freezes a generation containing the candidate and invalidates dependent attempts; restage chosen work in a new generation. If Main advances cleanly before promotion, use `subagent_integrate refresh` with exact old/new Main and combined-tip identities; restage immutable candidates into the new generation and rerun combined validation. Dirty or divergent Main blocks refresh. For interrupted validation, prove old coordinator/check processes stopped, then guarded `reconcile` classifies the exact generation; never resume a live lease. A `validation_failed` generation without checks, review, or a prior combined correction can `refresh` with identical `expectedMain`/`newMain`, the old exact combined `expectedTip`, and the next generation number. Main and the retained integration tip must be exact and clean. Restage immutable candidates explicitly and rerun all final gates in the new checkout; never replay old uncertain checks, infer passing evidence, or reset correction allowances. At most two unreleased integration worktrees may coexist. Rejected candidates and superseded generations remain retained until Main explicitly calls `subagent_integrate release` with the exact generation and tip; for a rejected worker include its task ID and attempt after releasing all generations using it. Dirty/conflicted or unproved resources block release without force deletion. Impossible fresh dependent work after rejection needs a new request. `subagent_abort` cannot discard retained candidates or integration generations; release them first. For workers without candidates, abort cleans only proven unchanged, clean owned resources. If cleanup is deferred or blocked, inspect status and repeat abort after resolving the blocker. Dirty or committed work and uncertain allocations remain for manual recovery. Old v4 state is rejected (current state is v5), not migrated. Status, abort, process I/O, termination, and verified post-promotion cleanup keep finite safety budgets.
40
40
 
41
41
  All launches use the global policy in `config/pi-subagent/config.json`: productive concurrency, per-child turns/tokens, and idle timeout. Ephemeral children also have a hard runtime limit; isolated requests have a correction cap. Productive requests have no whole-run wall-clock deadline. Request fields cannot replenish or override limits.