@ferris1225/pi-subagents 4.1.13 → 4.1.16

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,26 +1,24 @@
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";
12
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
11
  import { Text } from "@earendil-works/pi-tui";
14
- import { resolve } from "node:path";
12
+ import { join, resolve } from "node:path";
15
13
  import { Type } from "typebox";
16
- import { discoverAgents, resolveAgentTools, type AgentConfig } from "./agents.ts";
17
- import { getStateRoot } from "./durable.ts";
14
+ import { discoverAgents, isWriteCapableAgent, resolveAgentTools, type AgentConfig } from "./agents.ts";
18
15
  import { loadConfig } from "./config.ts";
19
16
  import { formatUsage, queuedResult } from "./format.ts";
20
17
  import {
21
- buildFinalDocumenterBrief,
22
18
  buildFinalReviewBrief,
23
- documentationDisposition,
19
+ buildReReviewBrief,
20
+ buildReviewerFixBrief,
21
+ MAX_REVIEW_FIX_ROUNDS,
24
22
  type ChainStep,
25
23
  type ManagedWorkflowOutcome,
26
24
  } from "./workflow.ts";
@@ -36,6 +34,7 @@ import {
36
34
  import type { SubagentRuntime } from "./runtime.ts";
37
35
  import { persistThreadCheckpoint } from "./thread-lifecycle.ts";
38
36
  import {
37
+ getProjectRoot,
39
38
  getResultOutput,
40
39
  isFailedResult,
41
40
  reviewVerdict,
@@ -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,22 +82,48 @@ 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
- export function defaultIsolationMode(mode: "single" | "parallel", agentName: string, requested?: IsolationMode): IsolationMode {
87
+ /** Roles that default to worktree isolation in parallel dispatches even when
88
+ * the live catalog cannot be consulted (render-only call sites). Custom
89
+ * write-capable agents join them via isWriteCapableAgent on the execute path. */
90
+ const WORKTREE_DEFAULT_AGENTS = new Set(["worker", "cleaner", "documenter"]);
91
+
92
+ /** Resolve the default isolation for a dispatch. Parallel write-capable agents
93
+ * get a detached worktree: shared writers serialize on the repository lane, so
94
+ * defaulting them to shared would turn one parallel batch into a convoy that
95
+ * also parks process slots. Explicit requests always win. */
96
+ export function defaultIsolationMode(
97
+ mode: "single" | "parallel",
98
+ agentName: string,
99
+ requested?: IsolationMode,
100
+ writeCapable = WORKTREE_DEFAULT_AGENTS.has(agentName),
101
+ ): IsolationMode {
96
102
  if (requested) return requested;
97
- return mode === "parallel" && agentName === "worker" ? "worktree" : "shared";
103
+ return mode === "parallel" && writeCapable ? "worktree" : "shared";
98
104
  }
99
105
 
100
- function workflowStageStatus(result: SingleResult): WorkflowStageStatus {
106
+ function workflowStageStatus(result: SingleResult, relation?: string): WorkflowStageStatus {
101
107
  if (isFailedResult(result)) return "failed";
108
+ if (relation === "review fix") return "done";
102
109
  if (result.agent !== "reviewer") return "done";
103
110
  const verdict = reviewVerdict(getResultOutput(result));
104
111
  if (verdict === "fail") return "changes";
105
112
  return verdict === "pass" ? "done" : "failed";
106
113
  }
107
114
 
115
+ /** The runtime-granted write continuation of a failed gate: same reviewer
116
+ * role, model, and retained session, but the read-only boundary is lifted for
117
+ * this one stage so it applies its own fix instructions. One line only: the
118
+ * full fix-stage contract is already in the retained session's prompt. */
119
+ function withReviewerFixStageAgent(agent: AgentConfig): AgentConfig {
120
+ return {
121
+ ...agent,
122
+ tools: undefined,
123
+ 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 specified, verify, and report what changed; never edit during a review and never emit a verdict here.`,
124
+ };
125
+ }
126
+
108
127
  export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
109
128
  // Latest dispatch environment. The dispatcher is created once per process so
110
129
  // restored threads can resume before any dispatch has run; each execute
@@ -183,25 +202,43 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
183
202
  (mode: "single" | "parallel", background = false) =>
184
203
  (results: SingleResult[]): SubagentDetails => ({ mode, results, background });
185
204
 
205
+ /** Pacing note appended to dispatch confirmations whenever runs are actually
206
+ * waiting: states the real slot capacity so ordinary queueing is never
207
+ * mistaken for a hard dispatch limit. Empty when everything is running. */
208
+ const queuePacingNote = (): string => {
209
+ const runs = monitor.getRuns();
210
+ const waiting = runs.filter((run) => run.status === "queued").length;
211
+ if (waiting === 0) return "";
212
+ const running = runs.filter((run) => run.status === "running" || run.status === "interrupting").length;
213
+ return ` Pacing: ${running} running · ${waiting} waiting for a free process slot (capacity ${runtime.backgroundQueue.capacity}); waiting runs start automatically as slots free — keep dispatching independent units.`;
214
+ };
215
+
186
216
  /** Launch one workflow-internal child in a fresh model context. It sees the
187
217
  * parent's exact repository/worktree state and is registered by its own id,
188
- * but never enters top-level lifecycle policy or completion delivery. */
218
+ * but never enters top-level lifecycle policy or completion delivery.
219
+ * `stage` continues a retained session (the reviewer fix stage) and/or
220
+ * replaces the resolved agent (lifting the reviewer read-only boundary). */
189
221
  const launchInWorkflow = async (
190
222
  request: ManagedWorkflowRequest,
191
223
  agentName: string,
192
224
  task: string,
193
225
  meta: RunChainMeta,
226
+ stage: {
227
+ agentOverride?: AgentConfig;
228
+ session?: { sessionId: string; sessionDir: string };
229
+ } = {},
194
230
  ): Promise<{ runId: number; result: SingleResult }> => {
195
231
  const discoveredAgent = request.agents.find((candidate) => candidate.name === agentName);
196
232
  if (!discoveredAgent) {
197
233
  throw new Error(`Managed workflow requires enabled agent "${agentName}", but discovery did not provide it.`);
198
234
  }
235
+ const boundaryAgent = stage.agentOverride ?? discoveredAgent;
199
236
  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.
237
+ resolveAgentTools({ ...candidate, tools: boundaryAgent.tools }, runtime.getActiveTools());
238
+ const agent = resolveLiveAgentTools(boundaryAgent);
239
+ // Workflow policy (agents) stays fixed for the chain, but model/thinking
240
+ // routes are re-read per stage so config edits apply to stages that have
241
+ // not launched yet.
205
242
  const stageConfig = await loadConfig(runtime.configPath).catch(() => request.config);
206
243
  const resolvedRoute = resolveDispatchModelRoute(agent, stageConfig, request.ctx);
207
244
  const route = request.isolation === "worktree"
@@ -229,7 +266,10 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
229
266
  onLive,
230
267
  makeDetails: makeDetails("single", true),
231
268
  idleTimeoutMs: stageConfig.idleTimeoutSec * 1000,
232
- sessionRoot: getStateRoot(runtime.configPath),
269
+ sessionRoot: join(getProjectRoot(runtime.configPath, request.executionCwd), "sessions"),
270
+ ...(stage.session
271
+ ? { sessionId: stage.session.sessionId, sessionDir: stage.session.sessionDir, stdinText: task }
272
+ : {}),
233
273
  },
234
274
  route.mainFallbackRef,
235
275
  );
@@ -303,62 +343,37 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
303
343
  status: workflowStageStatus(initialStepResult),
304
344
  }];
305
345
  let reviewStage: WorkflowStage | undefined;
306
- if (request.plan.kind === "post-writer" && enabled("reviewer")) {
346
+ if (enabled("reviewer")) {
307
347
  reviewStage = { agent: "reviewer", relation: "review", status: "pending" };
308
348
  workflowStages.push(reviewStage);
309
349
  }
310
- let documentationStage: WorkflowStage | undefined;
311
- if (enabled("documenter")) {
312
- documentationStage = { agent: "documenter", relation: "docs", status: "pending" };
313
- workflowStages.push(documentationStage);
314
- }
315
350
  const publishWorkflowStages = (): void => {
316
351
  monitor.setWorkflowStages(request.parentRunId, workflowStages);
317
352
  };
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
353
  publishWorkflowStages();
333
354
 
334
355
  const launchStep = async (
335
356
  agentName: string,
336
357
  task: string,
337
358
  relation: string,
338
- projection: {
339
- stage?: WorkflowStage;
340
- timelineRelation?: string;
341
- childRelation?: string;
359
+ stage: WorkflowStage,
360
+ stageOptions: {
361
+ agentOverride?: AgentConfig;
362
+ session?: { sessionId: string; sessionDir: string };
342
363
  } = {},
343
364
  ): Promise<SingleResult> => {
344
365
  if (!enabled(agentName)) {
345
366
  throw new Error(`Managed workflow cannot launch disabled or missing agent "${agentName}".`);
346
367
  }
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
368
  stage.status = "active";
354
369
  publishWorkflowStages();
355
370
  try {
356
371
  const step = await launchInWorkflow(request, agentName, task, {
357
372
  groupId: request.groupId,
358
- relationLabel: projection.childRelation ?? relation,
373
+ relationLabel: relation,
359
374
  parentRunId: request.parentRunId,
360
- });
361
- stage.status = workflowStageStatus(step.result);
375
+ }, stageOptions);
376
+ stage.status = workflowStageStatus(step.result, relation);
362
377
  publishWorkflowStages();
363
378
  request.rememberLatest(step.result);
364
379
  steps.push({ ...step, relation });
@@ -370,68 +385,51 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
370
385
  }
371
386
  };
372
387
 
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
388
  try {
411
389
  // Park/stop/shutdown may win after the top-level child settles but
412
390
  // before this continuation starts. Preserve that stable checkpoint and
413
391
  // 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(
392
+ if (!canContinue()) return { steps };
393
+ if (reviewStage) {
394
+ const discoveredReviewer = request.agents.find((candidate) => candidate.name === "reviewer")!;
395
+ let gateReview = await launchStep(
424
396
  "reviewer",
425
- buildFinalReviewBrief(initialStepResult, { documenterPending: enabled("documenter") }),
397
+ buildFinalReviewBrief(initialStepResult),
426
398
  "final review",
427
- { stage: reviewStage },
399
+ reviewStage,
428
400
  );
429
- await runFinalDocumentation(initialStepResult, gateReview);
430
- } else {
431
- // No gate configured: the documenter is the only downstream stage.
432
- await runFinalDocumentation(initialStepResult, undefined);
401
+ // The failing gate owns its fixes: the same retained
402
+ // reviewer session applies its own fix instructions with write access,
403
+ // then a converging re-review verifies the fixes. The cap only stops
404
+ // pathological burn and hands the still-failing gate to the main agent.
405
+ for (let round = 1; round <= MAX_REVIEW_FIX_ROUNDS; round++) {
406
+ const gateSession = gateReview.sessionId && gateReview.sessionDir
407
+ ? { sessionId: gateReview.sessionId, sessionDir: gateReview.sessionDir }
408
+ : undefined;
409
+ if (reviewVerdict(getResultOutput(gateReview)) !== "fail" || !gateSession || !canContinue()) break;
410
+ const fixStage: WorkflowStage = { agent: "reviewer", relation: "fix", status: "pending" };
411
+ workflowStages.push(fixStage);
412
+ publishWorkflowStages();
413
+ const fixResult = await launchStep(
414
+ "reviewer",
415
+ buildReviewerFixBrief(getResultOutput(gateReview)),
416
+ "review fix",
417
+ fixStage,
418
+ { agentOverride: withReviewerFixStageAgent(discoveredReviewer), session: gateSession },
419
+ );
420
+ if (isFailedResult(fixResult) || !canContinue()) break;
421
+ const reReviewStage: WorkflowStage = { agent: "reviewer", relation: "review", status: "pending" };
422
+ workflowStages.push(reReviewStage);
423
+ publishWorkflowStages();
424
+ gateReview = await launchStep(
425
+ "reviewer",
426
+ buildReReviewBrief(fixResult, round),
427
+ round === 1 ? "re-review" : `re-review ${round}`,
428
+ reReviewStage,
429
+ );
430
+ }
433
431
  }
434
- return { kind: request.plan.kind, steps };
432
+ return { steps };
435
433
  } finally {
436
434
  removeWorkflowGroup(request.groupId);
437
435
  }
@@ -456,15 +454,14 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
456
454
  name: "subagent",
457
455
  label: "Subagent",
458
456
  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.",
457
+ "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.",
458
+ "Put every genuinely independent unit in one `tasks` array (no per-call cap). Process slots scale with the machine; when all slots are busy the extra runs simply wait and start automatically as slots free waiting is pacing, never a rejection or a limit on how much you may dispatch.",
459
+ "Every parallel write-capable agent (worker, cleaner, documenter, custom writers) defaults to a detached Git worktree, so writers run concurrently; shared mode serializes same-repository writers. Explicit `shared` keeps the caller's checkout; setup failure never silently falls back to shared.",
460
+ "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 in bounded, converging rounds. 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.",
461
+ "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
462
  ].join(" "),
466
463
  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.",
464
+ "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
465
  parameters: SubagentParams,
469
466
 
470
467
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -523,21 +520,27 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
523
520
  };
524
521
  }
525
522
 
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.
523
+ // Sub-agents run detached from the foreground turn: the editor stays
524
+ // available and completion messages later wake the main agent. The turn is
525
+ // NOT terminated here the model can keep dispatching independent units
526
+ // or do its own work, and the background queue paces how many child
527
+ // processes actually run at once, so no per-call task cap is enforced.
530
528
  if (params.tasks && params.tasks.length > 0) {
531
529
  const results: SingleResult[] = [];
532
530
  // Preserve caller order (and deterministic completion batching) while
533
531
  // preparing each isolated filesystem before its queue entry can start.
534
532
  for (const item of params.tasks) {
533
+ const catalogAgent = agents.find((candidate) => candidate.name === item.agent);
535
534
  results.push(await startBackground(
536
535
  item.agent,
537
536
  item.task,
538
537
  item.cwd,
539
- defaultIsolationMode("parallel", item.agent, item.isolation as IsolationMode | undefined),
540
- { advisoryReview: item.advisory === true },
538
+ defaultIsolationMode(
539
+ "parallel",
540
+ item.agent,
541
+ item.isolation as IsolationMode | undefined,
542
+ catalogAgent ? isWriteCapableAgent(catalogAgent) : undefined,
543
+ ),
541
544
  ));
542
545
  }
543
546
  const startedRuns = results.filter((result) => result.exitCode === -1);
@@ -558,15 +561,14 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
558
561
  throw new Error(`No background subagents were started.\n${failureLines.join("\n")}`);
559
562
  }
560
563
  const text = [
561
- `Started ${started} background subagent${started === 1 ? "" : "s"}: ${startedRefs.join(", ")}. Results will automatically resume the main agent when ready.`,
564
+ `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
565
  ...(failureLines.length > 0
563
566
  ? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
564
567
  : []),
565
- ].join("\n");
568
+ ].join("\n") + queuePacingNote();
566
569
  return {
567
570
  content: [{ type: "text", text }],
568
571
  details: makeDetails("parallel", true)(results),
569
- terminate: true,
570
572
  };
571
573
  }
572
574
 
@@ -575,16 +577,14 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
575
577
  params.task as string,
576
578
  params.cwd,
577
579
  defaultIsolationMode("single", params.agent as string, params.isolation as IsolationMode | undefined),
578
- { advisoryReview: params.advisory === true },
579
580
  );
580
581
  if (result.exitCode !== -1) {
581
582
  throw new Error(getResultOutput(result));
582
583
  }
583
584
  const runRef = result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`;
584
585
  return {
585
- content: [{ type: "text", text: `Started ${runRef} in the background. Its result will automatically resume the main agent when ready.` }],
586
+ 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.${queuePacingNote()}` }],
586
587
  details: makeDetails("single", true)([result]),
587
- terminate: true,
588
588
  };
589
589
 
590
590
  },
package/src/durable.ts CHANGED
@@ -1,23 +1,26 @@
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";
14
- import { existsSync } from "node:fs";
16
+ import { existsSync, type Dirent, readdirSync, statSync } from "node:fs";
15
17
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
16
18
  import { dirname, join } from "node:path";
17
19
  import type { UsageStats } from "./rpc-run.ts";
18
20
  import type { SubagentThread } from "./runtime.ts";
19
- import { getResultOutput, isFailedResult, type SingleResult } from "./spawn.ts";
21
+ import { getResultOutput, isFailedResult, getProjectRoot, PROJECT_ROOTS_DIR_NAME, type SingleResult } from "./spawn.ts";
20
22
  import {
23
+ isPathInside,
21
24
  restoreWorktreeIsolation,
22
25
  type IsolationMode,
23
26
  normalizeWorktreeSnapshot,
@@ -27,11 +30,16 @@ import {
27
30
 
28
31
  export const THREADS_MANIFEST_FILE_NAME = "pi-subagents-threads.json";
29
32
  const THREADS_MANIFEST_VERSION = 1;
30
- 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
+ /** Project directories whose newest file has not been touched for this long
35
+ * are deleted wholesale on load, so per-project sessions/worktrees/results
36
+ * can never accumulate forever. Parked threads' manifest references always
37
+ * win over the age rule. */
38
+ export const PROJECT_ROOT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1_000;
39
+
40
+ /** Fixed retention: parked work (which may hold unintegrated changes) stops
41
+ * being resumable after a month. Older manifests may still carry settled
42
+ * records from previous versions; restore discards them on sight. */
35
43
  export const PARKED_RECORD_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
36
44
 
37
45
  /** Result excerpts are for status display after restore, not full transcripts. */
@@ -74,10 +82,6 @@ interface ThreadsManifest {
74
82
  records: ThreadRecord[];
75
83
  }
76
84
 
77
- export function getStateRoot(configPath: string): string {
78
- return join(dirname(configPath), STATE_DIR_NAME);
79
- }
80
-
81
85
  export function getThreadsManifestPath(configPath: string): string {
82
86
  return join(dirname(configPath), THREADS_MANIFEST_FILE_NAME);
83
87
  }
@@ -297,7 +301,7 @@ async function discardRecordArtifacts(record: ThreadRecord): Promise<void> {
297
301
  }
298
302
 
299
303
  /** Drop records past their retention age along with their artifacts. Runs at
300
- * extension load; the fixed ages honor the no-config-knobs policy. */
304
+ * extension load; the fixed age honors the no-config-knobs policy. */
301
305
  export async function pruneThreadRecords(
302
306
  configPath: string,
303
307
  now = Date.now(),
@@ -309,8 +313,7 @@ export async function pruneThreadRecords(
309
313
  let changed = false;
310
314
  const kept: ThreadRecord[] = [];
311
315
  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) {
316
+ if (now - record.updatedAt <= PARKED_RECORD_MAX_AGE_MS) {
314
317
  kept.push(record);
315
318
  continue;
316
319
  }
@@ -334,3 +337,66 @@ export function referencedDurablePaths(records: readonly ThreadRecord[]): Set<st
334
337
  }
335
338
  return paths;
336
339
  }
340
+
341
+ /** Newest modification time anywhere under root (directories count via their
342
+ * own entries); undefined when root cannot be read. */
343
+ function newestMtimeMs(root: string, now: number = Date.now()): number | undefined {
344
+ let newest: number | undefined;
345
+ const stack: string[] = [root];
346
+ while (stack.length > 0) {
347
+ const dir = stack.pop()!;
348
+ let entries: Dirent[];
349
+ try {
350
+ entries = readdirSync(dir, { withFileTypes: true });
351
+ } catch {
352
+ continue;
353
+ }
354
+ for (const entry of entries) {
355
+ const path = join(dir, entry.name);
356
+ let mtime: number;
357
+ try {
358
+ mtime = statSync(path).mtimeMs;
359
+ } catch {
360
+ continue;
361
+ }
362
+ if (mtime > 0 && mtime <= now && (newest === undefined || mtime > newest)) newest = mtime;
363
+ if (entry.isDirectory() && !entry.isSymbolicLink()) stack.push(path);
364
+ }
365
+ }
366
+ return newest;
367
+ }
368
+
369
+ /** Delete project directories under the ferris-pi-subagents root that have
370
+ * been idle past PROJECT_ROOT_MAX_AGE_MS. A directory containing any path the
371
+ * threads manifest still references is never touched, so parked work outlives
372
+ * the age rule. Returns the removed directory names. */
373
+ export async function pruneStaleProjectRoots(configPath: string, options: { now?: number } = {}): Promise<string[]> {
374
+ const now = options.now ?? Date.now();
375
+ const records = await readThreadRecords(configPath).catch(() => [] as ThreadRecord[]);
376
+ const referenced = referencedDurablePaths(records);
377
+ const root = join(dirname(configPath), PROJECT_ROOTS_DIR_NAME);
378
+ let projects: Dirent[];
379
+ try {
380
+ projects = readdirSync(root, { withFileTypes: true });
381
+ } catch {
382
+ return [];
383
+ }
384
+ const removed: string[] = [];
385
+ for (const project of projects) {
386
+ if (!project.isDirectory() || project.isSymbolicLink()) continue;
387
+ const projectDir = join(root, project.name);
388
+ if (containsReferencedPath(projectDir, referenced)) continue;
389
+ const newest = newestMtimeMs(projectDir, now);
390
+ if (newest === undefined || now - newest <= PROJECT_ROOT_MAX_AGE_MS) continue;
391
+ await rm(projectDir, { recursive: true, force: true }).catch(() => undefined);
392
+ if (!existsSync(projectDir)) removed.push(project.name);
393
+ }
394
+ return removed;
395
+ }
396
+
397
+ function containsReferencedPath(projectDir: string, referenced: ReadonlySet<string>): boolean {
398
+ for (const path of referenced) {
399
+ if (isPathInside(projectDir, path)) return true;
400
+ }
401
+ return false;
402
+ }