@ferris1225/pi-subagents 4.1.2 → 4.1.4

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/fixloop.ts CHANGED
@@ -1,17 +1,19 @@
1
1
  /**
2
2
  * Managed workflow policy and handoff formatting.
3
3
  *
4
- * Successful top-level writers can continue through documentation sync and an
5
- * independent final review. A direct passing reviewer is also forced through
6
- * documentation sync plus a fresh review when documenter is enabled. Any final
7
- * gate failure may then use the established worker optional documenter
8
- * reviewer fix rounds. Internal steps are launched by dispatch directly, so
9
- * they never re-enter this top-level policy or wake the main agent mid-chain.
4
+ * Successful top-level writers continue through an independent code review
5
+ * gate; bounded worker reviewer fix rounds close its findings; one final
6
+ * documentation sync runs after the gate settles, so code fixes never
7
+ * invalidate an earlier docs pass and reviewers stay focused on code. A direct
8
+ * passing reviewer gets that same single final documentation sync instead of a
9
+ * second gate; a direct failing reviewer uses the same fix rounds plus the
10
+ * final sync. Internal steps are launched by dispatch directly, so they never
11
+ * re-enter this top-level policy or wake the main agent mid-chain.
10
12
  */
11
13
 
12
14
  import { isWriteCapableAgent, type AgentConfig } from "./agents.ts";
13
15
  import { getResultOutput, isFailedResult, reviewVerdict, type SingleResult } from "./spawn.ts";
14
- import { extractKeyFragments, formatUsageCompact, sumUsage } from "./monitor.ts";
16
+ import { formatUsageCompact, sumUsage } from "./monitor.ts";
15
17
  import type { SubagentsConfig } from "./config.ts";
16
18
 
17
19
  /**
@@ -107,7 +109,9 @@ export function getManagedWorkflowPlan(
107
109
  if (result.agent !== "reviewer") return undefined;
108
110
 
109
111
  const verdict = reviewVerdict(getResultOutput(result));
110
- if (verdict === "pass" && availability.documenter && availability.reviewer) {
112
+ // The pass stands as the code gate; documenter then syncs docs once and the
113
+ // workflow delivers without a second gate.
114
+ if (verdict === "pass" && availability.documenter) {
111
115
  return { kind: "review-pass-sync", initialRelation: "pre-documentation review" };
112
116
  }
113
117
  if (verdict === "fail" && availability.worker && shouldTriggerFixLoop(result, config)) {
@@ -135,77 +139,56 @@ export function buildFixTaskBrief(reviewerResult: SingleResult, round: number, m
135
139
  `Fix EVERY finding in the reviewer's findings list — there is no severity triage; all of them get fixed.`,
136
140
  `If a finding is factually wrong or clearly out of scope, say so explicitly instead of fixing it.`,
137
141
  `Do NOT refactor unrelated code beyond what the findings require.`,
138
- `Do NOT commit, push, publish, tag, or release; do not bump versions. The parent chain still owns documentation sync and final review.`,
142
+ `Do NOT commit, push, publish, tag, or release; do not bump versions. The parent chain still owns re-review and the final documentation sync.`,
139
143
  `After editing, run the project's format/build/tests when they exist and report`,
140
144
  `exactly what you changed (paths + short rationale) so a reviewer can verify.`,
141
145
  remaining > 0
142
146
  ? `A reviewer will re-review your changes automatically after you finish.`
143
- : `This is the last auto-fix round; optional documentation sync and a fresh reviewer still run before final delivery.`,
147
+ : `This is the last auto-fix round; the workflow runs any enabled final documentation sync and then delivers.`,
144
148
  ].join("\n");
145
149
  }
146
150
 
147
- interface DocumentationBriefOptions {
148
- title: string;
149
- reports: Array<{ label: string; result: SingleResult }>;
150
- closing: string;
151
- }
152
-
153
- function buildDocumentationBrief(options: DocumentationBriefOptions): string {
154
- const reportSections = options.reports.flatMap(({ label, result }) => [
155
- `${label}:`,
156
- `---`,
157
- getResultOutput(result),
158
- `---`,
159
- ``,
160
- ]);
151
+ /** Build the single documentation handoff that runs after the review gate
152
+ * settles. The last writer's report (top-level writer or final fix-round
153
+ * worker) and the terminal gate review are leads; the pending diff stays
154
+ * authoritative. At most one of the two is undefined in every managed flow. */
155
+ export function buildFinalDocumenterBrief(
156
+ lastWriterResult?: SingleResult,
157
+ finalReviewResult?: SingleResult,
158
+ ): string {
159
+ const reportSections = [
160
+ ...(lastWriterResult
161
+ ? [
162
+ `The last writer (${lastWriterResult.agent}) reported:`,
163
+ `---`,
164
+ getResultOutput(lastWriterResult),
165
+ `---`,
166
+ ``,
167
+ ]
168
+ : []),
169
+ ...(finalReviewResult
170
+ ? [
171
+ `The final gate review reported:`,
172
+ `---`,
173
+ getResultOutput(finalReviewResult),
174
+ `---`,
175
+ ``,
176
+ ]
177
+ : []),
178
+ ];
161
179
  return [
162
- options.title,
180
+ `Final documentation sync: the review gate settled and you are the last managed stage before delivery.`,
163
181
  ``,
164
182
  ...reportSections,
165
- `Inspect the actual git diff (the complete pending diff) and relevant implementation; the report is only a lead.`,
166
- `Synchronize stale README/docs, examples, API comments, docstrings, and explanatory comments with the behavior that will be committed.`,
183
+ `Inspect the actual git diff (the complete pending diff) and relevant implementation; the reports are only leads.`,
184
+ `Apply every documentation note the reviews recorded, then synchronize stale README/docs, examples, API comments, docstrings, and explanatory comments with the behavior that will be committed.`,
167
185
  `Change documentation surfaces only; never alter runtime behavior or tests to make prose true.`,
168
186
  `Make zero edits when the diff creates no documentation drift.`,
169
- `Do NOT commit, push, publish, tag, or release; do not bump versions. ${options.closing}`,
187
+ `Do NOT commit, push, publish, tag, or release; do not bump versions. The parent workflow delivers directly after you; no fresh reviewer runs.`,
170
188
  `Report exact documentation/comment paths changed, or state explicitly that no sync was needed.`,
171
189
  ].join("\n");
172
190
  }
173
191
 
174
- /** Build the pre-commit documentation handoff after one auto-fix worker. */
175
- export function buildDocumenterTaskBrief(
176
- workerResult: SingleResult,
177
- round: number,
178
- reviewerResult?: SingleResult,
179
- ): string {
180
- return buildDocumentationBrief({
181
- title: `Documentation sync after auto-fix round ${round}.`,
182
- reports: [
183
- ...(reviewerResult ? [{ label: "The triggering reviewer reported", result: reviewerResult }] : []),
184
- { label: "The worker reported", result: workerResult },
185
- ],
186
- closing: "a fresh reviewer gate runs after you.",
187
- });
188
- }
189
-
190
- /** Build the automatic documentation stage after a successful top-level writer. */
191
- export function buildPostWriterDocumenterBrief(writerResult: SingleResult): string {
192
- return buildDocumentationBrief({
193
- title: `Documentation sync after successful top-level ${writerResult.agent}.`,
194
- reports: [{ label: `The ${writerResult.agent} reported`, result: writerResult }],
195
- closing: "the managed workflow owns any final reviewer and delivery.",
196
- });
197
- }
198
-
199
- /** A direct passing review cannot be the final gate while documenter is enabled:
200
- * the preliminary report is context, but the actual pending diff is authoritative. */
201
- export function buildReviewPassDocumenterBrief(reviewerResult: SingleResult): string {
202
- return buildDocumentationBrief({
203
- title: "Documentation sync required before accepting a direct passing review.",
204
- reports: [{ label: "The preliminary reviewer reported", result: reviewerResult }],
205
- closing: "the preliminary pass is not final and a fresh reviewer gate runs after you.",
206
- });
207
- }
208
-
209
192
  /**
210
193
  * One step of an auto-fix chain as delivered: the run id (so the condensed
211
194
  * summary can point at per-run detail via subagent_status), the result, and
@@ -224,16 +207,6 @@ export interface ManagedWorkflowOutcome {
224
207
  steps: ChainStep[];
225
208
  }
226
209
 
227
- /** Max distinguishing fragments kept in a one-line chain summary. */
228
- export const CHAIN_SUMMARY_FRAGMENTS_MAX = 3;
229
-
230
- /** The most telling fragments (paths, quoted phrases, symbols) of a run's final
231
- * output: for a worker these are the paths it changed, for a reviewer the
232
- * issues it found. Capped so summaries stay one line. */
233
- export function chainKeyFragments(result: SingleResult): string[] {
234
- return extractKeyFragments(getResultOutput(result)).slice(0, CHAIN_SUMMARY_FRAGMENTS_MAX);
235
- }
236
-
237
210
  function workflowResultStatus(result: SingleResult): string {
238
211
  if (isFailedResult(result)) return "failed";
239
212
  if (result.agent === "reviewer") {
@@ -244,15 +217,8 @@ function workflowResultStatus(result: SingleResult): string {
244
217
  }
245
218
 
246
219
  function workflowStepLine(step: ChainStep): string {
247
- const fragments = chainKeyFragments(step.result);
248
- const writer = step.result.agent === "worker" || step.result.agent === "cleaner" || step.result.agent === "documenter";
249
- const suffix = fragments.length > 0
250
- ? writer
251
- ? ` — changed: ${fragments.join(" · ")}`
252
- : ` — ${fragments.join(" · ")}`
253
- : "";
254
220
  const id = step.runId !== undefined ? `#${step.runId} ` : "";
255
- return `- ${id}${step.result.agent} · ${step.relation} · ${workflowResultStatus(step.result)}${suffix}`;
221
+ return `- ${id}${step.result.agent} · ${step.relation} · ${workflowResultStatus(step.result)}`;
256
222
  }
257
223
 
258
224
  function appendWorkflowFooter(lines: string[], steps: readonly ChainStep[]): void {
@@ -260,7 +226,13 @@ function appendWorkflowFooter(lines: string[], steps: readonly ChainStep[]): voi
260
226
  const usage = formatUsageCompact(total);
261
227
  lines.push("", `Totals: ${steps.length} run${steps.length === 1 ? "" : "s"}${usage ? ` · ${usage}` : ""}`);
262
228
  const ids = steps.filter((step) => step.runId !== undefined).map((step) => `#${step.runId}`);
263
- lines.push(`Full per-run reports (output, usage, failed tools): subagent_status ${ids.join(" ")}`);
229
+ lines.push(`Per-run details: subagent_status ${ids.join(" ")}`);
230
+ }
231
+
232
+ function formatWorkflowSummary(title: string, steps: readonly ChainStep[]): string {
233
+ const lines = [title, "", ...steps.map(workflowStepLine)];
234
+ appendWorkflowFooter(lines, steps);
235
+ return lines.join("\n");
264
236
  }
265
237
 
266
238
  /** Condensed compatibility summary for a direct REVIEW_FAIL auto-fix chain. */
@@ -269,13 +241,10 @@ export function formatChainSummary(
269
241
  terminalResult: SingleResult = steps[steps.length - 1]!.result,
270
242
  ): string {
271
243
  const rounds = steps.filter((step) => step.relation.startsWith("fix round")).length;
272
- const lines = [
244
+ return formatWorkflowSummary(
273
245
  `## Auto-fix chain: ${Math.max(1, rounds)} round${rounds === 1 ? "" : "s"} — final ${workflowResultStatus(terminalResult)}`,
274
- "",
275
- ...steps.map(workflowStepLine),
276
- ];
277
- appendWorkflowFooter(lines, steps);
278
- return lines.join("\n");
246
+ steps,
247
+ );
279
248
  }
280
249
 
281
250
  /** One clear final delivery for all newly managed writer/documenter workflows. */
@@ -286,42 +255,42 @@ export function formatManagedWorkflowSummary(
286
255
  const route = steps.map((step) => step.result.agent).join(" → ");
287
256
  const fixRounds = steps.filter((step) => step.relation.startsWith("fix round")).length;
288
257
  const roundNote = fixRounds > 0 ? ` · ${fixRounds} fix round${fixRounds === 1 ? "" : "s"}` : "";
289
- const lines = [
258
+ return formatWorkflowSummary(
290
259
  `## Managed workflow: ${route}${roundNote} — final ${workflowResultStatus(terminalResult)}`,
291
- "",
292
- ...steps.map(workflowStepLine),
293
- ];
294
- appendWorkflowFooter(lines, steps);
295
- return lines.join("\n");
260
+ steps,
261
+ );
262
+ }
263
+
264
+ export interface GateBriefOptions {
265
+ /** A final documenter runs after the gate settles. Documentation drift is
266
+ * then routed to it as non-gating notes instead of failing the gate. */
267
+ documenterPending: boolean;
296
268
  }
297
269
 
298
- /** Build the first independent final gate after a top-level writer or required
299
- * post-pass documentation sync. Reports carry intent; the actual pending diff
300
- * remains authoritative. */
270
+ /** Build the code gate that runs directly after a top-level writer, before any
271
+ * documentation. Reports carry intent; the actual pending diff remains
272
+ * authoritative. */
301
273
  export function buildFinalReviewBrief(
302
274
  initialResult: SingleResult,
303
- documenterResult?: SingleResult,
275
+ options: GateBriefOptions,
304
276
  ): string {
305
- const documenterSection = documenterResult
306
- ? [
307
- ``,
308
- `The documenter's full sync report:`,
309
- `---`,
310
- getResultOutput(documenterResult),
311
- `---`,
312
- ]
313
- : [];
314
277
  return [
315
- `Fresh final gate for a managed ${initialResult.agent} workflow.`,
278
+ `Fresh code gate for a managed ${initialResult.agent} workflow.`,
316
279
  ``,
317
280
  `The top-level ${initialResult.agent}'s full report:`,
318
281
  `---`,
319
282
  getResultOutput(initialResult),
320
283
  `---`,
321
- ...documenterSection,
322
284
  ``,
323
- `Run \`git status\` and \`git diff\` and inspect the actual pending code and documentation; reports are context, not proof.`,
324
- `Remain read-only. Verify correctness, regressions, tests, documentation drift, and that documenter was the last writer when it ran.`,
285
+ `Run \`git status\` and \`git diff\` and inspect the actual pending code; the report is context, not proof.`,
286
+ `Remain read-only. Verify correctness, regressions, and tests.`,
287
+ ...(options.documenterPending
288
+ ? [
289
+ `Documentation sync runs AFTER this gate, so documentation drift is not a gate finding: record needed`,
290
+ `documentation updates as a separate short "## Documentation notes" list for the final documenter,`,
291
+ `and fail the gate only for code or test findings.`,
292
+ ]
293
+ : []),
325
294
  `This is an acceptance gate, not an advisory audit. End with exactly one standalone machine verdict line:`,
326
295
  `VERDICT: REVIEW_PASS when no finding remains, otherwise VERDICT: REVIEW_FAIL.`,
327
296
  ].join("\n");
@@ -329,29 +298,19 @@ export function buildFinalReviewBrief(
329
298
 
330
299
  /**
331
300
  * The re-review brief handed to the reviewer after a worker fix round. Includes
332
- * the prior review, worker report, and optional documenter report so the
333
- * reviewer can adjudicate rejections instead of restating findings. The
334
- * convergence contract keeps rounds from ping-ponging: rule on the open
335
- * findings once, add only defects this round's edits introduced, never re-open
336
- * a verified resolution.
301
+ * the prior review and worker report so the reviewer can adjudicate rejections
302
+ * instead of restating findings. The convergence contract keeps rounds from
303
+ * ping-ponging: rule on the open findings once, add only defects this round's
304
+ * edits introduced, never re-open a verified resolution.
337
305
  */
338
306
  export function buildReReviewBrief(
339
307
  reviewerResult: SingleResult,
340
308
  round: number,
341
309
  workerResult: SingleResult,
342
- documenterResult?: SingleResult,
310
+ options: GateBriefOptions = { documenterPending: false },
343
311
  ): string {
344
312
  const review = getResultOutput(reviewerResult);
345
313
  const workerReport = getResultOutput(workerResult);
346
- const documenterSection = documenterResult
347
- ? [
348
- ``,
349
- `The documenter's pre-commit sync report:`,
350
- `---`,
351
- getResultOutput(documenterResult),
352
- `---`,
353
- ]
354
- : [];
355
314
  return [
356
315
  `Re-review after auto-fix round ${round}.`,
357
316
  ``,
@@ -364,7 +323,6 @@ export function buildReReviewBrief(
364
323
  `---`,
365
324
  workerReport,
366
325
  `---`,
367
- ...documenterSection,
368
326
  ``,
369
327
  `Rule on EVERY previous finding: resolved, or still open. A finding the worker rejected must be`,
370
328
  `adjudicated ONCE — accept the rejection unless you can concretely refute the worker's reasoning;`,
@@ -372,6 +330,11 @@ export function buildReReviewBrief(
372
330
  `Run \`git diff\` to see what changed, then add NEW findings only when they are defects this round's`,
373
331
  `edits introduced or exposed (or a load-bearing issue the earlier review genuinely missed).`,
374
332
  `Do NOT re-open a finding you verified as resolved.`,
333
+ ...(options.documenterPending
334
+ ? [
335
+ `Carry any unresolved "## Documentation notes" forward verbatim; the final documenter applies them after the chain settles.`,
336
+ ]
337
+ : []),
375
338
  `REQUEST_CHANGES only while an open finding remains; otherwise APPROVE.`,
376
339
  `End with your machine-readable verdict line as usual (VERDICT: REVIEW_PASS / REVIEW_FAIL).`,
377
340
  ].join("\n");
package/src/monitor.ts CHANGED
@@ -19,6 +19,7 @@ import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
19
19
  // ---------------------------------------------------------------------------
20
20
 
21
21
  export type RunStatus = "queued" | "running" | "steering" | "interrupting" | "parked" | "done" | "failed";
22
+ export type ContinuationKind = "resume-retained" | "resume-appended" | "fork-retained" | "fork-appended" | "retarget";
22
23
 
23
24
  export function isRunActiveStatus(status: RunStatus): boolean {
24
25
  return status === "queued" || status === "running" || status === "steering" || status === "interrupting";
@@ -45,16 +46,25 @@ export interface RunView {
45
46
  usage: UsageStats;
46
47
  /** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
47
48
  activity?: string;
48
- /** Epoch ms when the run started executing (set on first "running" status). */
49
+ /** Epoch ms when this logical run first started executing. */
49
50
  startedAt?: number;
50
- /** Epoch ms when the run finished (set on "done"/"failed"). */
51
+ /** Epoch ms when the current active segment started. */
52
+ activeSince?: number;
53
+ /** Cumulative active execution time from closed segments; parked time is excluded. */
54
+ elapsedMs: number;
55
+ /** Epoch ms when the latest active segment stopped. */
51
56
  endedAt?: number;
57
+ /** Why this generation reused retained context, shown in the widget/status. */
58
+ continuationKind?: ContinuationKind;
52
59
  /** When set, this is an internal managed-workflow step. */
53
60
  groupId?: string;
54
61
  /** Human-readable role within a chain, e.g. "fix round 1" or "re-review round 1". */
55
62
  relationLabel?: string;
56
63
  /** Stable owning run whose row represents the whole managed workflow. */
57
64
  parentRunId?: number;
65
+ /** This stable top-level row currently owns a multi-stage managed workflow.
66
+ * Its elapsed time is workflow-wide; active child rows own stage telemetry. */
67
+ managedWorkflow?: boolean;
58
68
  }
59
69
 
60
70
  /** Optional metadata for documenter/reviewer/fix children of a stable parent run. */
@@ -64,6 +74,7 @@ export interface RunChainMeta {
64
74
  parentRunId?: number;
65
75
  isolation?: IsolationMode;
66
76
  forkedFromRunId?: number;
77
+ continuationKind?: ContinuationKind;
67
78
  }
68
79
 
69
80
  // ---------------------------------------------------------------------------
@@ -276,10 +287,32 @@ export function formatDuration(ms: number): string {
276
287
  return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
277
288
  }
278
289
 
279
- /** Elapsed wall time of a run: live while running, final once finished. */
290
+ /** Cumulative active time across generations; parked gaps never count. */
291
+ export function elapsedMilliseconds(run: RunView, now: number = Date.now()): number {
292
+ let elapsed = run.elapsedMs ?? 0;
293
+ if (run.activeSince !== undefined) elapsed += Math.max(0, now - run.activeSince);
294
+ // Keep formatting tolerant of older/synthetic RunView values that predate
295
+ // segmented timing and carry only startedAt/endedAt.
296
+ if (elapsed === 0 && run.startedAt !== undefined && run.activeSince === undefined) {
297
+ elapsed = Math.max(0, (run.endedAt ?? now) - run.startedAt);
298
+ }
299
+ return elapsed;
300
+ }
301
+
280
302
  export function formatElapsed(run: RunView, now: number = Date.now()): string {
281
- if (run.startedAt === undefined) return "";
282
- return formatDuration((run.endedAt ?? now) - run.startedAt);
303
+ if (run.startedAt === undefined && run.elapsedMs <= 0) return "";
304
+ return formatDuration(elapsedMilliseconds(run, now));
305
+ }
306
+
307
+ export function continuationLabel(kind: ContinuationKind | undefined, sourceRunId?: number): string | undefined {
308
+ switch (kind) {
309
+ case "resume-retained": return "resume: current objective";
310
+ case "resume-appended": return "resume: appended objective";
311
+ case "fork-retained": return `fork${sourceRunId === undefined ? "" : ` #${sourceRunId}`}: current objective`;
312
+ case "fork-appended": return `fork${sourceRunId === undefined ? "" : ` #${sourceRunId}`}: appended objective`;
313
+ case "retarget": return "retarget: replacement objective";
314
+ default: return undefined;
315
+ }
283
316
  }
284
317
 
285
318
  /** Max length of the argument target inside a formatted activity line. */
@@ -409,11 +442,13 @@ export class MonitorStore {
409
442
  thinking,
410
443
  status: "queued",
411
444
  usage: emptyUsage(),
445
+ elapsedMs: 0,
412
446
  ...(meta?.groupId ? { groupId: meta.groupId } : {}),
413
447
  ...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
414
448
  ...(meta?.parentRunId !== undefined ? { parentRunId: meta.parentRunId } : {}),
415
449
  ...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined } : {}),
416
450
  ...(meta?.forkedFromRunId !== undefined ? { forkedFromRunId: meta.forkedFromRunId } : {}),
451
+ ...(meta?.continuationKind ? { continuationKind: meta.continuationKind } : {}),
417
452
  });
418
453
  this.notify();
419
454
  return id;
@@ -422,17 +457,33 @@ export class MonitorStore {
422
457
  setStatus(id: number, status: RunStatus): void {
423
458
  const run = this.find(id);
424
459
  if (!run) return;
460
+ const previousStatus = run.status;
461
+ const wasExecuting = previousStatus === "running" || previousStatus === "steering" || previousStatus === "interrupting";
462
+ const isExecuting = status === "running" || status === "steering" || status === "interrupting";
463
+ const now = Date.now();
425
464
  run.status = status;
426
- if (status === "running" || status === "steering" || status === "interrupting") {
427
- if (run.startedAt === undefined) run.startedAt = Date.now();
428
- // A selected-to-main handoff or resumed generation restarts the clock; a
429
- // stale endedAt would freeze the elapsed display at the first attempt.
430
- if (run.endedAt !== undefined) run.endedAt = undefined;
431
- } else if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
432
- run.endedAt = Date.now();
465
+ if (isExecuting && !wasExecuting) {
466
+ run.startedAt ??= now;
467
+ run.activeSince = now;
468
+ run.endedAt = undefined;
469
+ } else if (!isExecuting && wasExecuting && run.activeSince !== undefined) {
470
+ run.elapsedMs += Math.max(0, now - run.activeSince);
471
+ run.activeSince = undefined;
433
472
  }
473
+ if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
474
+ run.endedAt = now;
475
+ }
476
+ this.notify();
477
+ }
478
+ /** Switch a stable top-level row from one model run to workflow ownership.
479
+ * The original role remains for identity; child rows show stage telemetry. */
480
+ setManagedWorkflow(id: number, active: boolean): void {
481
+ const run = this.find(id);
482
+ if (!run) return;
483
+ run.managedWorkflow = active || undefined;
434
484
  this.notify();
435
485
  }
486
+
436
487
  setUsage(id: number, usage: UsageStats, model?: string): void {
437
488
  const run = this.find(id);
438
489
  if (!run) return;
@@ -503,26 +554,38 @@ export class MonitorStore {
503
554
  this.notify();
504
555
  }
505
556
 
506
- /** Reflect the currently owned internal stage when a managed parent is parked
507
- * or inspected between children; the stable id and original task stay intact. */
508
- setAgent(id: number, agent: string): void {
557
+ /** Update the objective shown for a queued retarget or resumed generation. */
558
+ setTask(id: number, task: string): void {
509
559
  const run = this.find(id);
510
560
  if (!run) return;
511
- run.agent = agent;
561
+ run.task = task;
562
+ run.label = runLabel(task);
512
563
  this.notify();
513
564
  }
514
565
 
515
- /** Update the objective shown for a queued retarget or resumed generation. */
516
- setTask(id: number, task: string): void {
566
+ setContinuationKind(id: number, kind: ContinuationKind): void {
517
567
  const run = this.find(id);
518
568
  if (!run) return;
519
- run.task = task;
520
- run.label = runLabel(task);
569
+ run.continuationKind = kind;
521
570
  this.notify();
522
571
  }
523
572
 
524
- /** Reuse a stable logical run id for a resumed generation. */
525
- restartRun(id: number, agent: string, task: string, model?: string, thinking?: string, isolation?: IsolationMode): void {
573
+ getElapsedMs(id: number, now: number = Date.now()): number | undefined {
574
+ const run = this.find(id);
575
+ return run ? elapsedMilliseconds(run, now) : undefined;
576
+ }
577
+
578
+ /** Reuse a stable logical run id for a resumed generation without discarding
579
+ * active time accumulated by earlier generations. */
580
+ restartRun(
581
+ id: number,
582
+ agent: string,
583
+ task: string,
584
+ model?: string,
585
+ thinking?: string,
586
+ isolation?: IsolationMode,
587
+ meta?: { elapsedMs?: number; continuationKind?: ContinuationKind },
588
+ ): void {
526
589
  const run = this.find(id);
527
590
  if (!run) {
528
591
  this.runs.push({
@@ -535,6 +598,8 @@ export class MonitorStore {
535
598
  ...(isolation ? { isolation, integrationStatus: isolation === "worktree" ? "pending" as const : undefined } : {}),
536
599
  status: "queued",
537
600
  usage: emptyUsage(),
601
+ elapsedMs: meta?.elapsedMs ?? 0,
602
+ continuationKind: meta?.continuationKind,
538
603
  });
539
604
  this.notify();
540
605
  return;
@@ -549,8 +614,11 @@ export class MonitorStore {
549
614
  run.status = "queued";
550
615
  run.usage = emptyUsage();
551
616
  run.activity = undefined;
552
- run.startedAt = undefined;
617
+ run.managedWorkflow = undefined;
618
+ run.activeSince = undefined;
553
619
  run.endedAt = undefined;
620
+ run.elapsedMs = Math.max(run.elapsedMs, meta?.elapsedMs ?? 0);
621
+ run.continuationKind = meta?.continuationKind;
554
622
  this.notify();
555
623
  }
556
624
 
@@ -589,12 +657,14 @@ export class MonitorStore {
589
657
 
590
658
  summarize(run: RunView): string {
591
659
  const usage = formatUsageCompact(run.usage);
592
- const parts = [run.agent];
660
+ const parts = [run.managedWorkflow ? `${run.agent} workflow` : run.agent];
661
+ const continuation = continuationLabel(run.continuationKind, run.forkedFromRunId);
662
+ if (continuation) parts.push(continuation);
593
663
  if (run.relationLabel) parts.push(run.relationLabel);
594
- if (run.model) parts.push(run.model);
595
- if (run.thinking) parts.push(`thinking ${run.thinking}`);
664
+ if (!run.managedWorkflow && run.model) parts.push(run.model);
665
+ if (!run.managedWorkflow && run.thinking) parts.push(`thinking ${run.thinking}`);
596
666
  if (run.isolation === "worktree") parts.push(`worktree ${run.integrationStatus ?? "active"}`);
597
- if (usage) parts.push(usage);
667
+ if (!run.managedWorkflow && usage) parts.push(usage);
598
668
  const elapsed = formatElapsed(run);
599
669
  if (elapsed) parts.push(elapsed);
600
670
  return parts.join(" · ");
package/src/prompt.ts CHANGED
@@ -34,8 +34,8 @@ export function buildDelegationDirective(
34
34
  ...(hasDocumenter ? ["documenter"] : []),
35
35
  ];
36
36
  const automaticWriterRoute = [
37
- ...(hasDocumenter ? ["documenter"] : []),
38
37
  ...(hasReviewer ? ["reviewer"] : []),
38
+ ...(hasDocumenter ? ["documenter"] : []),
39
39
  ].join(" → ");
40
40
  const namedWorktreeTargets = [
41
41
  ...(hasWorker ? ["worker"] : []),
@@ -65,7 +65,7 @@ export function buildDelegationDirective(
65
65
  : []),
66
66
  ...(hasDocumenter
67
67
  ? [
68
- `Use \`documenter\` directly for explicit whole-codebase maintenance or standalone documentation work.${codeWriterNames.length > 0 ? ` Successful ${codeWriterNames.join("/")} runs already auto-sync the actual diff; never dispatch a duplicate.` : ""} Zero edits is valid and broad mode is never inferred. It changes docs/comments only and never runtime behavior, versions, release state, or ${hasReviewer ? "the final reviewer gate" : "direct final verification"}.`,
68
+ `Use \`documenter\` directly for explicit whole-codebase maintenance or standalone documentation work.${codeWriterNames.length > 0 ? ` Successful ${codeWriterNames.join("/")} runs already auto-sync the actual diff once after the review gate; never dispatch a duplicate.` : ""} Zero edits is valid and broad mode is never inferred. It changes docs/comments only and never runtime behavior, versions, or release state.`,
69
69
  ]
70
70
  : []),
71
71
  ...(hasReviewer
@@ -103,7 +103,7 @@ export function buildDelegationDirective(
103
103
  ? [
104
104
  ...(hasDocumenter
105
105
  ? [
106
- `A direct REVIEW_PASS is preliminary: runtime runs documenter on the pending diff, then a fresh reviewer. A direct REVIEW_FAIL ${autoFixEnabled ? "keeps auto-fix; maxFixRounds limits worker fixes only, not initial docs/review." : "cannot start fixes while worker/fix rounds are disabled."}`,
106
+ `A direct REVIEW_PASS is final for code: runtime runs the final documentation sync once and delivers. A direct REVIEW_FAIL ${autoFixEnabled ? "keeps auto-fix; maxFixRounds limits worker fixes only, not the final documentation sync." : "cannot start fixes while worker/fix rounds are disabled."}`,
107
107
  ]
108
108
  : []),
109
109
  "Resolve every gate finding; do not bypass the configured auto-fix/re-review cap. A reviewer report without a standalone VERDICT is advisory and cannot trigger writes.",
package/src/rpc-run.ts CHANGED
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
14
14
  import { basename, join } from "node:path";
15
15
  import { StringDecoder } from "node:string_decoder";
16
16
  import type { Message } from "@earendil-works/pi-ai";
17
- import type { AgentConfig } from "./agents.ts";
17
+ import { SUBAGENT_TOOL_NAMES, type AgentConfig } from "./agents.ts";
18
18
  import type { ThinkingLevel } from "./config.ts";
19
19
  import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
20
20
 
@@ -469,7 +469,7 @@ export interface RunRpcAttemptOptions {
469
469
  /** Run one persistent RPC child until a stable `agent_settled` or control action. */
470
470
  export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise<RpcSingleResult> {
471
471
  const { agent, agentName, task, thinkingLevel, idleTimeoutMs, signal, onLive, control } = options;
472
- const args: string[] = ["--mode", "rpc", "--exclude-tools", "subagent,subagent_control"];
472
+ const args: string[] = ["--mode", "rpc", "--exclude-tools", SUBAGENT_TOOL_NAMES.join(",")];
473
473
  if (options.sessionDir && options.sessionId) {
474
474
  args.push("--session-dir", options.sessionDir);
475
475
  args.push(sessionExists(options.sessionDir, options.sessionId) ? "--session" : "--session-id", options.sessionId);
@@ -478,7 +478,10 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
478
478
  }
479
479
  if (agent.model) args.push("--model", agent.model);
480
480
  args.push("--thinking", thinkingLevel);
481
- if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
481
+ if (agent.tools) {
482
+ if (agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
483
+ else args.push("--no-tools");
484
+ }
482
485
 
483
486
  let tmpPromptDir: string | null = null;
484
487
  let tmpPromptPath: string | null = null;
package/src/runtime.ts CHANGED
@@ -67,6 +67,8 @@ export interface SubagentThread {
67
67
  lifecycleOperation?: ThreadLifecycleOperation;
68
68
  sessionId?: string;
69
69
  sessionDir?: string;
70
+ /** Active execution time accumulated across retained resume generations. */
71
+ elapsedMs: number;
70
72
  /** Most recent generation result, retained for parked destructive-stop output. */
71
73
  lastResult?: SingleResult;
72
74
  /** A destructive stop retires context even if the active child settles later. */
@@ -92,6 +94,8 @@ export interface SubagentThread {
92
94
  export interface SubagentRuntime {
93
95
  configPath: string;
94
96
  backgroundQueue: BackgroundTaskQueue;
97
+ /** Live parent tool names from ExtensionAPI, read again for each child launch. */
98
+ getActiveTools: () => string[];
95
99
  /** False after session_shutdown; guards delivery and queue work. */
96
100
  sessionActive: boolean;
97
101
  /** Deliver a batch of completion messages to the main window, waking it only
@@ -128,6 +132,7 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
128
132
  const runtime: SubagentRuntime = {
129
133
  configPath,
130
134
  backgroundQueue,
135
+ getActiveTools: () => pi.getActiveTools(),
131
136
  sessionActive: true,
132
137
  sendCompletionGroup: (items) => {
133
138
  if (!runtime.sessionActive || items.length === 0) return;