@henryqw/pi-subagent 27.8.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.
@@ -155,9 +157,17 @@ The extension sends follow-ups when a candidate becomes ready, a worker needs at
155
157
 
156
158
  Open `/subagent` to browse direct work on this session branch and isolated requests in the current canonical Git checkout, including other sessions. The menu groups rows under **Direct**, **Isolated**, and **Orphaned** headers. Each isolated row shows the request ID, the status, and a short goal summary. **Refresh** or a header 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.
157
159
 
160
+ Before the menu opens, and after each **Refresh**, a **Current subagent work** report shows which subagents are working. The extension calculates it from local and saved evidence without a model call. Without interactive UI, `/subagent` sends this report before the request list.
161
+
162
+ A direct workflow is working only when this Pi process owns it and has a running worker. Direct work with a delivered result shows its outcome under **Completed / history** and is not in the report. Other recorded-only direct work shows as unverified. An isolated request is working only when its status is active and a live run holds the repository lease. Without a live lease, an active request shows as interrupted; run `/subagent recover`.
163
+
158
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.
159
165
 
160
- Requests whose parent checkout was removed show in the **Orphaned · parent checkout removed** group from any checkout of the same repository. Every isolated request, including an orphaned request, has **Close and delete worktrees (discard work)**. Select it to review owned resources and confirm deletion. **This permanently deletes committed, uncommitted, and ignored content in the listed checkouts.** Cancel makes no change.
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.
161
171
 
162
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.
163
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;
@@ -332,6 +333,8 @@ export declare class IsolatedRunner {
332
333
  invalidIds: string[];
333
334
  states: RunState[];
334
335
  }>;
336
+ /** True while a live productive run holds this root's lease; the lease does not name its request. */
337
+ hasProductiveRunLease(root: string): Promise<boolean>;
335
338
  /** Classify orphaned work under the same repository lease as productive execution.
336
339
  * Status remains read-only; Main alone chooses any subsequent action. */
337
340
  recoverRepository(root: string): Promise<{
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) {
@@ -639,6 +641,10 @@ export class IsolatedRunner {
639
641
  })),
640
642
  })) };
641
643
  }
644
+ /** True while a live productive run holds this root's lease; the lease does not name its request. */
645
+ async hasProductiveRunLease(root) {
646
+ return await this.store.hasProductiveRunLease(root);
647
+ }
642
648
  /** Classify orphaned work under the same repository lease as productive execution.
643
649
  * Status remains read-only; Main alone chooses any subsequent action. */
644
650
  async recoverRepository(root) {
@@ -666,7 +672,7 @@ export class IsolatedRunner {
666
672
  }
667
673
  const requests = [];
668
674
  for (const request of inventory.requests) {
669
- 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")) {
670
676
  requests.push(await this.status(request.id, root));
671
677
  }
672
678
  }
@@ -679,7 +685,7 @@ export class IsolatedRunner {
679
685
  const owners = [];
680
686
  let lease;
681
687
  const request = ({ state, path }, orphaned) => ({ id: state.request.id, name: state.request.goal,
682
- status: inventoryStatus(state), directory: dirname(path), root: state.root, orphaned });
688
+ status: inventoryStatus(state, orphaned), directory: dirname(path), root: state.root, orphaned });
683
689
  const orphans = checkouts[0] ? (await this.store.listOrphans(realpathSync.native(checkouts[0]))).states : [];
684
690
  for (const handle of orphans) {
685
691
  const { state } = handle;
@@ -1175,7 +1181,7 @@ export class IsolatedRunner {
1175
1181
  };
1176
1182
  generations.push(generation);
1177
1183
  await this.saveProductive(handle); // Before worktree add.
1178
- }, context.signal));
1184
+ }, context.signal, [action.id, `integration-${action.generation}`]));
1179
1185
  if (result.outcome !== "ready") {
1180
1186
  if (!generations.at(-1))
1181
1187
  throw new Error("Integration allocation did not persist an owned worktree plan.");
@@ -1376,7 +1382,7 @@ export class IsolatedRunner {
1376
1382
  state.integration.refresh = { from: action.expectedMain, to: action.newMain, generation: action.generation,
1377
1383
  status: "unknown", failure: "Integration allocation has not been proved; retain the recorded checkout." };
1378
1384
  await this.saveProductive(handle);
1379
- }, context.signal));
1385
+ }, context.signal, [action.id, `integration-${action.generation}`]));
1380
1386
  }
1381
1387
  catch (error) {
1382
1388
  // A prepared checkout remains recorded; no repeated allocation from this intent.
@@ -1782,7 +1788,7 @@ export class IsolatedRunner {
1782
1788
  const primary = await primaryCheckout(cwd);
1783
1789
  const { states, invalidIds } = await this.store.listOrphans(primary);
1784
1790
  return { invalidIds, requests: states.map((handle) => ({
1785
- 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),
1786
1792
  directory: dirname(handle.path), root: handle.state.root, orphaned: true, tasks: [],
1787
1793
  })) };
1788
1794
  }
@@ -2297,6 +2303,7 @@ export class IsolatedRunner {
2297
2303
  try {
2298
2304
  if (intent.kind === "worktree") {
2299
2305
  result = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.allocateWorktree({
2306
+ requestId: state.request.id,
2300
2307
  root: state.root,
2301
2308
  ...(attempt.waveBase.head !== state.main.head ? { baseRoot: state.integration.generations.at(-1)?.worktree?.path } : {}),
2302
2309
  intent,
@@ -2422,7 +2429,7 @@ export class IsolatedRunner {
2422
2429
  if (worktree.baseCommit !== wave.base.head || !sameIdentity(await inspectBase({ root: baseRoot }, context), wave.base)) {
2423
2430
  throw new Error("Staged dependency snapshot drifted before text worktree creation.");
2424
2431
  }
2425
- });
2432
+ }, [state.request.id, task.taskId, `text-${attempt.number}`]);
2426
2433
  if (!isolated)
2427
2434
  throw new Error("Explicit isolated text work requires a Git checkout with a committed HEAD; it never falls back to Main.");
2428
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
  {
@@ -269,15 +275,15 @@ After readiness, use `subagent_stage revise` with the exact candidate, generatio
269
275
 
270
276
  ### Inspect and revise active work
271
277
 
272
- 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**.
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.
@@ -842,7 +842,8 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
842
842
  catch (error) { console.error("Pi Subagent workspace widget update failed.", error); }
843
843
  }
844
844
  const orphans = await getComponents().runner.listOrphanRequests(root);
845
- return { root, requests: [...requests, ...orphans.requests], invalidIds: [...new Set([...invalidIds, ...orphans.invalidIds])] };
845
+ const leaseLive = await getComponents().runner.hasProductiveRunLease(root);
846
+ return { root, leaseLive, requests: [...requests, ...orphans.requests], invalidIds: [...new Set([...invalidIds, ...orphans.invalidIds])] };
846
847
  },
847
848
  async recover(cwd) {
848
849
  const root = await lookupRoot(cwd);
@@ -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. */
@@ -56,6 +57,8 @@ export interface IsolatedRequest {
56
57
  }
57
58
  export interface IsolatedInventory {
58
59
  root: string;
60
+ /** A live productive run holds this root's lease; the lease does not name its request. */
61
+ leaseLive: boolean;
59
62
  requests: readonly IsolatedRequest[];
60
63
  invalidIds: readonly string[];
61
64
  }
@@ -190,7 +193,7 @@ const summary = (text: string, max = 60): string => {
190
193
  };
191
194
  // Identity and status first so clipping cuts only the goal summary.
192
195
  const isolatedLabel = ({ id, status, name }: IsolatedRequest): string => `${id} · ${status} · ${summary(name)}`;
193
- 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);
194
197
  const priority = (status: string) => /attention|blocked|retained|failed/i.test(status) ? 0 : completed(status) ? 2 : 1;
195
198
  // Recorded-only and unverified work is uncertain, not active.
196
199
  const statusTone = (status: string): Tone => /attention|blocked|retained|recorded only|unverified/i.test(status) ? "warning"
@@ -200,7 +203,7 @@ const glyph = (status: string) => TONES[statusTone(status)].glyph;
200
203
  const cleanupReport = ({ text, failed, skipped }: CleanupReport): Report => textReport(failed ? "error" : skipped ? "warning" : "success", text);
201
204
 
202
205
  const PRUNE_IGNORED_CONFIRM_CODE_POINTS = 4_000;
203
- 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");
204
207
  const NOT_SHOWN = "they are not removed in this run; run /subagent prune again after this run.";
205
208
 
206
209
  /** Fit whole item lines within `max` code points; never cut silently, and report exactly which items were shown. */
@@ -244,7 +247,7 @@ function pruneReview({ root, items }: PrunePlan): { report: Report; shown: Prune
244
247
  const prunable = items.filter((item) => item.prunable).length;
245
248
  const head: Report = { tone: "info", title: "Subagent prune review", sections: [{ rows: [
246
249
  { label: "root", value: root, code: true }, { label: "prunable", value: String(prunable) }, { label: "blocked", value: String(items.length - prunable) },
247
- { label: "IDs", value: "first 8 characters after pi-subagent/subagent-" },
250
+ { label: "IDs", value: "readable name, if present, and first 8 suffix characters" },
248
251
  ] }] };
249
252
  // Each line measures its exact Markdown, so the cap never cuts a shown item.
250
253
  const reviewed = fitItems(reportMarkdown(head), rows, ({ section, index, row }) => index ? sectionMarkdown({ rows: [row] }) : `\n${sectionMarkdown({ ...section, rows: [row] })}`,
@@ -291,6 +294,42 @@ function directReport(task: DirectTask): Report {
291
294
  ] };
292
295
  }
293
296
 
297
+ const WORKING_REPORT_CODE_POINTS = 4_000;
298
+ /** Current work from local and saved evidence only. Recorded-only direct work and isolated work without a live lease never count as working. */
299
+ export function workingReport(direct: readonly DirectTask[], inventory: IsolatedInventory | undefined): Report {
300
+ const working: Row[] = [];
301
+ const attention: Row[] = [];
302
+ const unverified: Row[] = [];
303
+ for (const task of direct) {
304
+ const text = `${directLabel(task)} · ${task.status}`;
305
+ // Read workflows list their running workers; write workflows stay observed locally until their workers are proved stopped.
306
+ if (task.status === "observed locally" && task.stoppable?.length !== 0) working.push({ text: task.stoppable ? `${directLabel(task)} · ${task.stoppable.length} running` : text });
307
+ else if (/retained|attention/.test(task.status)) attention.push({ text });
308
+ else if (completed(task.status)) continue;
309
+ else unverified.push({ text: task.status === "observed locally" ? `${text}; no running worker` : text });
310
+ }
311
+ for (const request of inventory?.requests ?? []) {
312
+ const active = !request.orphaned && ["pending", "running"].includes(request.status);
313
+ if (active && inventory!.leaseLive) working.push({ text: isolatedLabel(request) });
314
+ else if (active) attention.push({ text: `${isolatedLabel(request)} · interrupted; run /subagent recover` });
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) });
317
+ }
318
+ const sections: Section[] = [
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." }] },
320
+ { heading: `Needs attention (${attention.length})`, tone: "warning", rows: attention },
321
+ { heading: `Unverified (${unverified.length})`, note: "not proof of running or completion", tone: "warning", rows: unverified },
322
+ ];
323
+ const head: Report = { tone: attention.length ? "warning" : "info", title: "Current subagent work", sections: [] };
324
+ const items = sections.flatMap((section) => section.rows.map((row, index) => ({ section, index, row })));
325
+ const fitted = fitItems(reportMarkdown(head), items, ({ section, index, row }) => index ? sectionMarkdown({ rows: [row] }) : `\n${sectionMarkdown({ ...section, rows: [row] })}`,
326
+ WORKING_REPORT_CODE_POINTS, "open /subagent with interactive UI to see them.");
327
+ return { ...head, sections: [...sections.flatMap((section) => {
328
+ const rows = fitted.shown.filter((item) => item.section === section).map(({ row }) => row);
329
+ return rows.length ? [{ ...section, rows }] : [];
330
+ }), ...moreSection(fitted.more)] };
331
+ }
332
+
294
333
  type Choice<T> = { label: string; value: T };
295
334
  async function choose<T>(ctx: ExtensionContext, title: string, items: readonly Choice<T>[]): Promise<T | undefined> {
296
335
  // Numeric suffixes only tell duplicate labels apart. Never parse display text into an identity.
@@ -462,6 +501,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
462
501
  }
463
502
  if (!ctx.hasUI) {
464
503
  const inventory = await adapter.isolated(ctx.cwd, current);
504
+ deliver(ctx, workingReport(adapter.direct(ctx), inventory));
465
505
  const head: Report = { tone: "info", title: "Isolated requests (report only)", sections: [] };
466
506
  const row = (request: IsolatedRequest): Row => ({ text: `${glyph(request.status)} ${request.orphaned ? "orphaned · " : ""}${isolatedLabel(request)}` });
467
507
  const listed = fitItems(`${reportMarkdown(head)}\n`, inventory.requests, (request) => sectionMarkdown({ rows: [row(request)] }), ROLES_REPORT_CODE_POINTS,
@@ -473,6 +513,8 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
473
513
  return;
474
514
  }
475
515
  let history = false;
516
+ // The working report shows on open and on each Refresh, not after other menu actions.
517
+ let report = true;
476
518
  for (;;) {
477
519
  if (!valid()) return;
478
520
  // Direct recovery is independent of Git and isolated config/inventory failures.
@@ -482,6 +524,8 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
482
524
  try { inventory = await adapter.isolated(ctx.cwd, current); }
483
525
  catch (error) { unavailable = errorText(error); }
484
526
  if (!valid()) return;
527
+ if (report) deliver(ctx, workingReport(direct, inventory));
528
+ report = false;
485
529
  if (unavailable) ctx.ui.notify(`Isolated inventory unavailable (${unavailable}). Direct branch recovery remains available; check the canonical Git checkout/configuration.`, "warning");
486
530
  if (inventory?.invalidIds.length) for (const id of inventory.invalidIds) ctx.ui.notify(`Unreadable isolated state ID: ${JSON.stringify(id)}. Preserve this file; inspect the state store before making changes.`, "warning");
487
531
  type Selection = { kind: "direct"; task: DirectTask } | { kind: "isolated"; request: IsolatedRequest; root: string } | { kind: "refresh" } | { kind: "history" } | { kind: "many" } | { kind: "close" };
@@ -489,13 +533,13 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
489
533
  const group = <T extends { status: string }>(title: string, items: readonly T[], choice: (item: T) => Choice<Selection>): Choice<Selection>[] =>
490
534
  items.length ? [{ label: `── ${title} (${items.length}) ──`, value: { kind: "refresh" } },
491
535
  ...[...items].sort((a, b) => priority(a.status) - priority(b.status)).map(choice)] : [];
492
- const requests = inventory?.requests ?? [];
536
+ const requests = (inventory?.requests ?? []).filter((request) => history || !completed(request.status) || /retained/i.test(request.status));
493
537
  const isolatedChoice = (request: IsolatedRequest): Choice<Selection> => ({ label: ` ${glyph(request.status)} ${isolatedLabel(request)}`, value: { kind: "isolated", request, root: inventory!.root } });
494
538
  // One row per workflow; new records keep opaque IDs in details. Status is last so clipping preserves the reference.
495
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 } });
496
540
  const groups = [
497
541
  group("Direct", direct.filter((task) => history || !completed(task.status)), directChoice),
498
- group("Isolated", requests.filter((request) => !request.orphaned && (history || !completed(request.status) || /retained/i.test(request.status))), isolatedChoice),
542
+ group("Isolated", requests.filter((request) => !request.orphaned), isolatedChoice),
499
543
  group("Orphaned · parent checkout removed", requests.filter((request) => request.orphaned), isolatedChoice),
500
544
  ].flat();
501
545
  const rows = groups.filter(({ value }) => value.kind === "direct" || value.kind === "isolated");
@@ -509,7 +553,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
509
553
  if (!unavailable && !direct.length && !inventory?.requests.length && !inventory?.invalidIds.length) ctx.ui.notify("No direct tasks recorded on this session branch or isolated requests in this checkout. Ask Main to start delegation.", "info");
510
554
  const selected = await choose(ctx, history ? "Subagent · completed / history" : "Subagent · current work", selections);
511
555
  if (!valid() || !selected || selected.kind === "close") return;
512
- if (selected.kind === "refresh") continue;
556
+ if (selected.kind === "refresh") { report = true; continue; }
513
557
  if (selected.kind === "history") { history = !history; continue; }
514
558
  if (selected.kind === "many") {
515
559
  const picked = await chooseMany(ctx, "Subagent · select work", rows.map(({ label, value }) => ({ label: label.trim(), value })), [], valid);
@@ -300,7 +300,10 @@ export default function subagentExtension(pi: ExtensionAPI): void {
300
300
  const local = directTasks.get(record.taskId);
301
301
  let task = grouped.get(record.taskId);
302
302
  if (!task) grouped.set(record.taskId, task = { id: record.taskId, name: record.taskId,
303
- status: local?.recovery ? "admission retained" : local ? "observed locally" : "recorded only; not proof of running or completion",
303
+ status: local?.recovery ? "admission retained" : local ? "observed locally"
304
+ // A recovery result did not prove worker termination, so it stays visible for recovery.
305
+ : results.get(record.taskId)?.recovery ? "needs attention: worker termination unproved"
306
+ : results.get(record.taskId)?.outcome ?? "recorded only; not proof of running or completion",
304
307
  ...(results.get(record.taskId)?.cost === undefined ? {} : { cost: results.get(record.taskId)!.cost }),
305
308
  ...(local?.recovery ? { recovery: local.recovery } : {}), canClose: Boolean(local?.close),
306
309
  ...(local?.stopWorker ? { stoppable: local.stopWorker.running() } : {}), workers: [], tabs: [] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "27.8.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",