@henryqw/pi-subagent 27.9.0 → 27.10.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
@@ -136,6 +136,8 @@ Use isolated mode for checked implementation. An isolated request has one durabl
136
136
 
137
137
  Workers use Main's pinned committed branch, HEAD, and tree, not staged, unstaged, or untracked edits. Branch or HEAD changes block new waves. Candidates must be committed with no tracked or untracked changes. Ignored generated files permit candidacy but prevent automatic cleanup.
138
138
 
139
+ New isolated worktree names use the request and task IDs, plus an identity suffix. Integration names use the request ID and `integration-<generation>`. Herdr workspace and tab labels show the worker worktree name. Names do not use goals or requirements. Saved worktrees and labels keep their exact names for recovery and cleanup.
140
+
139
141
  Keep the durable ID; Main can work or end its turn without polling. Inspect ready candidates with `subagent_status`.
140
142
 
141
143
  - `subagent_stage` stages exact candidates while siblings run; resolve conflicts in the integration checkout.
@@ -161,7 +163,11 @@ A direct workflow is working only when this Pi process owns it and has a running
161
163
 
162
164
  Each row starts with a status symbol: `●` active, `✓` completed, `⚠` needs attention, `✗` failed. In the terminal UI, `/subagent` reports (Roles, details, reviews, and results) show as colored transcript entries. The model does not get these entries. In RPC mode, they show as notifications. Without interactive UI, they show as plain text messages.
163
165
 
164
- Requests whose parent checkout was removed show in the **Orphaned · parent checkout removed** group from any checkout of the same repository. Every isolated request, including an orphaned request, has **Close and delete worktrees (discard work)**. Select it to review owned resources and confirm deletion. **This permanently deletes committed, uncommitted, and ignored content in the listed checkouts.** Cancel makes no change.
166
+ Requests whose parent checkout was removed show in the **Orphaned · parent checkout removed** group from any checkout of the same repository. Saved `running` or `pending` states show as `saved running · unverified` or `saved pending · unverified`, not as live work. The report lists them under **Unverified**. This does not prove completion.
167
+
168
+ Both isolated groups hide terminal requests by default: `completed`, `aborted`, `final_failed`, and `superseded`. Select **Completed / history** to see them. Requests with retained resources or unresolved recovery stay visible. This filter does not delete saved records. Without interactive UI, the request list includes history.
169
+
170
+ Every isolated request, including an orphaned request, has **Close and delete worktrees (discard work)**. Select it to review owned resources and confirm deletion. **This permanently deletes committed, uncommitted, and ignored content in the listed checkouts.** Cancel makes no change.
165
171
 
166
172
  Select **Select multiple…** to apply one action to many rows. Select a row to mark it `[x]` or clear it, or use **Select all** or **Clear**, then select **Continue**. The next menu shows only the actions that apply. **Close and delete worktrees (discard work)** applies to isolated requests. **Close/cancel-and-release ALL owned workers** applies to direct workflows that can close. Each request shows its review, then one confirmation covers all of them.
167
173
 
@@ -46,6 +46,7 @@ export declare class CheckedGitRuntime implements GitRuntime, TaskCandidateInspe
46
46
  root: string;
47
47
  }, context: OperationContext): Promise<WorkspaceIdentity>;
48
48
  allocateWorktree(input: {
49
+ requestId: string;
49
50
  root: string;
50
51
  baseRoot?: string;
51
52
  intent: WorktreeAllocationIntent;
@@ -111,7 +111,7 @@ export class CheckedGitRuntime {
111
111
  if (!sameIdentity(current, input.attempt.waveBase)) {
112
112
  throw new Error("Recorded wave snapshot drifted after worktree preparation and before git worktree add.");
113
113
  }
114
- });
114
+ }, [input.requestId, input.task.id]);
115
115
  if (!created)
116
116
  return { kind: "worktree", outcome: "absent", failure: "Git worktree allocation requires a committed repository." };
117
117
  const worktree = cloneWorktree(created);
@@ -24,7 +24,7 @@ export interface HerdrHostRuntimeOptions {
24
24
  lsofCommand?: string;
25
25
  executionBudget?: () => Omit<EphemeralSubagentExecutionBudget, "startedAt">;
26
26
  }
27
- export declare function workspaceLabel(attempt: Pick<TaskAttempt, "correlationToken">): string;
27
+ export declare function workspaceLabel(attempt: TaskAttempt): string;
28
28
  export declare class HerdrHostRuntime implements HostRuntime {
29
29
  private readonly inspectInFlightCandidate;
30
30
  private readonly execute;
@@ -11,6 +11,7 @@ import { runProcess as defaultRunProcess } from "./process.js";
11
11
  import { EXECUTION_BUDGET_ENV } from "./ephemeral.js";
12
12
  import { assertPrivateLease, scanProcessLease } from "./process-lease.js";
13
13
  import { exactDirectTerminalTurn } from "./direct-herdr.js";
14
+ import { READABLE_BRANCH_PATTERN } from "./worktree.js";
14
15
  const MIN_HERDR_VERSION = [0, 9, 3];
15
16
  const MIN_HERDR_PROTOCOL = 22;
16
17
  const HERDR_OPERATION_CAP_MS = 30_000;
@@ -153,10 +154,14 @@ function worktreeIntent(attempt) {
153
154
  return intent.worktree;
154
155
  }
155
156
  export function workspaceLabel(attempt) {
156
- return attempt.correlationToken.slice(-6);
157
+ const { branch } = worktreeIntent(attempt);
158
+ return READABLE_BRANCH_PATTERN.test(branch) ? basename(branch) : attempt.correlationToken.slice(-6);
157
159
  }
158
- function expectedWorkerLabel(task) {
159
- return `${task.role}/${task.modelClass}`;
160
+ function expectedWorkerLabel(task, attempt) {
161
+ const { branch } = worktreeIntent(attempt);
162
+ return READABLE_BRANCH_PATTERN.test(branch)
163
+ ? `${workspaceLabel(attempt)}/${task.modelClass}`
164
+ : `${task.role}/${task.modelClass}`;
160
165
  }
161
166
  function expectedAgentName(token) {
162
167
  if (!CORRELATION_TOKEN_PATTERN.test(token))
@@ -187,7 +192,7 @@ function assertWorkspaceIntent(intent, attempt) {
187
192
  function assertWorkerTabIntent(intent, task, attempt) {
188
193
  const worktree = worktreeIntent(attempt);
189
194
  const workspace = ownedIntent(attempt, "workspace");
190
- if (exactString(intent.label, "worker-tab label") !== expectedWorkerLabel(task)
195
+ if (exactString(intent.label, "worker-tab label") !== expectedWorkerLabel(task, attempt)
191
196
  || exactString(intent.workspaceId, "worker-tab workspace ID") !== workspace.workspaceId
192
197
  || exactString(intent.workspaceRootTabId, "workspace root tab ID") !== workspace.rootTabId
193
198
  || exactString(intent.workspaceRootPaneId, "workspace root pane ID") !== workspace.rootPaneId
@@ -340,7 +345,7 @@ export class HerdrHostRuntime {
340
345
  this.assertLeasePath(leasePath, input.attempt.correlationToken);
341
346
  return {
342
347
  kind: "worker_tab",
343
- label: expectedWorkerLabel(input.task),
348
+ label: expectedWorkerLabel(input.task, input.attempt),
344
349
  workspaceId,
345
350
  workspaceRootTabId: exactString(workspace.rootTabId, "owned workspace root tab ID"),
346
351
  workspaceRootPaneId: exactString(workspace.rootPaneId, "owned workspace root pane ID"),
@@ -17,7 +17,7 @@ export declare class IntegrationGit {
17
17
  /** Refresh is allowed only for the exact clean checked-out Main and a strict same-branch descendant. */
18
18
  inspectMainAdvance(root: string, from: WorkspaceIdentity, to: WorkspaceIdentity, signal: AbortSignal): Promise<void>;
19
19
  /** Persist the prepared path/branch/base before Git is allowed to create anything. */
20
- allocate(root: string, childId: string, base: WorkspaceIdentity, onPrepared: (info: WorktreeInfo) => Promise<void>, signal: AbortSignal): Promise<GitOutcome<WorktreeInfo>>;
20
+ allocate(root: string, childId: string, base: WorkspaceIdentity, onPrepared: (info: WorktreeInfo) => Promise<void>, signal: AbortSignal, nameParts?: readonly string[]): Promise<GitOutcome<WorktreeInfo>>;
21
21
  /** Prove each recorded merge before any new merge or final gate. */
22
22
  inspectCombined(root: string, integration: WorktreeInfo, base: WorkspaceIdentity, stages: readonly StageReceipt[], signal: AbortSignal): Promise<WorkspaceIdentity>;
23
23
  /** Main's correction is one exact committed child of the checked staged tip. */
@@ -158,7 +158,7 @@ export class IntegrationGit {
158
158
  }
159
159
  }
160
160
  /** Persist the prepared path/branch/base before Git is allowed to create anything. */
161
- async allocate(root, childId, base, onPrepared, signal) {
161
+ async allocate(root, childId, base, onPrepared, signal, nameParts) {
162
162
  if (!sameIdentity(await inspect(root, signal), base))
163
163
  return { outcome: "drift", failure: "Main drifted before integration allocation." };
164
164
  let prepared;
@@ -170,7 +170,7 @@ export class IntegrationGit {
170
170
  await onPrepared(info);
171
171
  if (!sameIdentity(await inspect(root, signal), base))
172
172
  throw new Error("Main drifted before worktree add.");
173
- });
173
+ }, nameParts);
174
174
  if (!added)
175
175
  return { outcome: "blocked", failure: "Integration requires committed Git Main." };
176
176
  if (!sameIdentity(await inspect(root, signal), base))
package/dist/prune.d.ts CHANGED
@@ -33,7 +33,7 @@ export interface PruneItem {
33
33
  ignored: readonly string[];
34
34
  }
35
35
  export interface PruneOwnership {
36
- /** Every request or state that may still own a resource name (`subagent-<hex>`); unreadable state has no request. */
36
+ /** Every request or state that may still own an exact resource name; unreadable state has no request. */
37
37
  owners(name: string): readonly {
38
38
  label: string;
39
39
  request?: PruneRequest;
package/dist/runner.d.ts CHANGED
@@ -226,6 +226,7 @@ export interface GitRuntime {
226
226
  root: string;
227
227
  }, context: OperationContext): Promise<WorkspaceIdentity>;
228
228
  allocateWorktree(input: {
229
+ requestId: string;
229
230
  root: string;
230
231
  baseRoot?: string;
231
232
  intent: WorktreeAllocationIntent;
package/dist/runner.js CHANGED
@@ -5,7 +5,7 @@ import { utf8Prefix } from "./ephemeral.js";
5
5
  import { createChildWorktree, finalizeChildWorktree, inspectWorktreeDirty } from "./worktree.js";
6
6
  import { runGit } from "./git-process.js";
7
7
  import { IntegrationGit } from "./integration-git.js";
8
- import { checkBatchPasses, CLEANUP_KINDS, isCleanCommitted, MAX_PERSISTED_RUNTIME_TEXT_BYTES, MAX_POSSIBLE_RESOURCES, parseExecuteRequest, parseIntegrationAction, integrationGenerationPasses, parseResumeRequest, parseStageRequest, reusableCheckSource, reviewEvidencePasses, sameIdentity, formatCheckCommand, stagedCheckSources, RUN_STATE_VERSION, } from "./schema.js";
8
+ import { checkBatchPasses, CLEANUP_KINDS, isCleanCommitted, MAX_PERSISTED_RUNTIME_TEXT_BYTES, MAX_POSSIBLE_RESOURCES, parseExecuteRequest, parseIntegrationAction, integrationGenerationPasses, parseResumeRequest, parseStageRequest, reusableCheckSource, reviewEvidencePasses, sameIdentity, formatCheckCommand, stagedCheckSources, RUN_STATE_VERSION, TERMINAL_RUN_STATUSES, } from "./schema.js";
9
9
  import { FileRunStore, ProductiveRunLeaseBusyError } from "./store.js";
10
10
  const TRUNCATION_MARKER = "\n[truncated]";
11
11
  const TEXT_TASK_PROMPT_MAX_BYTES = 64 * 1024;
@@ -427,15 +427,17 @@ function correctionEligible(request, attempt) {
427
427
  && sameIdentity(attempt.preliminaryReview.identityAfter, attempt.candidate)));
428
428
  }
429
429
  function terminal(state) {
430
- return state.status === "completed" || state.status === "final_failed" || state.status === "superseded" || state.status === "aborted";
430
+ return TERMINAL_RUN_STATUSES.some((status) => status === state.status);
431
431
  }
432
- function inventoryStatus(state) {
432
+ function inventoryStatus(state, orphaned = false) {
433
+ if (orphaned && ["pending", "running"].includes(state.status))
434
+ return `saved ${state.status} · unverified`;
433
435
  const retained = state.tasks.some((task) => task.kind === "changeset"
434
436
  ? task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed"))
435
437
  : task.attempts.some((attempt) => attempt.cleanup && attempt.cleanup.outcome !== "pruned"))
436
438
  || state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")
437
439
  || (generation.worktree && generation.cleanup?.every((step) => step.status === "completed") !== true));
438
- return ["completed", "aborted"].includes(state.status) && retained ? `${state.status} · retained` : state.status;
440
+ return terminal(state) && retained ? `${state.status} · retained` : state.status;
439
441
  }
440
442
  /** Build one bounded changeset assignment. Call this before acquiring its Role launch. */
441
443
  export function buildChangesetTaskPrompt(input) {
@@ -670,7 +672,7 @@ export class IsolatedRunner {
670
672
  }
671
673
  const requests = [];
672
674
  for (const request of inventory.requests) {
673
- if (["pending", "running", "needs_attention", "completed · retained", "aborted · retained"].includes(request.status)) {
675
+ if (["pending", "running", "needs_attention"].includes(request.status) || request.status.endsWith(" · retained")) {
674
676
  requests.push(await this.status(request.id, root));
675
677
  }
676
678
  }
@@ -683,7 +685,7 @@ export class IsolatedRunner {
683
685
  const owners = [];
684
686
  let lease;
685
687
  const request = ({ state, path }, orphaned) => ({ id: state.request.id, name: state.request.goal,
686
- status: inventoryStatus(state), directory: dirname(path), root: state.root, orphaned });
688
+ status: inventoryStatus(state, orphaned), directory: dirname(path), root: state.root, orphaned });
687
689
  const orphans = checkouts[0] ? (await this.store.listOrphans(realpathSync.native(checkouts[0]))).states : [];
688
690
  for (const handle of orphans) {
689
691
  const { state } = handle;
@@ -1179,7 +1181,7 @@ export class IsolatedRunner {
1179
1181
  };
1180
1182
  generations.push(generation);
1181
1183
  await this.saveProductive(handle); // Before worktree add.
1182
- }, context.signal));
1184
+ }, context.signal, [action.id, `integration-${action.generation}`]));
1183
1185
  if (result.outcome !== "ready") {
1184
1186
  if (!generations.at(-1))
1185
1187
  throw new Error("Integration allocation did not persist an owned worktree plan.");
@@ -1380,7 +1382,7 @@ export class IsolatedRunner {
1380
1382
  state.integration.refresh = { from: action.expectedMain, to: action.newMain, generation: action.generation,
1381
1383
  status: "unknown", failure: "Integration allocation has not been proved; retain the recorded checkout." };
1382
1384
  await this.saveProductive(handle);
1383
- }, context.signal));
1385
+ }, context.signal, [action.id, `integration-${action.generation}`]));
1384
1386
  }
1385
1387
  catch (error) {
1386
1388
  // A prepared checkout remains recorded; no repeated allocation from this intent.
@@ -1786,7 +1788,7 @@ export class IsolatedRunner {
1786
1788
  const primary = await primaryCheckout(cwd);
1787
1789
  const { states, invalidIds } = await this.store.listOrphans(primary);
1788
1790
  return { invalidIds, requests: states.map((handle) => ({
1789
- id: handle.state.request.id, name: handle.state.request.goal, status: inventoryStatus(handle.state),
1791
+ id: handle.state.request.id, name: handle.state.request.goal, status: inventoryStatus(handle.state, true),
1790
1792
  directory: dirname(handle.path), root: handle.state.root, orphaned: true, tasks: [],
1791
1793
  })) };
1792
1794
  }
@@ -2301,6 +2303,7 @@ export class IsolatedRunner {
2301
2303
  try {
2302
2304
  if (intent.kind === "worktree") {
2303
2305
  result = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.allocateWorktree({
2306
+ requestId: state.request.id,
2304
2307
  root: state.root,
2305
2308
  ...(attempt.waveBase.head !== state.main.head ? { baseRoot: state.integration.generations.at(-1)?.worktree?.path } : {}),
2306
2309
  intent,
@@ -2426,7 +2429,7 @@ export class IsolatedRunner {
2426
2429
  if (worktree.baseCommit !== wave.base.head || !sameIdentity(await inspectBase({ root: baseRoot }, context), wave.base)) {
2427
2430
  throw new Error("Staged dependency snapshot drifted before text worktree creation.");
2428
2431
  }
2429
- });
2432
+ }, [state.request.id, task.taskId, `text-${attempt.number}`]);
2430
2433
  if (!isolated)
2431
2434
  throw new Error("Explicit isolated text work requires a Git checkout with a committed HEAD; it never falls back to Main.");
2432
2435
  let result;
package/dist/schema.d.ts CHANGED
@@ -1791,6 +1791,8 @@ export type IntegrationCandidate = Static<typeof IntegrationCandidateSchema>;
1791
1791
  export type IntegrationStage = Static<typeof IntegrationStageSchema>;
1792
1792
  export type IntegrationGeneration = Static<typeof IntegrationGenerationSchema>;
1793
1793
  export type IntegrationState = Static<typeof IntegrationStateSchema>;
1794
+ declare const TerminalRunStatusSchema: Type.TUnion<[Type.TLiteral<"completed">, Type.TLiteral<"final_failed">, Type.TLiteral<"superseded">, Type.TLiteral<"aborted">]>;
1795
+ export declare const TERMINAL_RUN_STATUSES: readonly Static<typeof TerminalRunStatusSchema>[];
1794
1796
  declare const RunStateSchema: Type.TObject<{
1795
1797
  version: Type.TLiteral<5>;
1796
1798
  request: Type.TObject<{
package/dist/schema.js CHANGED
@@ -412,6 +412,10 @@ export const IntegrationStateSchema = Type.Object({
412
412
  failure: OptionalRuntimeTextSchema,
413
413
  }, { additionalProperties: false })),
414
414
  }, { additionalProperties: false });
415
+ const TerminalRunStatusSchema = Type.Union([
416
+ Type.Literal("completed"), Type.Literal("final_failed"), Type.Literal("superseded"), Type.Literal("aborted"),
417
+ ]);
418
+ export const TERMINAL_RUN_STATUSES = TerminalRunStatusSchema.anyOf.map((status) => status.const);
415
419
  const RunStateSchema = Type.Object({
416
420
  version: Type.Literal(RUN_STATE_VERSION),
417
421
  request: ExecuteRequestSchema,
@@ -421,8 +425,8 @@ const RunStateSchema = Type.Object({
421
425
  requestStartMain: WorkspaceSchema,
422
426
  main: WorkspaceSchema,
423
427
  status: Type.Union([
424
- Type.Literal("pending"), Type.Literal("running"), Type.Literal("needs_attention"), Type.Literal("completed"),
425
- Type.Literal("final_failed"), Type.Literal("superseded"), Type.Literal("aborted"),
428
+ Type.Literal("pending"), Type.Literal("running"), Type.Literal("needs_attention"),
429
+ ...TerminalRunStatusSchema.anyOf,
426
430
  ]),
427
431
  tasks: Type.Array(TaskStateSchema, { minItems: 1, maxItems: MAX_TASKS }),
428
432
  waves: Type.Array(WaveStateSchema, { maxItems: MAX_TASKS }),
@@ -25,7 +25,8 @@ export type WorktreePayload = {
25
25
  dirty?: boolean;
26
26
  };
27
27
  export declare const WORKTREES_DIRNAME = ".worktrees";
28
- /** Branches `createChildWorktree` creates for workers and integration checkouts, at `<primary>/.worktrees/<branch basename>`. */
28
+ export declare const READABLE_BRANCH_PATTERN: RegExp;
29
+ /** Keep saved opaque names eligible for exact recovery and cleanup. */
29
30
  export declare const OWNED_BRANCH_PATTERN: RegExp;
30
31
  export declare class WorktreeSetupError extends Error {
31
32
  name: string;
@@ -39,7 +40,7 @@ export declare class WorktreeSetupError extends Error {
39
40
  * repository (unwritable path, stale branch, git lock) throw so an isolated
40
41
  * role never silently loses its isolation.
41
42
  */
42
- export declare function createChildWorktree(cwd: string, childId: string, run?: GitRunner, signal?: AbortSignal, onPrepared?: (worktree: WorktreeInfo) => Promise<void>): Promise<WorktreeInfo | undefined>;
43
+ export declare function createChildWorktree(cwd: string, childId: string, run?: GitRunner, signal?: AbortSignal, onPrepared?: (worktree: WorktreeInfo) => Promise<void>, nameParts?: readonly string[]): Promise<WorktreeInfo | undefined>;
43
44
  export interface WorktreeDirtyInspection {
44
45
  dirty: boolean;
45
46
  failure?: string;
package/dist/worktree.js CHANGED
@@ -5,8 +5,11 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { runGit } from "./git-process.js";
6
6
  export const WORKTREES_DIRNAME = ".worktrees";
7
7
  const BRANCH_NAMESPACE = "pi-subagent";
8
- /** Branches `createChildWorktree` creates for workers and integration checkouts, at `<primary>/.worktrees/<branch basename>`. */
9
- export const OWNED_BRANCH_PATTERN = new RegExp(`^${BRANCH_NAMESPACE}/subagent-[0-9a-f]{24}$`);
8
+ const NAME_COMPONENT_LIMIT = 24;
9
+ const NAME_LIMIT = 72;
10
+ export const READABLE_BRANCH_PATTERN = new RegExp(`^${BRANCH_NAMESPACE}/subagent-[a-z0-9](?:[a-z0-9-]{0,${NAME_LIMIT - 2}}[a-z0-9])?-[0-9a-f]{24}$`);
11
+ /** Keep saved opaque names eligible for exact recovery and cleanup. */
12
+ export const OWNED_BRANCH_PATTERN = new RegExp(`${READABLE_BRANCH_PATTERN.source}|^${BRANCH_NAMESPACE}/subagent-[0-9a-f]{24}$`);
10
13
  export class WorktreeSetupError extends Error {
11
14
  name = "WorktreeSetupError";
12
15
  worktree;
@@ -16,6 +19,11 @@ export class WorktreeSetupError extends Error {
16
19
  }
17
20
  }
18
21
  const sanitizeShortId = (childId) => createHash("sha256").update(childId).digest("hex").slice(0, 24);
22
+ function nameSlug(parts) {
23
+ return parts.map((part) => part.toLowerCase().replace(/[^a-z0-9]+/g, "-")
24
+ .slice(0, NAME_COMPONENT_LIMIT).replace(/^-+|-+$/g, "") || "task")
25
+ .join("-").slice(0, NAME_LIMIT).replace(/-+$/, "") || "task";
26
+ }
19
27
  const stripGitLineEnd = (value) => value.replace(/\r?\n$/, "");
20
28
  function hasRepositoryMarker(cwd) {
21
29
  for (let directory = resolve(cwd);; directory = dirname(directory)) {
@@ -60,7 +68,7 @@ async function ensureLocalExclude(gitDir) {
60
68
  * repository (unwritable path, stale branch, git lock) throw so an isolated
61
69
  * role never silently loses its isolation.
62
70
  */
63
- export async function createChildWorktree(cwd, childId, run = runGit, signal, onPrepared) {
71
+ export async function createChildWorktree(cwd, childId, run = runGit, signal, onPrepared, nameParts = [childId]) {
64
72
  const root = await run(["rev-parse", "--show-toplevel"], cwd, signal);
65
73
  signal?.throwIfAborted();
66
74
  if (root.code === -1)
@@ -130,7 +138,7 @@ export async function createChildWorktree(cwd, childId, run = runGit, signal, on
130
138
  }
131
139
  signal?.throwIfAborted();
132
140
  const worktreesRoot = join(stableRepoRoot, WORKTREES_DIRNAME);
133
- const name = `subagent-${sanitizeShortId(childId)}`;
141
+ const name = `subagent-${nameSlug(nameParts)}-${sanitizeShortId(childId)}`;
134
142
  const branch = `${BRANCH_NAMESPACE}/${name}`;
135
143
  const path = join(worktreesRoot, name);
136
144
  const worktree = { path, cwd: join(path, relativeCwd), branch, repoRoot: stableRepoRoot, baseCommit };
@@ -194,7 +194,13 @@ Activity uses the existing session sample and file-size cache. The extension ign
194
194
 
195
195
  ## Isolated contract
196
196
 
197
- An isolated request has one durable ID, one goal, and 1 to 8 typed tasks:
197
+ An isolated request has one durable ID, one goal, and 1 to 8 typed tasks.
198
+
199
+ New worktrees use `.worktrees/subagent-<name>-<24hex>` and branch `pi-subagent/subagent-<name>-<24hex>`. The name uses the request and task IDs. Text tasks also include `text-<attempt>`. Integration checkouts use the request ID and `integration-<generation>`. Each name component has at most 24 characters; the full readable part has at most 72. Names use lowercase ASCII letters, digits, and hyphens. The 24-hex identity suffix keeps names distinct when readable parts are shortened or normalized.
200
+
201
+ Herdr workspace labels use the worker worktree name. Worker tab labels add the model class. Names never use goals, requirements, or other free-form task text. Saved opaque worktree names and their Herdr labels stay unchanged. Resume and cleanup use the exact saved allocations; the extension does not rename existing resources.
202
+
203
+ For example:
198
204
 
199
205
  ```json
200
206
  {
@@ -271,13 +277,13 @@ After readiness, use `subagent_stage revise` with the exact candidate, generatio
271
277
 
272
278
  To select work by label, open `/subagent` without arguments. First, a **Current subagent work** report shows the work that local and saved evidence proves is running: locally owned direct workflows with a running worker, and active isolated requests while a live run holds the repository lease. The report lists active requests without a live lease as interrupted, and recorded-only direct work as unverified. A live lease does not identify its request, so several active requests in one checkout all show as working. 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**.
273
279
 
274
- The menu also shows requests whose saved parent checkout no longer exists, in their own **Orphaned · parent checkout removed** group. 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.
280
+ The menu also shows requests whose saved parent checkout no longer exists, in their own **Orphaned · parent checkout removed** group. Saved `running` and `pending` states have a `saved` prefix and an `unverified` suffix. The report lists them under **Unverified**, never **Working**, even if the current checkout has a live lease. These labels do not change saved state or prove completion. Orphaned requests with retained resources or `needs_attention` show under **Needs attention**. 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.
275
281
 
276
282
  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.
277
283
 
278
284
  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`.
279
285
 
280
- For older work, choose Refresh or completed/history. The extension shows a warning for invalid state IDs and preserves them. Direct recovery still works outside Git or when isolated inventory is not available. The browse action does not resume work or change resources. Direct **Close/cancel-and-release** is an explicit termination action.
286
+ Both isolated groups hide terminal requests by default: `completed`, `aborted`, `final_failed`, and `superseded`. Requests with retained resources or unresolved recovery stay visible. Select **Completed / history** to see all saved requests; **Refresh** reloads the current view. The filter does not delete records. Without interactive UI, the request list includes history. The extension shows a warning for invalid state IDs and preserves them. Direct recovery still works outside Git or when isolated inventory is not available. The browse action does not resume work or change resources. Direct **Close/cancel-and-release** is an explicit termination action.
281
287
 
282
288
  For a locally active unsealed isolated changeset, choose **Send follow-up instructions** to open a native multiline editor. A submission queues the instruction, not a completed revision. Choose **Edit queued instructions** to withdraw *all still-pending* instructions for that selected task before the editor opens. Their full text shows in FIFO order, with blank lines between them. An explicit submission queues **one replacement** at the end. If you cancel, the withdrawn instructions stay removed.
283
289
 
@@ -400,7 +406,7 @@ One confirmation permits permanent deletion of committed, uncommitted, and ignor
400
406
 
401
407
  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.
402
408
 
403
- Main cannot run inside a checkout selected for deletion. A session change stops further removal. Only this request's recorded `pi-subagent/subagent-<24hex>` branches and their exact primary `.worktrees/` paths can be removed. The primary checkout is never removed.
409
+ Main cannot run inside a checkout selected for deletion. A session change stops further removal. Only this request's recorded `pi-subagent/subagent-<name>-<24hex>` branches, saved legacy `pi-subagent/subagent-<24hex>` branches, and their exact primary `.worktrees/` paths can be removed. The primary checkout is never removed.
404
410
 
405
411
  To close and delete many requests, select **Select multiple…** in `/subagent`, select the requests, then select **Continue** and **Close and delete worktrees (discard work)**. The command shows the review of each request. Then one confirmation covers the unblocked resource groups of all of them. The same menu can close all owned workers of each selected direct workflow that can close. The command reports the number of selected rows that the action skipped.
406
412
 
@@ -408,7 +414,7 @@ The report lists removed, skipped, and failed resources. After complete cleanup,
408
414
 
409
415
  ### Prune leftover worktrees
410
416
 
411
- 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 starts with the number of removable and blocked items. It groups the items by the exact reason that the command can remove them, or by their exact blockers, and shows each reason once. Under each reason, it identifies each item by the first 8 of its 24 hex characters. The command can remove an item only when all of these conditions are true:
417
+ Run `/subagent prune` to clean up leftover worker and integration checkouts under `.worktrees/`. Their branches have the name `pi-subagent/subagent-<name>-<24hex>`, or the saved legacy name `pi-subagent/subagent-<24hex>`. The command also cleans up such branches that have no checkout. The review starts with the number of removable and blocked items. It groups the items by the exact reason that the command can remove them, or by their exact blockers, and shows each reason once. Under each reason, it shows the readable name, if present, and the first 8 characters of the identity suffix. The command can remove an item only when all of these conditions are true:
412
418
 
413
419
  - Its branch tip is already in Main's HEAD or in the local `origin` default-branch ref. The command does not fetch.
414
420
  - Its checkout has no tracked or untracked changes. No merge, rebase, cherry-pick, revert, or bisect is in progress.
@@ -2,6 +2,7 @@ import { getMarkdownTheme, type ExtensionAPI, type ExtensionContext, type Theme
2
2
  import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
3
3
  import type { DiscardPlan } from "../dist/discard.js";
4
4
  import type { PruneItem } from "../dist/prune.js";
5
+ import { TERMINAL_RUN_STATUSES } from "../dist/schema.js";
5
6
  import { compactCost } from "./result-transport.ts";
6
7
 
7
8
  /** Saved before launch for user matching only; never ownership or mutation authority. */
@@ -192,7 +193,7 @@ const summary = (text: string, max = 60): string => {
192
193
  };
193
194
  // Identity and status first so clipping cuts only the goal summary.
194
195
  const isolatedLabel = ({ id, status, name }: IsolatedRequest): string => `${id} · ${status} · ${summary(name)}`;
195
- const completed = (status: string) => ["completed", "success", "failed", "failure", "aborted", "integrated"].includes(status);
196
+ const completed = (status: string) => [...TERMINAL_RUN_STATUSES, "success", "failed", "failure", "integrated"].includes(status);
196
197
  const priority = (status: string) => /attention|blocked|retained|failed/i.test(status) ? 0 : completed(status) ? 2 : 1;
197
198
  // Recorded-only and unverified work is uncertain, not active.
198
199
  const statusTone = (status: string): Tone => /attention|blocked|retained|recorded only|unverified/i.test(status) ? "warning"
@@ -202,7 +203,7 @@ const glyph = (status: string) => TONES[statusTone(status)].glyph;
202
203
  const cleanupReport = ({ text, failed, skipped }: CleanupReport): Report => textReport(failed ? "error" : skipped ? "warning" : "success", text);
203
204
 
204
205
  const PRUNE_IGNORED_CONFIRM_CODE_POINTS = 4_000;
205
- const shortId = ({ branch }: PruneItem) => branch.replace(/^pi-subagent\/subagent-/, "").slice(0, 8);
206
+ const shortId = ({ branch }: PruneItem) => branch.replace(/^pi-subagent\/subagent-/, "").replace(/([0-9a-f]{8})[0-9a-f]{16}$/, "$1");
206
207
  const NOT_SHOWN = "they are not removed in this run; run /subagent prune again after this run.";
207
208
 
208
209
  /** Fit whole item lines within `max` code points; never cut silently, and report exactly which items were shown. */
@@ -246,7 +247,7 @@ function pruneReview({ root, items }: PrunePlan): { report: Report; shown: Prune
246
247
  const prunable = items.filter((item) => item.prunable).length;
247
248
  const head: Report = { tone: "info", title: "Subagent prune review", sections: [{ rows: [
248
249
  { label: "root", value: root, code: true }, { label: "prunable", value: String(prunable) }, { label: "blocked", value: String(items.length - prunable) },
249
- { label: "IDs", value: "first 8 characters after pi-subagent/subagent-" },
250
+ { label: "IDs", value: "readable name, if present, and first 8 suffix characters" },
250
251
  ] }] };
251
252
  // Each line measures its exact Markdown, so the cap never cuts a shown item.
252
253
  const reviewed = fitItems(reportMarkdown(head), rows, ({ section, index, row }) => index ? sectionMarkdown({ rows: [row] }) : `\n${sectionMarkdown({ ...section, rows: [row] })}`,
@@ -311,7 +312,8 @@ export function workingReport(direct: readonly DirectTask[], inventory: Isolated
311
312
  const active = !request.orphaned && ["pending", "running"].includes(request.status);
312
313
  if (active && inventory!.leaseLive) working.push({ text: isolatedLabel(request) });
313
314
  else if (active) attention.push({ text: `${isolatedLabel(request)} · interrupted; run /subagent recover` });
314
- else if (!request.orphaned && (request.status === "needs_attention" || /retained/.test(request.status))) attention.push({ text: isolatedLabel(request) });
315
+ else if (request.status === "needs_attention" || /retained/.test(request.status)) attention.push({ text: isolatedLabel(request) });
316
+ else if (request.orphaned && /unverified/.test(request.status)) unverified.push({ text: isolatedLabel(request) });
315
317
  }
316
318
  const sections: Section[] = [
317
319
  working.length ? { heading: `Working (${working.length})`, tone: "info", rows: working } : { rows: [{ text: inventory ? "No subagents are working." : "No direct subagents are working. Isolated status is unavailable." }] },
@@ -531,13 +533,13 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
531
533
  const group = <T extends { status: string }>(title: string, items: readonly T[], choice: (item: T) => Choice<Selection>): Choice<Selection>[] =>
532
534
  items.length ? [{ label: `── ${title} (${items.length}) ──`, value: { kind: "refresh" } },
533
535
  ...[...items].sort((a, b) => priority(a.status) - priority(b.status)).map(choice)] : [];
534
- const requests = inventory?.requests ?? [];
536
+ const requests = (inventory?.requests ?? []).filter((request) => history || !completed(request.status) || /retained/i.test(request.status));
535
537
  const isolatedChoice = (request: IsolatedRequest): Choice<Selection> => ({ label: ` ${glyph(request.status)} ${isolatedLabel(request)}`, value: { kind: "isolated", request, root: inventory!.root } });
536
538
  // One row per workflow; new records keep opaque IDs in details. Status is last so clipping preserves the reference.
537
539
  const directChoice = (task: DirectTask): Choice<Selection> => ({ label: ` ${glyph(task.status)} ${directLabel(task)} · ${task.status}${costSuffix(task.cost)}${task.workers.length || task.name === task.id ? "" : ` · ${task.id}`}`, value: { kind: "direct", task } });
538
540
  const groups = [
539
541
  group("Direct", direct.filter((task) => history || !completed(task.status)), directChoice),
540
- group("Isolated", requests.filter((request) => !request.orphaned && (history || !completed(request.status) || /retained/i.test(request.status))), isolatedChoice),
542
+ group("Isolated", requests.filter((request) => !request.orphaned), isolatedChoice),
541
543
  group("Orphaned · parent checkout removed", requests.filter((request) => request.orphaned), isolatedChoice),
542
544
  ].flat();
543
545
  const rows = groups.filter(({ value }) => value.kind === "direct" || value.kind === "isolated");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "27.9.0",
3
+ "version": "27.10.0",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",