@henryqw/pi-subagent 27.4.5 → 27.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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.
@@ -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.0",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",