@ferris1225/pi-subagents 4.1.13 → 4.1.15

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/src/dispatch.ts CHANGED
@@ -1,11 +1,9 @@
1
1
  /**
2
2
  * The `subagent` tool: dispatches explorer/worker/cleaner/documenter/reviewer agents as isolated pi
3
3
  * child processes, single or parallel. Owns the public dispatch contract,
4
- * per-run status tracking, managed worker/cleaner → reviewer workflows with a
5
- * conditional documenter, bounded worker/reviewer fix rounds, and internal
6
- * step launching. Stable
7
- * thread generations, final integration, and completion ownership live in
8
- * thread-lifecycle.ts.
4
+ * per-run status tracking, the managed worker/cleaner → reviewer gate, and
5
+ * internal step launching. Stable thread generations, final integration, and
6
+ * completion ownership live in thread-lifecycle.ts.
9
7
  */
10
8
 
11
9
  import { StringEnum } from "@earendil-works/pi-ai";
@@ -18,9 +16,10 @@ import { getStateRoot } from "./durable.ts";
18
16
  import { loadConfig } from "./config.ts";
19
17
  import { formatUsage, queuedResult } from "./format.ts";
20
18
  import {
21
- buildFinalDocumenterBrief,
22
19
  buildFinalReviewBrief,
23
- documentationDisposition,
20
+ buildReReviewBrief,
21
+ buildReviewerFixBrief,
22
+ MAX_REVIEW_FIX_ROUNDS,
24
23
  type ChainStep,
25
24
  type ManagedWorkflowOutcome,
26
25
  } from "./workflow.ts";
@@ -65,11 +64,6 @@ const IsolationSchema = Type.Optional(
65
64
  StringEnum(["shared", "worktree"] as const, { description: ISOLATION_DESCRIPTION }),
66
65
  );
67
66
 
68
- const ADVISORY_DESCRIPTION =
69
- "Report-only reviewer dispatch: findings return to you for the decision; a verdict (if emitted anyway) never starts the auto-fix chain. Use when re-verifying work you already fixed yourself.";
70
-
71
- const AdvisorySchema = Type.Optional(Type.Boolean({ description: ADVISORY_DESCRIPTION }));
72
-
73
67
  const TaskItem = Type.Object({
74
68
  agent: Type.String({ description: "Name of the agent to invoke" }),
75
69
  task: Type.String({
@@ -78,7 +72,6 @@ const TaskItem = Type.Object({
78
72
  }),
79
73
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
80
74
  isolation: IsolationSchema,
81
- advisory: AdvisorySchema,
82
75
  });
83
76
 
84
77
  const SubagentParams = Type.Object({
@@ -89,7 +82,6 @@ const SubagentParams = Type.Object({
89
82
  tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
90
83
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
91
84
  isolation: IsolationSchema,
92
- advisory: AdvisorySchema,
93
85
  });
94
86
 
95
87
  export function defaultIsolationMode(mode: "single" | "parallel", agentName: string, requested?: IsolationMode): IsolationMode {
@@ -97,14 +89,26 @@ export function defaultIsolationMode(mode: "single" | "parallel", agentName: str
97
89
  return mode === "parallel" && agentName === "worker" ? "worktree" : "shared";
98
90
  }
99
91
 
100
- function workflowStageStatus(result: SingleResult): WorkflowStageStatus {
92
+ function workflowStageStatus(result: SingleResult, relation?: string): WorkflowStageStatus {
101
93
  if (isFailedResult(result)) return "failed";
94
+ if (relation === "review fix") return "done";
102
95
  if (result.agent !== "reviewer") return "done";
103
96
  const verdict = reviewVerdict(getResultOutput(result));
104
97
  if (verdict === "fail") return "changes";
105
98
  return verdict === "pass" ? "done" : "failed";
106
99
  }
107
100
 
101
+ /** The runtime-granted write continuation of a failed gate: same reviewer
102
+ * role, model, and retained session, but the read-only boundary is lifted for
103
+ * this one stage so it applies its own fix instructions. */
104
+ function withReviewerFixStageAgent(agent: AgentConfig): AgentConfig {
105
+ return {
106
+ ...agent,
107
+ tools: undefined,
108
+ systemPrompt: `${agent.systemPrompt.trimEnd()}\n\nRuntime workflow context: FIX STAGE — your gate just returned REVIEW_FAIL. Your read-only boundary is lifted for this stage only: apply your own fix instructions exactly as you specified them, run the narrowest decisive checks, and report what changed. Never edit during a review and never emit a verdict in a fix stage.`,
109
+ };
110
+ }
111
+
108
112
  export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
109
113
  // Latest dispatch environment. The dispatcher is created once per process so
110
114
  // restored threads can resume before any dispatch has run; each execute
@@ -185,23 +189,30 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
185
189
 
186
190
  /** Launch one workflow-internal child in a fresh model context. It sees the
187
191
  * parent's exact repository/worktree state and is registered by its own id,
188
- * but never enters top-level lifecycle policy or completion delivery. */
192
+ * but never enters top-level lifecycle policy or completion delivery.
193
+ * `stage` continues a retained session (the reviewer fix stage) and/or
194
+ * replaces the resolved agent (lifting the reviewer read-only boundary). */
189
195
  const launchInWorkflow = async (
190
196
  request: ManagedWorkflowRequest,
191
197
  agentName: string,
192
198
  task: string,
193
199
  meta: RunChainMeta,
200
+ stage: {
201
+ agentOverride?: AgentConfig;
202
+ session?: { sessionId: string; sessionDir: string };
203
+ } = {},
194
204
  ): Promise<{ runId: number; result: SingleResult }> => {
195
205
  const discoveredAgent = request.agents.find((candidate) => candidate.name === agentName);
196
206
  if (!discoveredAgent) {
197
207
  throw new Error(`Managed workflow requires enabled agent "${agentName}", but discovery did not provide it.`);
198
208
  }
209
+ const boundaryAgent = stage.agentOverride ?? discoveredAgent;
199
210
  const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
200
- resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
201
- const agent = resolveLiveAgentTools(discoveredAgent);
202
- // Workflow policy (fix-round caps, agents) stays fixed for the chain,
203
- // but model/thinking routes are re-read per stage so config edits
204
- // apply to stages that have not launched yet.
211
+ resolveAgentTools({ ...candidate, tools: boundaryAgent.tools }, runtime.getActiveTools());
212
+ const agent = resolveLiveAgentTools(boundaryAgent);
213
+ // Workflow policy (agents) stays fixed for the chain, but model/thinking
214
+ // routes are re-read per stage so config edits apply to stages that have
215
+ // not launched yet.
205
216
  const stageConfig = await loadConfig(runtime.configPath).catch(() => request.config);
206
217
  const resolvedRoute = resolveDispatchModelRoute(agent, stageConfig, request.ctx);
207
218
  const route = request.isolation === "worktree"
@@ -230,6 +241,9 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
230
241
  makeDetails: makeDetails("single", true),
231
242
  idleTimeoutMs: stageConfig.idleTimeoutSec * 1000,
232
243
  sessionRoot: getStateRoot(runtime.configPath),
244
+ ...(stage.session
245
+ ? { sessionId: stage.session.sessionId, sessionDir: stage.session.sessionDir, stdinText: task }
246
+ : {}),
233
247
  },
234
248
  route.mainFallbackRef,
235
249
  );
@@ -303,62 +317,37 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
303
317
  status: workflowStageStatus(initialStepResult),
304
318
  }];
305
319
  let reviewStage: WorkflowStage | undefined;
306
- if (request.plan.kind === "post-writer" && enabled("reviewer")) {
320
+ if (enabled("reviewer")) {
307
321
  reviewStage = { agent: "reviewer", relation: "review", status: "pending" };
308
322
  workflowStages.push(reviewStage);
309
323
  }
310
- let documentationStage: WorkflowStage | undefined;
311
- if (enabled("documenter")) {
312
- documentationStage = { agent: "documenter", relation: "docs", status: "pending" };
313
- workflowStages.push(documentationStage);
314
- }
315
324
  const publishWorkflowStages = (): void => {
316
325
  monitor.setWorkflowStages(request.parentRunId, workflowStages);
317
326
  };
318
- const insertBeforeDocumentation = (stage: WorkflowStage): void => {
319
- const documentationIndex = documentationStage
320
- ? workflowStages.indexOf(documentationStage)
321
- : -1;
322
- if (documentationIndex === -1) workflowStages.push(stage);
323
- else workflowStages.splice(documentationIndex, 0, stage);
324
- };
325
- const removeDocumentationStage = (): void => {
326
- if (!documentationStage) return;
327
- const index = workflowStages.indexOf(documentationStage);
328
- if (index !== -1) workflowStages.splice(index, 1);
329
- documentationStage = undefined;
330
- publishWorkflowStages();
331
- };
332
327
  publishWorkflowStages();
333
328
 
334
329
  const launchStep = async (
335
330
  agentName: string,
336
331
  task: string,
337
332
  relation: string,
338
- projection: {
339
- stage?: WorkflowStage;
340
- timelineRelation?: string;
341
- childRelation?: string;
333
+ stage: WorkflowStage,
334
+ stageOptions: {
335
+ agentOverride?: AgentConfig;
336
+ session?: { sessionId: string; sessionDir: string };
342
337
  } = {},
343
338
  ): Promise<SingleResult> => {
344
339
  if (!enabled(agentName)) {
345
340
  throw new Error(`Managed workflow cannot launch disabled or missing agent "${agentName}".`);
346
341
  }
347
- const stage: WorkflowStage = projection.stage ?? {
348
- agent: agentName,
349
- relation: projection.timelineRelation ?? relation,
350
- status: "pending",
351
- };
352
- if (!projection.stage) insertBeforeDocumentation(stage);
353
342
  stage.status = "active";
354
343
  publishWorkflowStages();
355
344
  try {
356
345
  const step = await launchInWorkflow(request, agentName, task, {
357
346
  groupId: request.groupId,
358
- relationLabel: projection.childRelation ?? relation,
347
+ relationLabel: relation,
359
348
  parentRunId: request.parentRunId,
360
- });
361
- stage.status = workflowStageStatus(step.result);
349
+ }, stageOptions);
350
+ stage.status = workflowStageStatus(step.result, relation);
362
351
  publishWorkflowStages();
363
352
  request.rememberLatest(step.result);
364
353
  steps.push({ ...step, relation });
@@ -370,68 +359,52 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
370
359
  }
371
360
  };
372
361
 
373
- /** Run the low-cost final documentation sync only when the terminal REVIEW_PASS
374
- * reports drift or omits the marker. A failed process, missing verdict, or
375
- * REVIEW_FAIL never writes docs. With no reviewer, retain the conservative
376
- * writer → documenter fallback. */
377
- const runFinalDocumentation = async (
378
- lastWriterResult: SingleResult | undefined,
379
- finalReviewResult: SingleResult | undefined,
380
- ): Promise<void> => {
381
- if (!canContinue() || !enabled("documenter")) return;
382
- if (lastWriterResult?.agent === "documenter") {
383
- removeDocumentationStage();
384
- return;
385
- }
386
- if (finalReviewResult) {
387
- if (isFailedResult(finalReviewResult)) {
388
- removeDocumentationStage();
389
- return;
390
- }
391
- const reviewOutput = getResultOutput(finalReviewResult);
392
- if (
393
- reviewVerdict(reviewOutput) !== "pass" ||
394
- documentationDisposition(reviewOutput) === "clean"
395
- ) {
396
- removeDocumentationStage();
397
- return;
398
- }
399
- }
400
- documentationStage ??= { agent: "documenter", relation: "docs", status: "pending" };
401
- if (!workflowStages.includes(documentationStage)) workflowStages.push(documentationStage);
402
- await launchStep(
403
- "documenter",
404
- buildFinalDocumenterBrief(lastWriterResult, finalReviewResult),
405
- "final documentation sync",
406
- { stage: documentationStage },
407
- );
408
- };
409
-
410
362
  try {
411
363
  // Park/stop/shutdown may win after the top-level child settles but
412
364
  // before this continuation starts. Preserve that stable checkpoint and
413
365
  // never create an already-aborted downstream child.
414
- if (!canContinue()) return { kind: request.plan.kind, steps };
415
- if (request.plan.kind === "review-pass-sync") {
416
- // The direct passing review already gated the pending code. Its
417
- // disposition requested (or conservatively defaulted to) one docs sync.
418
- await runFinalDocumentation(undefined, initialStepResult);
419
- } else if (enabled("reviewer")) {
420
- // A failing gate is not a workflow continuation: the REVIEW_FAIL
421
- // report and its fix instructions are delivered to the main agent,
422
- // which owns the fix decision.
423
- const gateReview = await launchStep(
366
+ if (!canContinue()) return { steps };
367
+ if (reviewStage) {
368
+ const discoveredReviewer = request.agents.find((candidate) => candidate.name === "reviewer")!;
369
+ let gateReview = await launchStep(
424
370
  "reviewer",
425
- buildFinalReviewBrief(initialStepResult, { documenterPending: enabled("documenter") }),
371
+ buildFinalReviewBrief(initialStepResult),
426
372
  "final review",
427
- { stage: reviewStage },
373
+ reviewStage,
428
374
  );
429
- await runFinalDocumentation(initialStepResult, gateReview);
430
- } else {
431
- // No gate configured: the documenter is the only downstream stage.
432
- await runFinalDocumentation(initialStepResult, undefined);
375
+ // The failing gate owns its fixes until it passes: the same retained
376
+ // reviewer session applies its own fix instructions with write access,
377
+ // then a fresh gate re-scans the complete diff. Nobody outside the
378
+ // loop has to guess what satisfies the gate; the cap only stops
379
+ // pathological burn and hands the still-failing gate to the main agent.
380
+ for (let round = 1; round <= MAX_REVIEW_FIX_ROUNDS; round++) {
381
+ const gateSession = gateReview.sessionId && gateReview.sessionDir
382
+ ? { sessionId: gateReview.sessionId, sessionDir: gateReview.sessionDir }
383
+ : undefined;
384
+ if (reviewVerdict(getResultOutput(gateReview)) !== "fail" || !gateSession || !canContinue()) break;
385
+ const fixStage: WorkflowStage = { agent: "reviewer", relation: "fix", status: "pending" };
386
+ workflowStages.push(fixStage);
387
+ publishWorkflowStages();
388
+ const fixResult = await launchStep(
389
+ "reviewer",
390
+ buildReviewerFixBrief(getResultOutput(gateReview)),
391
+ "review fix",
392
+ fixStage,
393
+ { agentOverride: withReviewerFixStageAgent(discoveredReviewer), session: gateSession },
394
+ );
395
+ if (isFailedResult(fixResult) || !canContinue()) break;
396
+ const reReviewStage: WorkflowStage = { agent: "reviewer", relation: "review", status: "pending" };
397
+ workflowStages.push(reReviewStage);
398
+ publishWorkflowStages();
399
+ gateReview = await launchStep(
400
+ "reviewer",
401
+ buildReReviewBrief(fixResult, round),
402
+ round === 1 ? "re-review" : `re-review ${round}`,
403
+ reReviewStage,
404
+ );
405
+ }
433
406
  }
434
- return { kind: request.plan.kind, steps };
407
+ return { steps };
435
408
  } finally {
436
409
  removeWorkflowGroup(request.groupId);
437
410
  }
@@ -456,15 +429,13 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
456
429
  name: "subagent",
457
430
  label: "Subagent",
458
431
  description: [
459
- "Dispatch enabled specialized agents as isolated leaf Pi child processes, singly or in parallel; fan-out breadth is yoursextra tasks queue for the next free process slot.",
460
- "Built-ins: explorer for broad read-only reconnaissance (a retrieval index, never a gate); worker for implementation; cleaner as a separate explicitly authorized cleanup/removal/simplification/deduplication entry; documenter for explicit docs/comments work or conditional final diff sync; reviewer for generic read-only assessments and independent code gates.",
461
- "Work starts in the background. Successful worker/cleaner runs keep one enabled reviewer gate; a REVIEW_FAIL is delivered to you with fix instructions — resolve the findings yourself (fix inline or dispatch a briefed worker) without waiting for the user; only a genuinely destructive or scope-changing fix is worth asking about. Documenter runs afterward only when REVIEW_PASS reports DOCUMENTATION: NEEDED or omits the marker, with a reviewer-disabled fallback. A top-level documenter delivers directly. Results resume the main agent and are already shown, so do not poll, duplicate downstream roles, or restate them.",
462
- "Single tasks default to shared; parallel workers default to detached Git worktrees. Only write-capable agents can use worktree isolation, and failures never fall back silently to shared.",
463
- "A selected-model or provider failure continues the retained session on the current main model; ordinary tool/task failures do not.",
464
- "Use subagent_control to resume a parked or settled thread's retained context by stable run id; use subagent_stop for destructive cancellation.",
432
+ "Dispatch enabled agents as isolated leaf Pi child processes, singly or in parallel. Dispatching never blocks your turn runs proceed in the background and each completion resumes you automatically; never poll or restate delivered results.",
433
+ "Put every genuinely independent unit in one `tasks` array (no per-call cap; extras queue for a free process slot). Single tasks share the checkout; parallel workers default to detached Git worktrees write-capable agents only, and setup failure never silently falls back to shared.",
434
+ "Successful worker/cleaner runs get one automatic reviewer gate; a failing gate is fixed by the reviewer itself in a write-enabled continuation of the same session and re-reviewed until it passes. A REVIEW_FAIL from a gate you dispatched directly returns its findings to you fix them inline or via a briefed worker without waiting for the user.",
435
+ "A configured child-model failure continues the retained session on the current main model. Resume a parked or settled thread with subagent_control by run id; use subagent_stop for destructive cancellation.",
465
436
  ].join(" "),
466
437
  promptSnippet:
467
- "Dispatch isolated background agents for broad recon, self-contained implementation, authorized cleanup, explicit docs, or independent review; keep trivial work on direct tools. Worker/cleaner gates and only needed/conservative docs sync run automatically, REVIEW_FAIL findings return to you, results resume automatically, and each workflow delivers once.",
438
+ "Dispatch isolated background agents for recon, implementation, cleanup, docs, or review; never blocks your turn, and REVIEW_FAIL findings return to you to fix.",
468
439
  parameters: SubagentParams,
469
440
 
470
441
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -523,10 +494,11 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
523
494
  };
524
495
  }
525
496
 
526
- // Sub-agents intentionally detach from the foreground turn. This makes the
527
- // editor available immediately; completion messages later wake the main agent.
528
- // Fan-out breadth is the model's call; the background queue paces how many
529
- // child processes actually run at once, so no per-call task cap is enforced.
497
+ // Sub-agents run detached from the foreground turn: the editor stays
498
+ // available and completion messages later wake the main agent. The turn is
499
+ // NOT terminated here the model can keep dispatching independent units
500
+ // or do its own work, and the background queue paces how many child
501
+ // processes actually run at once, so no per-call task cap is enforced.
530
502
  if (params.tasks && params.tasks.length > 0) {
531
503
  const results: SingleResult[] = [];
532
504
  // Preserve caller order (and deterministic completion batching) while
@@ -537,7 +509,6 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
537
509
  item.task,
538
510
  item.cwd,
539
511
  defaultIsolationMode("parallel", item.agent, item.isolation as IsolationMode | undefined),
540
- { advisoryReview: item.advisory === true },
541
512
  ));
542
513
  }
543
514
  const startedRuns = results.filter((result) => result.exitCode === -1);
@@ -558,7 +529,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
558
529
  throw new Error(`No background subagents were started.\n${failureLines.join("\n")}`);
559
530
  }
560
531
  const text = [
561
- `Started ${started} background subagent${started === 1 ? "" : "s"}: ${startedRefs.join(", ")}. Results will automatically resume the main agent when ready.`,
532
+ `Started ${started} background subagent${started === 1 ? "" : "s"}: ${startedRefs.join(", ")}. They run in the background and never block you — dispatch more independent units now or keep working; each result resumes you automatically when you are idle.`,
562
533
  ...(failureLines.length > 0
563
534
  ? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
564
535
  : []),
@@ -566,7 +537,6 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
566
537
  return {
567
538
  content: [{ type: "text", text }],
568
539
  details: makeDetails("parallel", true)(results),
569
- terminate: true,
570
540
  };
571
541
  }
572
542
 
@@ -575,16 +545,14 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
575
545
  params.task as string,
576
546
  params.cwd,
577
547
  defaultIsolationMode("single", params.agent as string, params.isolation as IsolationMode | undefined),
578
- { advisoryReview: params.advisory === true },
579
548
  );
580
549
  if (result.exitCode !== -1) {
581
550
  throw new Error(getResultOutput(result));
582
551
  }
583
552
  const runRef = result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`;
584
553
  return {
585
- content: [{ type: "text", text: `Started ${runRef} in the background. Its result will automatically resume the main agent when ready.` }],
554
+ content: [{ type: "text", text: `Started ${runRef} in the background. It never blocks you dispatch more independent units now or keep working; its result resumes you automatically when you are idle.` }],
586
555
  details: makeDetails("single", true)([result]),
587
- terminate: true,
588
556
  };
589
557
 
590
558
  },
package/src/durable.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  /**
2
- * Durable thread state: a manifest next to the config that lets parked and
3
- * settled sub-agent threads survive pi reloads and restarts, plus the durable
2
+ * Durable thread state: a manifest next to the config that lets interrupted
3
+ * (parked) sub-agent threads survive pi reloads and restarts, plus the durable
4
4
  * state root that keeps their retained sessions and isolated worktrees out of
5
5
  * the OS temp directory.
6
6
  *
7
- * Records are small path/state snapshots, never full transcripts; the retained
8
- * Pi session files and worktrees they point at remain the actual context.
9
- * Writes are atomic (tmp+rename) and serialized through the same
10
- * withFileMutationQueue as the recovery manifest.
7
+ * Only parked threads are ever recorded: a thread that settles normally drops
8
+ * its record, so the manifest file exists exactly while unfinished work needs
9
+ * it and disappears on its own. Records are small path/state snapshots, never
10
+ * full transcripts; the retained Pi session files and worktrees they point at
11
+ * remain the actual context. Writes are atomic (tmp+rename) and serialized
12
+ * through the same withFileMutationQueue as the recovery manifest.
11
13
  */
12
14
 
13
15
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
@@ -29,9 +31,9 @@ export const THREADS_MANIFEST_FILE_NAME = "pi-subagents-threads.json";
29
31
  const THREADS_MANIFEST_VERSION = 1;
30
32
  export const STATE_DIR_NAME = "pi-subagents-state";
31
33
 
32
- /** Fixed retention: settled results stop being resumable after a week,
33
- * parked work (which may hold unintegrated changes) after a month. */
34
- export const SETTLED_RECORD_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
34
+ /** Fixed retention: parked work (which may hold unintegrated changes) stops
35
+ * being resumable after a month. Older manifests may still carry settled
36
+ * records from previous versions; restore discards them on sight. */
35
37
  export const PARKED_RECORD_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
36
38
 
37
39
  /** Result excerpts are for status display after restore, not full transcripts. */
@@ -297,7 +299,7 @@ async function discardRecordArtifacts(record: ThreadRecord): Promise<void> {
297
299
  }
298
300
 
299
301
  /** Drop records past their retention age along with their artifacts. Runs at
300
- * extension load; the fixed ages honor the no-config-knobs policy. */
302
+ * extension load; the fixed age honors the no-config-knobs policy. */
301
303
  export async function pruneThreadRecords(
302
304
  configPath: string,
303
305
  now = Date.now(),
@@ -309,8 +311,7 @@ export async function pruneThreadRecords(
309
311
  let changed = false;
310
312
  const kept: ThreadRecord[] = [];
311
313
  for (const record of records) {
312
- const maxAge = record.state === "parked" ? PARKED_RECORD_MAX_AGE_MS : SETTLED_RECORD_MAX_AGE_MS;
313
- if (now - record.updatedAt <= maxAge) {
314
+ if (now - record.updatedAt <= PARKED_RECORD_MAX_AGE_MS) {
314
315
  kept.push(record);
315
316
  continue;
316
317
  }
package/src/format.ts CHANGED
@@ -156,6 +156,14 @@ export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: nu
156
156
  return `The sub-agent could not complete this task: ${cause}${retry}.${recovery}`;
157
157
  }
158
158
 
159
+ /** Instruction appended whenever a failing gate verdict is delivered: the
160
+ * findings return to the main agent, which owns the fix decision — the runtime
161
+ * never auto-fixes. Stating it at this exact decision point keeps the main
162
+ * agent from relaying the findings to the user and stopping. */
163
+ export function reviewFailFollowUpNote(): string {
164
+ return "This gate failed and the findings are yours to resolve now: fix them inline or dispatch a worker briefed with these fix instructions, then re-verify the change. Ask the user only before a genuinely destructive or scope-changing fix; do not deliver while a finding stands.";
165
+ }
166
+
159
167
  /** Resolve a run-id request to actual ids: an exact numeric match always wins
160
168
  * (so "1" never fans out to 10, 11, …); only when no exact match exists does a
161
169
  * prefix match run, as a convenience for partial ids. Keeps single-digit lookups
package/src/prompt.ts CHANGED
@@ -40,53 +40,40 @@ export function buildDelegationDirective(
40
40
  : `${namedWorktreeTargets.slice(0, -1).join(", ")}, ${namedWorktreeTargets.at(-1)}, or another`;
41
41
 
42
42
  const dispatchRules = [
43
- "Route substantive work to sub-agents so your context stays lean for orchestration; inline only trivial work — a one-shot lookup, a single focused read/edit, or an answer already in context.",
43
+ `Delegate aggressively: child contexts are cheap, yours is scarce. Inline only trivial work — a one-shot lookup, a single focused edit, an answer already in context${hasWorker ? "; default every non-trivial implementation, fix, refactor, or test task to `worker`" : ""}.`,
44
44
  ...(hasExplorer
45
45
  ? [
46
- "Use `explorer` for any broad or multi-file search; it is a retrieval index, never a gate — its findings are leads, not proof. Before acting on them yourself editing inline, deleting, or deciding something load-bearing re-read the files involved; a child you brief re-verifies them in its own context.",
46
+ "`explorer`: broad or multi-file search. Its findings are leads, never proof — re-read load-bearing files before acting yourself (a child you brief re-verifies). Split a broad question into several parallel explorers with disjoint scopes.",
47
47
  ]
48
48
  : []),
49
- ...(hasWorker
50
- ? ["Use `worker` for a self-contained implementation, fix, refactor, or test whose separate context pays for itself."]
51
- : []),
52
49
  ...(hasCleaner
53
- ? [
54
- `Use \`cleaner\` only for user-authorized cleanup or deduplication; it applies every safe proven cut without item-by-item approval, and never runs as a pre-commit gate or by PR count.`,
55
- ]
50
+ ? ["`cleaner`: only user-authorized cleanup or dedup; it applies every safe proven cut without per-item approval and is never a gate."]
56
51
  : []),
57
52
  ...(hasDocumenter
58
- ? [
59
- `Use \`documenter\` directly only for explicit standalone documentation work; a top-level documenter delivers without a gate.${codeWriterNames.length > 0 ? ` The runtime runs the final docs sync after REVIEW_PASS with DOCUMENTATION: NEEDED or a missing marker, or as the reviewer-disabled fallback; writers sync docs they directly affect — never dispatch a duplicate.` : ""}`,
60
- ]
53
+ ? ["`documenter`: standalone docs/comment work, or syncing real drift a change left — writers already sync what they directly affect."]
61
54
  : []),
62
55
  ...(hasReviewer
63
56
  ? [
64
- `Use \`reviewer\` for read-only assessments or gates.${codeWriterNames.length > 0 ? ` Successful ${codeWriterNames.join("/")} runs already get one fresh gate and then deliver once.` : ""} Advisory output has no VERDICT and cannot authorize edits; dispatch your own re-verification with \`advisory: true\`.`,
57
+ `\`reviewer\`: read-only assessments and gates${codeWriterNames.length > 0 ? `; successful ${codeWriterNames.join("/")} runs get one fresh gate, and failing gates are fixed by the reviewer itself and re-reviewed until they pass` : ""}. Advisory output has no VERDICT and cannot authorize edits.`,
65
58
  ]
66
59
  : []),
67
- "You own the fan-out breadth: every genuinely independent unit goes in one `tasks` array — there is no per-call cap, and extra tasks queue for the next free process slot. One child owns one coherent deliverable and its files; no two children share a file or re-answer the same question; dependent work starts only after its prerequisite delivers.",
68
- "Brief each child with the complete goal, exact paths, constraints, and expected output; it has no conversation memory and cannot delegate. Continue a parked/settled thread with `subagent_control resume`.",
69
- `Single tasks share the checkout${hasWorker ? "; parallel workers default to detached Git worktrees" : ""}. Request \`isolation: "worktree"\` only for ${worktreeTargets} write-capable agent in a Git repo with committed HEAD; setup failure never silently falls back to shared.`,
70
- "A configured child model/provider failure automatically continues the same retained session on the current main model; do not redispatch. Ordinary tool/task failures stay on the selected model.",
60
+ `Parallelize by default: map the todo list onto ONE \`tasks\` dispatch. One child owns one deliverable and its files; only genuinely dependent work waits for its prerequisite.`,
61
+ "Brief each child completely goal, exact paths, constraints, expected output; it has no conversation memory and cannot delegate. Resume parked threads with `subagent_control resume`.",
62
+ `Request \`isolation: "worktree"\` only for ${worktreeTargets} write-capable agent in a repo with committed HEAD.`,
71
63
  ];
72
64
 
73
65
  const handoffRules = [
74
- "Dispatch ends this turn; each completion resumes the main agent automatically never sleep, poll, or call `subagent_wait` to hold the turn.",
66
+ "Dispatch never blocks or ends your turn — keep working; each completion resumes you automatically. Never sleep, poll, or `subagent_wait` to hold the turn.",
75
67
  "Results are already shown; add only your conclusion or next action, never a restatement.",
76
- "Before declaring the overall task done, use `subagent_status` to confirm that no runs remain active.",
68
+ "Before declaring the overall task done, `subagent_status` must show no active runs.",
77
69
  ];
78
70
 
79
71
  const verificationRules = [
80
- "Never report an unrun check as passed; surface unavailable checks and pre-existing failures honestly, and inspect actual changes before reporting completion.",
72
+ "Never report an unrun check as passed; surface unavailable checks and pre-existing failures, and inspect actual changes before reporting completion.",
81
73
  ...(hasReviewer
82
74
  ? [
83
- ...(hasDocumenter
84
- ? [
85
- "A direct REVIEW_PASS with DOCUMENTATION: CLEAN delivers immediately; NEEDED or a missing marker runs one documentation sync.",
86
- ]
87
- : []),
88
- "A REVIEW_FAIL — direct or from a managed gate — returns the findings to you: resolve them yourself, inline or via a worker you brief, without waiting for the user; the runtime never auto-fixes. Ask only for genuinely destructive or scope-changing fixes. Advisory reports cannot trigger writes.",
89
- "Use multi-model cross-review only when explicitly requested or for genuinely high-risk security, unsafe/FFI, persistence-migration, or concurrency changes.",
75
+ "A REVIEW_FAIL from a gate you dispatched directly returns its findings to you: fix them inline or via a briefed worker without waiting for the user (ask only for genuinely destructive or scope-changing fixes), then re-verify.",
76
+ "Multi-model cross-review only when explicitly requested or for high-risk security, unsafe/FFI, persistence-migration, or concurrency changes.",
90
77
  ]
91
78
  : []),
92
79
  "Commit or push only when explicitly requested, applicable checks pass, and no review finding remains unresolved.",
@@ -95,7 +82,7 @@ export function buildDelegationDirective(
95
82
  return `
96
83
  ## Sub-agent delegation (pi-subagents)
97
84
 
98
- The \`subagent\` tool runs isolated leaf Pi child processes and context windows; each completion automatically resumes the main agent.
85
+ \`subagent\` runs isolated leaf Pi child processes in the background; each completion resumes you.
99
86
 
100
87
  Agents:
101
88
  ${catalog}
package/src/runtime.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  type CompletionMessageItem,
21
21
  } from "./completion.ts";
22
22
  import { type ThinkingLevel } from "./config.ts";
23
- import { threadRecordFromThread, upsertThreadRecord, type ThreadRecord } from "./durable.ts";
23
+ import { removeThreadRecord, threadRecordFromThread, upsertThreadRecord, type ThreadRecord } from "./durable.ts";
24
24
  import { isRunActiveStatus, monitor } from "./monitor.ts";
25
25
  import type { RpcRunControl } from "./rpc-run.ts";
26
26
  import type { StartBackgroundInternal } from "./thread-lifecycle.ts";
@@ -50,8 +50,6 @@ export interface SubagentThread {
50
50
  executionCwd: string;
51
51
  thinkingLevel?: ThinkingLevel;
52
52
  isolation: IsolationMode;
53
- /** Report-only reviewer dispatch: verdicts never chain into a managed workflow. */
54
- advisoryReview: boolean;
55
53
  worktree?: WorktreeIsolation;
56
54
  state: ThreadState;
57
55
  control: RpcRunControl;
@@ -242,10 +240,14 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
242
240
  Promise.allSettled(preflights),
243
241
  runtime.backgroundQueue.waitForIdle(),
244
242
  ]);
245
- // Persist one record per non-retired thread, then keep exactly the
246
- // artifacts those records reference. A thread whose settlement finished
247
- // during the wait above already wrote its own terminal record; the
248
- // lastResult-derived state below matches it.
243
+ // Only interrupted (parked) threads stay resumable across reloads:
244
+ // each keeps its durable record and retained artifacts. Settled
245
+ // threads drop their record the manifest exists only while
246
+ // unfinished work needs it — and their sessions are deleted now. A
247
+ // thread whose settlement finished during the wait above already
248
+ // wrote (or removed) its own record; the lastResult-derived state
249
+ // below matches it.
250
+ const settledIds: number[] = [];
249
251
  const records: ThreadRecord[] = [];
250
252
  for (const thread of runtime.threads.values()) {
251
253
  if (thread.retired) continue;
@@ -258,11 +260,13 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
258
260
  } else {
259
261
  state = "parked";
260
262
  }
261
- records.push(threadRecordFromThread(thread, state));
263
+ if (state === "parked") records.push(threadRecordFromThread(thread, state));
264
+ else settledIds.push(thread.id);
262
265
  }
263
- await Promise.all(
264
- records.map((record) => upsertThreadRecord(runtime.configPath, record).catch(() => undefined)),
265
- );
266
+ await Promise.all([
267
+ ...records.map((record) => upsertThreadRecord(runtime.configPath, record).catch(() => undefined)),
268
+ ...settledIds.map((runId) => removeThreadRecord(runtime.configPath, runId).catch(() => undefined)),
269
+ ]);
266
270
  // Retained-failure recovery records are persisted by the finalization
267
271
  // itself; shutdown only drops sessions no record claims anymore.
268
272
  const referenced = new Set(
package/src/setup.ts CHANGED
@@ -307,9 +307,9 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
307
307
  next.agentThinkingLevels.cleaner = config.agentThinkingLevels.reviewer;
308
308
  }
309
309
  }
310
- // Documenter intentionally follows the faster explorer route. Fresh
311
- // installs leave it unselected; enabling it later inherits any explorer
312
- // overrides instead of silently choosing a stronger model.
310
+ // Documenter intentionally follows the faster explorer route. When it
311
+ // is re-enabled after being explicitly disabled, it inherits any
312
+ // explorer overrides instead of silently choosing a stronger model.
313
313
  if (!config.enabledAgents.includes("documenter") && enabled.includes("documenter")) {
314
314
  if (!next.agentModels.documenter && config.agentModels.explorer) {
315
315
  next.agentModels.documenter = config.agentModels.explorer;