pi-subagents 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +78 -9
  2. package/README.md +213 -32
  3. package/index.ts +1 -0
  4. package/install.mjs +1 -1
  5. package/package.json +23 -8
  6. package/prompts/review-loop.md +3 -1
  7. package/skills/pi-subagents/SKILL.md +87 -25
  8. package/src/agents/agent-management.ts +82 -15
  9. package/src/agents/agent-serializer.ts +19 -0
  10. package/src/agents/agents.ts +91 -49
  11. package/src/agents/frontmatter.ts +67 -13
  12. package/src/agents/skills.ts +25 -12
  13. package/src/api/background-work.ts +197 -0
  14. package/src/api/delegation.ts +158 -0
  15. package/src/extension/chain-validation.ts +165 -0
  16. package/src/extension/doctor.ts +15 -0
  17. package/src/extension/fanout-child.ts +3 -1
  18. package/src/extension/index.ts +65 -124
  19. package/src/extension/rpc.ts +10 -2
  20. package/src/extension/schemas.ts +18 -14
  21. package/src/extension/steering-notices.ts +35 -0
  22. package/src/extension/tool-description.ts +20 -9
  23. package/src/intercom/intercom-bridge.ts +3 -2
  24. package/src/intercom/native-supervisor-channel.ts +9 -1
  25. package/src/intercom/result-intercom.ts +4 -0
  26. package/src/runs/background/async-execution.ts +293 -44
  27. package/src/runs/background/async-job-tracker.ts +56 -9
  28. package/src/runs/background/async-resume.ts +159 -52
  29. package/src/runs/background/async-status.ts +25 -18
  30. package/src/runs/background/auto-drain.ts +67 -0
  31. package/src/runs/background/chain-root-attachment.ts +16 -8
  32. package/src/runs/background/control-channel.ts +260 -13
  33. package/src/runs/background/fleet-view.ts +23 -2
  34. package/src/runs/background/notify.ts +79 -10
  35. package/src/runs/background/result-watcher.ts +12 -9
  36. package/src/runs/background/run-id-resolver.ts +14 -2
  37. package/src/runs/background/run-status.ts +23 -15
  38. package/src/runs/background/scheduled-runs.ts +3 -0
  39. package/src/runs/background/stale-run-reconciler.ts +32 -10
  40. package/src/runs/background/steering.ts +237 -0
  41. package/src/runs/background/subagent-runner.ts +898 -236
  42. package/src/runs/background/subagent-wait.ts +484 -0
  43. package/src/runs/background/top-level-async.ts +2 -1
  44. package/src/runs/background/wait-config.ts +36 -0
  45. package/src/runs/background/wait-tool.ts +26 -0
  46. package/src/runs/foreground/async-steering-action.ts +230 -0
  47. package/src/runs/foreground/chain-clarify.ts +22 -6
  48. package/src/runs/foreground/chain-execution.ts +50 -32
  49. package/src/runs/foreground/execution.ts +308 -94
  50. package/src/runs/foreground/subagent-executor.ts +592 -268
  51. package/src/runs/shared/acceptance.ts +355 -97
  52. package/src/runs/shared/child-protocol.ts +121 -0
  53. package/src/runs/shared/completion-guard.ts +8 -127
  54. package/src/runs/shared/dynamic-fanout.ts +6 -4
  55. package/src/runs/shared/model-fallback.ts +36 -0
  56. package/src/runs/shared/nested-events.ts +9 -4
  57. package/src/runs/shared/nested-render.ts +4 -1
  58. package/src/runs/shared/parallel-utils.ts +7 -0
  59. package/src/runs/shared/pi-args.ts +34 -7
  60. package/src/runs/shared/pi-spawn.ts +18 -12
  61. package/src/runs/shared/session-lease.ts +279 -0
  62. package/src/runs/shared/single-output.ts +61 -6
  63. package/src/runs/shared/spawn-budget.ts +128 -0
  64. package/src/runs/shared/subagent-control.ts +10 -6
  65. package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
  66. package/src/runs/shared/task-intent.ts +176 -0
  67. package/src/runs/shared/tool-availability.ts +65 -0
  68. package/src/runs/shared/turn-budget.ts +49 -4
  69. package/src/shared/atomic-json.ts +4 -1
  70. package/src/shared/fork-context.ts +28 -3
  71. package/src/shared/model-info.ts +7 -4
  72. package/src/shared/status-format.ts +7 -1
  73. package/src/shared/types.ts +203 -25
  74. package/src/shared/utils.ts +35 -7
  75. package/src/slash/delegation-adapters.ts +457 -0
  76. package/src/slash/delegation-request.ts +103 -0
  77. package/src/slash/prompt-template-bridge.ts +167 -344
  78. package/src/slash/slash-commands.ts +239 -6
  79. package/src/slash/subagents-admin.ts +428 -0
  80. package/src/slash/subagents-editor.ts +86 -0
  81. package/src/tui/fleet.ts +405 -0
  82. package/src/tui/render.ts +90 -16
  83. package/src/watchdog/change-signature.ts +127 -0
  84. package/src/watchdog/child-status.ts +205 -0
  85. package/src/watchdog/emission-guard.ts +123 -0
  86. package/src/watchdog/lsp-diagnostics.ts +532 -0
  87. package/src/watchdog/model-selection.ts +167 -0
  88. package/src/watchdog/register-child.ts +117 -0
  89. package/src/watchdog/register-main.ts +433 -0
  90. package/src/watchdog/render.ts +54 -0
  91. package/src/watchdog/review.ts +293 -0
  92. package/src/watchdog/runtime.ts +712 -0
  93. package/src/watchdog/settings.ts +528 -0
  94. package/src/watchdog/tool-actions.ts +155 -0
  95. package/src/watchdog/turn-delta.ts +161 -0
  96. package/src/watchdog/types.ts +188 -0
  97. package/src/watchdog/warning-format.ts +73 -0
  98. package/src/runs/background/wait.ts +0 -394
package/src/tui/render.ts CHANGED
@@ -268,6 +268,7 @@ function firstOutputLine(text: string): string {
268
268
 
269
269
  function resultStatusLine(result: Details["results"][number], output: string): string {
270
270
  if (result.detached) return result.detachedReason ? `Detached: ${result.detachedReason}` : "Detached";
271
+ if (result.stopped) return "Stopped";
271
272
  if (result.interrupted) return "Paused";
272
273
  if (result.exitCode !== 0) return `Error: ${result.error ?? (firstOutputLine(output) || `exit ${result.exitCode}`)}`;
273
274
  if (result.acceptance?.status && result.acceptance.status !== "not-required") return `Done · acceptance: ${result.acceptance.status}`;
@@ -281,6 +282,7 @@ function resultGlyph(result: Details["results"][number], output: string, theme:
281
282
  return theme.fg("accent", runningGlyph(seed));
282
283
  }
283
284
  if (result.detached) return theme.fg("warning", "■");
285
+ if (result.stopped) return theme.fg("warning", "■");
284
286
  if (result.interrupted) return theme.fg("warning", "■");
285
287
  if (result.exitCode !== 0) return theme.fg("error", "✗");
286
288
  if (hasEmptyTextOutputWithoutOutputTarget(result.task, output)) return theme.fg("warning", "✓");
@@ -349,6 +351,7 @@ function widgetActivity(job: AsyncJobState): string {
349
351
  if (job.status === "running") return "thinking…";
350
352
  if (job.status === "queued") return "queued…";
351
353
  if (job.status === "paused") return "Paused";
354
+ if (job.status === "stopped") return "Stopped";
352
355
  if (job.status === "failed") return "Failed";
353
356
  return "Done";
354
357
  }
@@ -397,6 +400,7 @@ function widgetStatusGlyph(job: AsyncJobState, theme: Theme): string {
397
400
  if (job.status === "queued") return theme.fg("muted", "◦");
398
401
  if (job.status === "complete") return theme.fg("success", "✓");
399
402
  if (job.status === "paused") return theme.fg("warning", "■");
403
+ if (job.status === "stopped") return theme.fg("warning", "■");
400
404
  return theme.fg("error", "✗");
401
405
  }
402
406
 
@@ -405,6 +409,7 @@ function widgetStepGlyph(status: AsyncJobStep["status"], theme: Theme, seed?: nu
405
409
  if (status === "complete" || status === "completed") return theme.fg("success", "✓");
406
410
  if (status === "failed") return theme.fg("error", "✗");
407
411
  if (status === "paused") return theme.fg("warning", "■");
412
+ if (status === "stopped") return theme.fg("warning", "■");
408
413
  return theme.fg("muted", "◦");
409
414
  }
410
415
 
@@ -413,6 +418,7 @@ function widgetStepStatus(status: AsyncJobStep["status"], theme: Theme): string
413
418
  if (status === "complete" || status === "completed") return theme.fg("success", "complete");
414
419
  if (status === "failed") return theme.fg("error", "failed");
415
420
  if (status === "paused") return theme.fg("warning", "paused");
421
+ if (status === "stopped") return theme.fg("warning", "stopped");
416
422
  return theme.fg("dim", status);
417
423
  }
418
424
 
@@ -620,7 +626,7 @@ function buildMultiProgressLabel(details: Pick<Details, "mode" | "results" | "pr
620
626
 
621
627
  if (details.mode === "parallel") {
622
628
  const totalCount = details.totalSteps ?? details.results.length;
623
- const statuses = new Array(totalCount).fill("pending") as Array<"pending" | "running" | "completed" | "failed" | "detached">;
629
+ const statuses = new Array(totalCount).fill("pending") as Array<"pending" | "running" | "completed" | "failed" | "stopped" | "detached">;
624
630
  for (const progress of details.progress ?? []) {
625
631
  if (progress.index >= 0 && progress.index < totalCount) statuses[progress.index] = progress.status;
626
632
  }
@@ -631,11 +637,13 @@ function buildMultiProgressLabel(details: Pick<Details, "mode" | "results" | "pr
631
637
  const index = result.progress?.index ?? progressFromArray?.index ?? i;
632
638
  if (index < 0 || index >= totalCount) continue;
633
639
  const status = result.progress?.status
634
- ?? (result.interrupted || result.detached
635
- ? "detached"
636
- : result.exitCode === 0
637
- ? "completed"
638
- : "failed");
640
+ ?? (result.stopped
641
+ ? "stopped"
642
+ : result.interrupted || result.detached
643
+ ? "detached"
644
+ : result.exitCode === 0
645
+ ? "completed"
646
+ : "failed");
639
647
  statuses[index] = status;
640
648
  }
641
649
  const running = statuses.filter((status) => status === "running").length;
@@ -788,6 +796,7 @@ function nestedStatusGlyph(state: NestedRunSummary["state"] | NestedStepSummary[
788
796
  if (state === "complete" || state === "completed") return theme.fg("success", "✓");
789
797
  if (state === "failed") return theme.fg("error", "✗");
790
798
  if (state === "paused") return theme.fg("warning", "■");
799
+ if (state === "stopped") return theme.fg("warning", "■");
791
800
  return theme.fg("muted", "◦");
792
801
  }
793
802
 
@@ -795,6 +804,29 @@ function nestedRunSeed(run: NestedRunSummary): number | undefined {
795
804
  return runningSeed(run.lastUpdate, run.lastActivityAt, run.currentStep, run.toolCount, run.turnCount, run.totalTokens?.total, run.currentToolStartedAt);
796
805
  }
797
806
 
807
+ function formatClockTime(ms: number | undefined): string | undefined {
808
+ if (ms === undefined || !Number.isFinite(ms)) return undefined;
809
+ const date = new Date(ms);
810
+ const pad = (value: number) => value.toString().padStart(2, "0");
811
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
812
+ }
813
+
814
+ function nestedRunEventTime(run: NestedRunSummary): number | undefined {
815
+ return run.state === "running"
816
+ ? (run.lastActivityAt ?? run.currentToolStartedAt ?? run.lastUpdate ?? run.startedAt)
817
+ : (run.endedAt ?? run.lastUpdate ?? run.lastActivityAt ?? run.startedAt);
818
+ }
819
+
820
+ function nestedStepTimestamp(step: NestedStepSummary, fallback?: number): string | undefined {
821
+ return formatClockTime(step.status === "running"
822
+ ? (step.lastActivityAt ?? step.currentToolStartedAt ?? fallback ?? step.startedAt)
823
+ : (step.endedAt ?? step.lastActivityAt ?? fallback ?? step.startedAt));
824
+ }
825
+
826
+ function nestedTimestampPrefix(timestamp: string | undefined): string {
827
+ return timestamp ? `[${timestamp}] ` : "";
828
+ }
829
+
798
830
  function nestedActivity(input: Pick<NestedRunSummary | NestedStepSummary, "activityState" | "lastActivityAt" | "currentTool" | "currentToolStartedAt" | "currentPath" | "turnCount" | "toolCount">, state: NestedRunSummary["state"] | NestedStepSummary["status"], snapshotNow?: number): string {
799
831
  const facts: string[] = [];
800
832
  if (input.currentTool && input.currentToolStartedAt !== undefined && snapshotNow !== undefined) facts.push(`${input.currentTool} ${formatDuration(Math.max(0, snapshotNow - input.currentToolStartedAt))}`);
@@ -809,6 +841,7 @@ function nestedActivity(input: Pick<NestedRunSummary | NestedStepSummary, "activ
809
841
  if (state === "running") return "thinking…";
810
842
  if (state === "queued" || state === "pending") return "queued…";
811
843
  if (state === "paused") return "Paused";
844
+ if (state === "stopped") return "Stopped";
812
845
  if (state === "failed") return "Failed";
813
846
  return "Done";
814
847
  }
@@ -817,7 +850,8 @@ function formatNestedWidgetLines(children: NestedRunSummary[] | undefined, theme
817
850
  if (!children?.length || lineBudget <= 0) return [];
818
851
  if (!expanded) {
819
852
  const aggregate = formatNestedAggregate(children);
820
- return aggregate ? [theme.fg("dim", `↳ ${aggregate}`)] : [];
853
+ const latest = children.reduce((acc, child) => Math.max(acc, nestedRunEventTime(child) ?? 0), 0);
854
+ return aggregate ? [theme.fg("dim", `↳ ${nestedTimestampPrefix(formatClockTime(latest))}${aggregate}`)] : [];
821
855
  }
822
856
  const lines: string[] = [];
823
857
  const maxDepth = 2;
@@ -837,7 +871,7 @@ function formatNestedWidgetLines(children: NestedRunSummary[] | undefined, theme
837
871
  }
838
872
  const activity = nestedActivity(child, child.state, snapshotNow ?? child.lastUpdate);
839
873
  const error = child.error ? ` · ${child.error}` : "";
840
- lines.push(theme.fg("dim", `${prefix}↳ ${nestedStatusGlyph(child.state, theme, nestedRunSeed(child))} ${nestedRunName(child)} · ${child.state} · ${activity}${error}`));
874
+ lines.push(theme.fg("dim", `${prefix}↳ ${nestedTimestampPrefix(formatClockTime(nestedRunEventTime(child)))}${nestedStatusGlyph(child.state, theme, nestedRunSeed(child))} ${nestedRunName(child)} · ${child.state} · ${activity}${error}`));
841
875
  if (depth === maxDepth) {
842
876
  const aggregate = formatNestedAggregate([...(child.steps?.flatMap((step) => step.children ?? []) ?? []), ...(child.children ?? [])]);
843
877
  if (aggregate && lines.length < lineBudget) lines.push(theme.fg("dim", `${prefix} ↳ ${aggregate}`));
@@ -845,7 +879,7 @@ function formatNestedWidgetLines(children: NestedRunSummary[] | undefined, theme
845
879
  }
846
880
  for (const step of child.steps ?? []) {
847
881
  if (lines.length >= lineBudget) return;
848
- lines.push(theme.fg("dim", `${prefix} ↳ ${nestedStatusGlyph(step.status, theme)} ${step.agent} · ${step.status} · ${nestedActivity(step, step.status, snapshotNow ?? child.lastUpdate)}`));
882
+ lines.push(theme.fg("dim", `${prefix} ↳ ${nestedTimestampPrefix(nestedStepTimestamp(step, child.lastUpdate))}${nestedStatusGlyph(step.status, theme)} ${step.agent} · ${step.status} · ${nestedActivity(step, step.status, snapshotNow ?? child.lastUpdate)}`));
849
883
  append(step.children, depth + 1, `${prefix} `);
850
884
  }
851
885
  append(child.children, depth + 1, `${prefix} `);
@@ -984,13 +1018,14 @@ function widgetSessionMatches(expanded: boolean): boolean {
984
1018
  && widgetLayoutSession.columns === currentTerminalColumns();
985
1019
  }
986
1020
 
987
- function widgetHeaderCounts(jobs: AsyncJobState[]): { running: AsyncJobState[]; queued: AsyncJobState[]; complete: AsyncJobState[]; failed: AsyncJobState[]; paused: AsyncJobState[] } {
1021
+ function widgetHeaderCounts(jobs: AsyncJobState[]): { running: AsyncJobState[]; queued: AsyncJobState[]; complete: AsyncJobState[]; failed: AsyncJobState[]; paused: AsyncJobState[]; stopped: AsyncJobState[] } {
988
1022
  return {
989
1023
  running: jobs.filter((job) => job.status === "running"),
990
1024
  queued: jobs.filter((job) => job.status === "queued"),
991
1025
  complete: jobs.filter((job) => job.status === "complete"),
992
1026
  failed: jobs.filter((job) => job.status === "failed"),
993
1027
  paused: jobs.filter((job) => job.status === "paused"),
1028
+ stopped: jobs.filter((job) => job.status === "stopped"),
994
1029
  };
995
1030
  }
996
1031
 
@@ -1002,6 +1037,7 @@ function buildSingleLineWidgetLines(jobs: AsyncJobState[], theme: Theme, width:
1002
1037
  if (counts.running.length > 0) parts.push(`${counts.running.length}/${jobs.length} running`);
1003
1038
  if (counts.queued.length > 0) parts.push(`${counts.queued.length} queued`);
1004
1039
  if (counts.failed.length > 0) parts.push(`${counts.failed.length} failed`);
1040
+ if (counts.stopped.length > 0) parts.push(`${counts.stopped.length} stopped`);
1005
1041
  if (counts.paused.length > 0) parts.push(`${counts.paused.length} paused`);
1006
1042
  if (!hasActive && counts.complete.length > 0) parts.push(`${counts.complete.length}/${jobs.length} done`);
1007
1043
  return [truncLine(`${theme.fg(hasActive ? "accent" : "dim", glyph)} ${theme.fg(hasActive ? "accent" : "dim", "subagents")} (${parts.join(", ") || `${jobs.length} total`})`, width)];
@@ -1065,6 +1101,7 @@ function progressiveHeaderLine(jobs: AsyncJobState[], theme: Theme, width: numbe
1065
1101
  if (counts.queued.length > 0) parts.push(`${counts.queued.length} queued`);
1066
1102
  if (!hasActive) {
1067
1103
  if (counts.failed.length > 0) parts.push(`${counts.failed.length} failed`);
1104
+ if (counts.stopped.length > 0) parts.push(`${counts.stopped.length} stopped`);
1068
1105
  if (counts.paused.length > 0) parts.push(`${counts.paused.length} paused`);
1069
1106
  if (counts.complete.length > 0) parts.push(`${counts.complete.length}/${jobs.length} done`);
1070
1107
  }
@@ -1089,7 +1126,7 @@ function progressiveHiddenLine(hiddenJobs: AsyncJobState[], theme: Theme, width:
1089
1126
  const parts: string[] = [];
1090
1127
  if (counts.running.length > 0) parts.push(`${counts.running.length} running`);
1091
1128
  if (counts.queued.length > 0) parts.push(`${counts.queued.length} queued`);
1092
- const finished = counts.complete.length + counts.failed.length + counts.paused.length;
1129
+ const finished = counts.complete.length + counts.failed.length + counts.paused.length + counts.stopped.length;
1093
1130
  if (finished > 0) parts.push(`${finished} finished`);
1094
1131
  return truncLine(theme.fg("dim", ` +${hiddenJobs.length} more${parts.length ? ` (${parts.join(", ")})` : ""}`), width);
1095
1132
  }
@@ -1292,11 +1329,17 @@ function renderSingleCompact(d: Details, r: Details["results"][number], theme: T
1292
1329
  c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${activity}`), width), 0, 0));
1293
1330
  const liveStatus = buildLiveStatusLine(r.progress, progressSnapshotNow);
1294
1331
  if (liveStatus && liveStatus !== activity) c.addChild(new Text(truncLine(theme.fg("dim", ` ${liveStatus}`), width), 0, 0));
1332
+ for (const nestedLine of formatNestedWidgetLines(r.children, theme, width, false, progressSnapshotNow)) {
1333
+ c.addChild(new Text(truncLine(` ${nestedLine}`, width), 0, 0));
1334
+ }
1295
1335
  c.addChild(new Text(truncLine(theme.fg("accent", ` ${liveDetailHintText()}`), width), 0, 0));
1296
1336
  if (r.artifactPaths) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${shortenPath(r.artifactPaths.outputPath)}`), width), 0, 0));
1297
1337
  return c;
1298
1338
  }
1299
1339
 
1340
+ for (const nestedLine of formatNestedWidgetLines(r.children, theme, width, false, r.progress?.lastActivityAt)) {
1341
+ c.addChild(new Text(truncLine(` ${nestedLine}`, width), 0, 0));
1342
+ }
1300
1343
  c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${resultStatusLine(r, output)}`), width), 0, 0));
1301
1344
  const preview = firstOutputLine(output);
1302
1345
  if (preview && r.exitCode === 0 && !hasEmptyTextOutputWithoutOutputTarget(r.task, output)) {
@@ -1312,7 +1355,9 @@ function renderMultiCompact(d: Details, theme: Theme, frame?: number): Component
1312
1355
  const hasRunning = d.progress?.some((p) => p.status === "running")
1313
1356
  || d.results.some((r) => r.progress?.status === "running")
1314
1357
  || workflowGraphHasStatus(d, ["running"]);
1315
- const failed = d.results.some((r) => r.exitCode !== 0 && r.progress?.status !== "running")
1358
+ const stopped = d.results.some((r) => r.stopped && r.progress?.status !== "running")
1359
+ || workflowGraphHasStatus(d, ["stopped"]);
1360
+ const failed = d.results.some((r) => !r.stopped && r.exitCode !== 0 && r.progress?.status !== "running")
1316
1361
  || workflowGraphHasStatus(d, ["failed"]);
1317
1362
  const paused = d.results.some((r) => (r.interrupted || r.detached) && r.progress?.status !== "running")
1318
1363
  || workflowGraphHasStatus(d, ["paused", "detached"]);
@@ -1335,8 +1380,10 @@ function renderMultiCompact(d: Details, theme: Theme, frame?: number): Component
1335
1380
  const stats = statJoin(theme, [multiLabel.headerLabel, formatProgressStats(theme, totalSummary), formatTotalCostStat(d.totalCost)]);
1336
1381
  const glyph = hasRunning
1337
1382
  ? theme.fg("accent", runningGlyph(frame !== undefined ? (runningSeed(progressRunningSeed(totalSummary), d.currentStepIndex) ?? 0) + frame : runningSeed(progressRunningSeed(totalSummary), d.currentStepIndex)))
1338
- : failed
1339
- ? theme.fg("error", "✗")
1383
+ : stopped
1384
+ ? theme.fg("warning", "■")
1385
+ : failed
1386
+ ? theme.fg("error", "✗")
1340
1387
  : paused
1341
1388
  ? theme.fg("warning", "■")
1342
1389
  : theme.fg("success", "✓");
@@ -1388,10 +1435,18 @@ function renderMultiCompact(d: Details, theme: Theme, frame?: number): Component
1388
1435
  if (rRunning && rProg && "status" in rProg) {
1389
1436
  const activity = compactCurrentActivity(rProg);
1390
1437
  c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${activity}`), width), 0, 0));
1438
+ for (const nestedLine of formatNestedWidgetLines(r.children, theme, width, false, snapshotNowForProgress(rProg))) {
1439
+ c.addChild(new Text(truncLine(` ${nestedLine}`, width), 0, 0));
1440
+ }
1391
1441
  c.addChild(new Text(truncLine(theme.fg("accent", ` ${liveDetailHintText()}`), width), 0, 0));
1392
1442
  } else if (!rPending && (r.exitCode !== 0 || r.interrupted || r.detached || hasEmptyTextOutputWithoutOutputTarget(r.task, output))) {
1393
1443
  c.addChild(new Text(truncLine(theme.fg(r.exitCode !== 0 ? "error" : "dim", ` ⎿ ${resultStatusLine(r, output)}`), width), 0, 0));
1394
1444
  }
1445
+ if (!rRunning && !rPending) {
1446
+ for (const nestedLine of formatNestedWidgetLines(r.children, theme, width, false, r.progress?.lastActivityAt)) {
1447
+ c.addChild(new Text(truncLine(` ${nestedLine}`, width), 0, 0));
1448
+ }
1449
+ }
1395
1450
  const outputTarget = extractOutputTarget(r.task);
1396
1451
  if (outputTarget) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${outputTarget}`), width), 0, 0));
1397
1452
  if (r.artifactPaths) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${shortenPath(r.artifactPaths.outputPath)}`), width), 0, 0));
@@ -1462,6 +1517,9 @@ export function renderSubagentResult(
1462
1517
 
1463
1518
  if (isRunning && r.progress) {
1464
1519
  const progressSnapshotNow = snapshotNowForProgress(r.progress);
1520
+ for (const nestedLine of formatNestedWidgetLines(r.children, theme, w, true, progressSnapshotNow, 12)) {
1521
+ c.addChild(new Text(fit(` ${nestedLine}`), 0, 0));
1522
+ }
1465
1523
  const toolLine = formatCurrentToolLine(r.progress, w, expanded, progressSnapshotNow);
1466
1524
  if (toolLine) {
1467
1525
  c.addChild(new Text(fit(theme.fg("warning", `> ${toolLine}`)), 0, 0));
@@ -1489,6 +1547,10 @@ export function renderSubagentResult(
1489
1547
  if (toolLine || liveStatusLine || r.progress.recentTools?.length || r.progress.recentOutput?.length || r.artifactPaths) {
1490
1548
  c.addChild(new Spacer(1));
1491
1549
  }
1550
+ } else {
1551
+ for (const nestedLine of formatNestedWidgetLines(r.children, theme, w, true, r.progress?.lastActivityAt, 8)) {
1552
+ c.addChild(new Text(fit(` ${nestedLine}`), 0, 0));
1553
+ }
1492
1554
  }
1493
1555
 
1494
1556
  if (expanded) {
@@ -1533,6 +1595,7 @@ export function renderSubagentResult(
1533
1595
  && hasEmptyTextOutputWithoutOutputTarget(r.task, getSingleResultOutput(r)),
1534
1596
  );
1535
1597
  const hasWorkflowFailure = workflowGraphHasStatus(d, ["failed"]);
1598
+ const hasWorkflowStop = d.results.some((r) => r.stopped && r.progress?.status !== "running") || workflowGraphHasStatus(d, ["stopped"]);
1536
1599
  const hasWorkflowPause = workflowGraphHasStatus(d, ["paused", "detached"]);
1537
1600
  const icon = hasRunning
1538
1601
  ? theme.fg("warning", "running")
@@ -1540,8 +1603,10 @@ export function renderSubagentResult(
1540
1603
  ? theme.fg("warning", "warning")
1541
1604
  : hasWorkflowFailure
1542
1605
  ? theme.fg("error", "failed")
1543
- : hasWorkflowPause
1544
- ? theme.fg("warning", "paused")
1606
+ : hasWorkflowStop
1607
+ ? theme.fg("warning", "stopped")
1608
+ : hasWorkflowPause
1609
+ ? theme.fg("warning", "paused")
1545
1610
  : ok === d.results.length
1546
1611
  ? theme.fg("success", "ok")
1547
1612
  : theme.fg("error", "failed");
@@ -1707,6 +1772,9 @@ export function renderSubagentResult(
1707
1772
  if (liveStatusLine) {
1708
1773
  c.addChild(new Text(fit(theme.fg("accent", ` ${liveStatusLine}`)), 0, 0));
1709
1774
  }
1775
+ for (const nestedLine of formatNestedWidgetLines(r.children, theme, w, true, progressSnapshotNow, 8)) {
1776
+ c.addChild(new Text(fit(` ${nestedLine}`), 0, 0));
1777
+ }
1710
1778
  c.addChild(new Text(fit(theme.fg("accent", ` ${liveDetailHintText()}`)), 0, 0));
1711
1779
  if (r.artifactPaths) {
1712
1780
  c.addChild(new Text(fit(theme.fg("dim", ` artifacts: ${shortenPath(r.artifactPaths.outputPath)}`)), 0, 0));
@@ -1726,6 +1794,12 @@ export function renderSubagentResult(
1726
1794
  }
1727
1795
  }
1728
1796
 
1797
+ if (!rRunning) {
1798
+ for (const nestedLine of formatNestedWidgetLines(r.children, theme, w, true, r.progress?.lastActivityAt, 8)) {
1799
+ c.addChild(new Text(fit(` ${nestedLine}`), 0, 0));
1800
+ }
1801
+ }
1802
+
1729
1803
  if (!rRunning && r.artifactPaths) {
1730
1804
  c.addChild(new Text(fit(theme.fg("dim", ` artifacts: ${shortenPath(r.artifactPaths.outputPath)}`)), 0, 0));
1731
1805
  }
@@ -0,0 +1,127 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+
6
+ const IGNORED_CHANGE_PREFIXES = [".pi-subagents/", "tmp/", "node_modules/"];
7
+ const IGNORED_CHANGE_PATHS = new Set([".pi-subagents", "tmp", "node_modules"]);
8
+
9
+ export interface WatchdogRepoChangeSignature {
10
+ root: string;
11
+ key: string;
12
+ changedPaths: string[];
13
+ }
14
+
15
+ function git(cwd: string, args: string[]): string | undefined {
16
+ const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
17
+ if (result.status !== 0) return undefined;
18
+ return result.stdout;
19
+ }
20
+
21
+ function normalizeRelPath(value: string): string {
22
+ return value.replaceAll(path.sep, "/").replace(/^\.\//, "");
23
+ }
24
+
25
+ function ignoredRelPath(relPath: string): boolean {
26
+ const normalized = normalizeRelPath(relPath);
27
+ return IGNORED_CHANGE_PATHS.has(normalized) || IGNORED_CHANGE_PREFIXES.some((prefix) => normalized.startsWith(prefix));
28
+ }
29
+
30
+ function hashFile(filePath: string): string {
31
+ return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
32
+ }
33
+
34
+ function hashPath(root: string, relPath: string): unknown {
35
+ const normalized = normalizeRelPath(relPath);
36
+ const fullPath = path.join(root, normalized);
37
+ let stat: fs.Stats;
38
+ try {
39
+ stat = fs.lstatSync(fullPath);
40
+ } catch (error) {
41
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { path: normalized, state: "deleted" };
42
+ throw error;
43
+ }
44
+ if (stat.isSymbolicLink()) {
45
+ return { path: normalized, state: "symlink", target: fs.readlinkSync(fullPath) };
46
+ }
47
+ if (stat.isDirectory()) {
48
+ const entries = fs.readdirSync(fullPath)
49
+ .map((entry) => normalizeRelPath(path.posix.join(normalized, entry)))
50
+ .filter((entry) => !ignoredRelPath(entry))
51
+ .sort();
52
+ return { path: normalized, state: "dir", entries: entries.map((entry) => hashPath(root, entry)) };
53
+ }
54
+ if (stat.isFile()) {
55
+ return { path: normalized, state: "file", mode: stat.mode & 0o777, size: stat.size, hash: hashFile(fullPath) };
56
+ }
57
+ return { path: normalized, state: "other", mode: stat.mode };
58
+ }
59
+
60
+ function parsePorcelainZ(raw: string): Array<{ status: string; paths: string[] }> {
61
+ const tokens = raw.split("\0").filter(Boolean);
62
+ const entries: Array<{ status: string; paths: string[] }> = [];
63
+ for (let index = 0; index < tokens.length; index++) {
64
+ const token = tokens[index]!;
65
+ if (token.length < 4) continue;
66
+ const status = token.slice(0, 2);
67
+ const relPath = token.slice(3);
68
+ const paths = [relPath];
69
+ if (status[0] === "R" || status[0] === "C") {
70
+ const originalPath = tokens[++index];
71
+ if (originalPath) paths.push(originalPath);
72
+ }
73
+ entries.push({ status, paths });
74
+ }
75
+ return entries;
76
+ }
77
+
78
+ export function computeWatchdogRepoChangeSignature(cwd: string): WatchdogRepoChangeSignature | undefined {
79
+ const root = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
80
+ if (!root) return undefined;
81
+ const statusOutput = git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
82
+ if (statusOutput === undefined) return undefined;
83
+ const entries = parsePorcelainZ(statusOutput)
84
+ .map((entry) => ({
85
+ status: entry.status,
86
+ paths: entry.paths.map(normalizeRelPath).filter((relPath) => !ignoredRelPath(relPath)),
87
+ }))
88
+ .filter((entry) => entry.paths.length > 0)
89
+ .sort((a, b) => `${a.status} ${a.paths.join("\0")}`.localeCompare(`${b.status} ${b.paths.join("\0")}`));
90
+ const changedPaths = [...new Set(entries.flatMap((entry) => entry.paths))].sort();
91
+ const payload = entries.map((entry) => ({
92
+ status: entry.status,
93
+ paths: entry.paths,
94
+ content: entry.paths.map((relPath) => hashPath(root, relPath)),
95
+ }));
96
+ const key = createHash("sha256").update(JSON.stringify(payload)).digest("hex");
97
+ return { root, key, changedPaths };
98
+ }
99
+
100
+ function toolNameFromMessage(message: Record<string, unknown>): string {
101
+ const value = message.toolName ?? message.name;
102
+ return typeof value === "string" ? value : "";
103
+ }
104
+
105
+ function toolResultSucceeded(message: Record<string, unknown>): boolean {
106
+ return message.isError !== true && message.error === undefined;
107
+ }
108
+
109
+ function messageIndicatesRepoEdit(message: unknown): boolean {
110
+ if (!message || typeof message !== "object") return false;
111
+ const input = message as Record<string, unknown>;
112
+ const role = input.role;
113
+ if (role !== "toolResult" && role !== "tool") return false;
114
+ const toolName = toolNameFromMessage(input);
115
+ return (toolName === "edit" || toolName === "write") && toolResultSucceeded(input);
116
+ }
117
+
118
+ export function eventIndicatesRepoEdit(event: unknown): boolean {
119
+ if (!event || typeof event !== "object") return false;
120
+ const input = event as Record<string, unknown>;
121
+ if (input.type === "turn_end" || input.event === "turn_end") {
122
+ return [input.message, ...(Array.isArray(input.toolResults) ? input.toolResults : [])].some(messageIndicatesRepoEdit);
123
+ }
124
+ if (input.type === "tool_result" || input.event === "tool_result") return messageIndicatesRepoEdit({ role: "toolResult", ...input });
125
+ if (input.type !== "tool_result_end" && input.event !== "tool_result_end") return false;
126
+ return messageIndicatesRepoEdit(input.message);
127
+ }
@@ -0,0 +1,205 @@
1
+ import type { ResolvedWatchdogConfig, WatchdogLspConfig } from "./types.ts";
2
+
3
+ export const CHILD_WATCHDOG_CONFIG_ENV = "PI_SUBAGENT_WATCHDOG_CHILD_CONFIG";
4
+ export const CHILD_WATCHDOG_STATUS_EVENT = "subagent.watchdog.status";
5
+
6
+ export const CHILD_WATCHDOG_PHASES = ["idle", "reviewing", "autofollow", "settling", "stale", "failed"] as const;
7
+ export type ChildWatchdogPhase = typeof CHILD_WATCHDOG_PHASES[number];
8
+
9
+ export interface ChildWatchdogConfig {
10
+ enabled: boolean;
11
+ runId?: string;
12
+ agent?: string;
13
+ childIndex?: number;
14
+ watchdogTailTimeoutMs: number;
15
+ agentEndTimeoutMs: number;
16
+ maxWarnings: number | null;
17
+ model?: string;
18
+ thinking?: string | false;
19
+ lsp: WatchdogLspConfig;
20
+ autoFollowBlockers: boolean;
21
+ autoFollowMaxAttempts: number | null;
22
+ stalemateRepeats: number;
23
+ }
24
+
25
+ export interface ChildWatchdogStatusEvent {
26
+ type: typeof CHILD_WATCHDOG_STATUS_EVENT;
27
+ runId?: string;
28
+ agent?: string;
29
+ childIndex?: number;
30
+ stepIndex?: number;
31
+ seq: number;
32
+ phase: ChildWatchdogPhase;
33
+ ts: number;
34
+ followUpPending: boolean;
35
+ reason?: string;
36
+ }
37
+
38
+ export interface ChildWatchdogStateSnapshot {
39
+ phase: ChildWatchdogPhase;
40
+ seq: number;
41
+ lastUpdate: number;
42
+ followUpPending: boolean;
43
+ reason?: string;
44
+ timedOut?: boolean;
45
+ }
46
+
47
+ export function resolveChildWatchdogConfig(input: {
48
+ config: ResolvedWatchdogConfig;
49
+ agent?: string;
50
+ runId?: string;
51
+ childIndex?: number;
52
+ }): ChildWatchdogConfig | undefined {
53
+ const override = input.agent ? input.config.children.overrides[input.agent] : undefined;
54
+ const enabled = input.config.enabled && (override?.enabled ?? input.config.children.enabled);
55
+ if (!enabled) return undefined;
56
+ const model = override?.model ?? input.config.children.model;
57
+ const thinking = override?.thinking ?? input.config.children.thinking;
58
+ return {
59
+ enabled: true,
60
+ ...(input.runId ? { runId: input.runId } : {}),
61
+ ...(input.agent ? { agent: input.agent } : {}),
62
+ ...(input.childIndex !== undefined ? { childIndex: input.childIndex } : {}),
63
+ watchdogTailTimeoutMs: input.config.children.watchdogTailTimeoutMs,
64
+ agentEndTimeoutMs: input.config.agentEndTimeoutMs,
65
+ maxWarnings: input.config.maxWarnings,
66
+ ...(model ? { model } : {}),
67
+ ...(thinking !== undefined ? { thinking } : {}),
68
+ lsp: { ...input.config.lsp },
69
+ autoFollowBlockers: input.config.children.autoFollow.blockers,
70
+ autoFollowMaxAttempts: input.config.children.autoFollow.maxAttempts,
71
+ stalemateRepeats: input.config.children.autoFollow.stalemateRepeats,
72
+ };
73
+ }
74
+
75
+ export function encodeChildWatchdogConfig(config: ChildWatchdogConfig | undefined): string | undefined {
76
+ return config ? JSON.stringify(config) : undefined;
77
+ }
78
+
79
+ function childConfigObject(value: unknown, field: string): Record<string, unknown> {
80
+ if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
81
+ throw new Error(`Invalid child watchdog config: ${field} must be an object.`);
82
+ }
83
+
84
+ function childConfigOptionalString(input: Record<string, unknown>, field: string): string | undefined {
85
+ if (!(field in input)) return undefined;
86
+ const value = input[field];
87
+ if (typeof value === "string" && value.trim()) return value;
88
+ throw new Error(`Invalid child watchdog config: ${field} must be a non-empty string.`);
89
+ }
90
+
91
+ function childConfigOptionalIndex(input: Record<string, unknown>, field: string): number | undefined {
92
+ if (!(field in input)) return undefined;
93
+ const value = input[field];
94
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value;
95
+ throw new Error(`Invalid child watchdog config: ${field} must be a non-negative integer.`);
96
+ }
97
+
98
+ function childConfigPositiveInteger(input: Record<string, unknown>, field: string): number {
99
+ const value = input[field];
100
+ if (typeof value === "number" && Number.isInteger(value) && value >= 1) return value;
101
+ throw new Error(`Invalid child watchdog config: ${field} must be a positive integer.`);
102
+ }
103
+
104
+ function childConfigNullableNonNegativeInteger(input: Record<string, unknown>, field: string): number | null {
105
+ const value = input[field];
106
+ if (value === null) return null;
107
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value;
108
+ throw new Error(`Invalid child watchdog config: ${field} must be null or a non-negative integer.`);
109
+ }
110
+
111
+ function childConfigBoolean(input: Record<string, unknown>, field: string): boolean {
112
+ const value = input[field];
113
+ if (typeof value === "boolean") return value;
114
+ throw new Error(`Invalid child watchdog config: ${field} must be a boolean.`);
115
+ }
116
+
117
+ function childConfigLsp(value: unknown): WatchdogLspConfig {
118
+ const input = childConfigObject(value, "lsp");
119
+ if (typeof input.enabled !== "boolean") throw new Error("Invalid child watchdog config: lsp.enabled must be a boolean.");
120
+ if (typeof input.timeoutMs !== "number" || !Number.isInteger(input.timeoutMs) || input.timeoutMs < 1) {
121
+ throw new Error("Invalid child watchdog config: lsp.timeoutMs must be a positive integer.");
122
+ }
123
+ if (typeof input.maxFiles !== "number" || !Number.isInteger(input.maxFiles) || input.maxFiles < 1) {
124
+ throw new Error("Invalid child watchdog config: lsp.maxFiles must be a positive integer.");
125
+ }
126
+ if (typeof input.maxDiagnostics !== "number" || !Number.isInteger(input.maxDiagnostics) || input.maxDiagnostics < 0) {
127
+ throw new Error("Invalid child watchdog config: lsp.maxDiagnostics must be a non-negative integer.");
128
+ }
129
+ return {
130
+ enabled: input.enabled,
131
+ timeoutMs: input.timeoutMs,
132
+ maxFiles: input.maxFiles,
133
+ maxDiagnostics: input.maxDiagnostics,
134
+ };
135
+ }
136
+
137
+ export function decodeChildWatchdogConfig(raw: string | undefined): ChildWatchdogConfig | undefined {
138
+ if (!raw) return undefined;
139
+ const parsed = childConfigObject(JSON.parse(raw), "root");
140
+ if (parsed.enabled === false) return undefined;
141
+ if (parsed.enabled !== true) throw new Error("Invalid child watchdog config: enabled must be true or false.");
142
+ const thinking = parsed.thinking;
143
+ if (thinking !== undefined && typeof thinking !== "string" && thinking !== false) {
144
+ throw new Error("Invalid child watchdog config: thinking must be a string or false.");
145
+ }
146
+ const runId = childConfigOptionalString(parsed, "runId");
147
+ const agent = childConfigOptionalString(parsed, "agent");
148
+ const childIndex = childConfigOptionalIndex(parsed, "childIndex");
149
+ const model = childConfigOptionalString(parsed, "model");
150
+ return {
151
+ enabled: true,
152
+ ...(runId ? { runId } : {}),
153
+ ...(agent ? { agent } : {}),
154
+ ...(childIndex !== undefined ? { childIndex } : {}),
155
+ watchdogTailTimeoutMs: childConfigPositiveInteger(parsed, "watchdogTailTimeoutMs"),
156
+ agentEndTimeoutMs: childConfigPositiveInteger(parsed, "agentEndTimeoutMs"),
157
+ maxWarnings: childConfigNullableNonNegativeInteger(parsed, "maxWarnings"),
158
+ ...(model ? { model } : {}),
159
+ ...(thinking !== undefined ? { thinking: thinking as string | false } : {}),
160
+ lsp: childConfigLsp(parsed.lsp),
161
+ autoFollowBlockers: childConfigBoolean(parsed, "autoFollowBlockers"),
162
+ autoFollowMaxAttempts: childConfigNullableNonNegativeInteger(parsed, "autoFollowMaxAttempts"),
163
+ stalemateRepeats: childConfigPositiveInteger(parsed, "stalemateRepeats"),
164
+ };
165
+ }
166
+
167
+ export function isChildWatchdogStatusEvent(value: unknown): value is ChildWatchdogStatusEvent {
168
+ if (!value || typeof value !== "object") return false;
169
+ const event = value as Partial<ChildWatchdogStatusEvent>;
170
+ return event.type === CHILD_WATCHDOG_STATUS_EVENT
171
+ && typeof event.seq === "number"
172
+ && Number.isInteger(event.seq)
173
+ && event.seq >= 0
174
+ && typeof event.ts === "number"
175
+ && Number.isFinite(event.ts)
176
+ && typeof event.followUpPending === "boolean"
177
+ && typeof event.phase === "string"
178
+ && (CHILD_WATCHDOG_PHASES as readonly string[]).includes(event.phase);
179
+ }
180
+
181
+ export function childWatchdogIsActive(snapshot: ChildWatchdogStateSnapshot | undefined): boolean {
182
+ if (!snapshot) return false;
183
+ return snapshot.followUpPending || snapshot.phase === "reviewing" || snapshot.phase === "autofollow" || snapshot.phase === "settling";
184
+ }
185
+
186
+ export function acceptChildWatchdogEvent(input: {
187
+ current: ChildWatchdogStateSnapshot | undefined;
188
+ event: ChildWatchdogStatusEvent;
189
+ runId?: string;
190
+ agent?: string;
191
+ childIndex?: number;
192
+ }): ChildWatchdogStateSnapshot | undefined {
193
+ if (input.runId !== undefined && input.event.runId !== input.runId) return undefined;
194
+ if (input.agent !== undefined && input.event.agent !== input.agent) return undefined;
195
+ const eventIndex = input.event.childIndex ?? input.event.stepIndex;
196
+ if (input.childIndex !== undefined && eventIndex !== input.childIndex) return undefined;
197
+ if (input.current && input.event.seq <= input.current.seq) return undefined;
198
+ return {
199
+ phase: input.event.phase,
200
+ seq: input.event.seq,
201
+ lastUpdate: input.event.ts,
202
+ followUpPending: input.event.followUpPending,
203
+ ...(input.event.reason ? { reason: input.event.reason } : {}),
204
+ };
205
+ }