pi-subagents 0.53.0 → 0.55.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 (76) hide show
  1. package/CHANGELOG.md +66 -5
  2. package/README.md +1 -1
  3. package/agents/reviewer.md +12 -2
  4. package/docs/agents.md +4 -4
  5. package/docs/configuration.md +6 -6
  6. package/docs/extension-api.md +14 -4
  7. package/docs/models.md +41 -6
  8. package/docs/observability.md +14 -3
  9. package/docs/tool-reference.md +21 -6
  10. package/docs/workflows.md +7 -1
  11. package/index.ts +10 -1
  12. package/package.json +1 -1
  13. package/prompts/council.md +19 -7
  14. package/prompts/parallel-review.md +5 -1
  15. package/prompts/review-loop.md +9 -5
  16. package/skills/council-mode/SKILL.md +50 -26
  17. package/skills/pi-subagents/SKILL.md +5 -1
  18. package/skills/pi-subagents/references/constraints-and-recipes.md +16 -1
  19. package/skills/pi-subagents/references/execution-controls.md +13 -4
  20. package/skills/pi-subagents/references/multi-lane-orchestration.md +3 -3
  21. package/skills/pi-subagents/references/prompting-and-roles.md +14 -7
  22. package/src/agents/agent-management.ts +115 -14
  23. package/src/agents/agent-serializer.ts +2 -2
  24. package/src/agents/agents.ts +195 -47
  25. package/src/api/external-job-provider.ts +10 -1
  26. package/src/api/preflight.ts +33 -19
  27. package/src/api/project-panes.ts +2 -0
  28. package/src/extension/doctor.ts +10 -0
  29. package/src/extension/fanout-child.ts +3 -2
  30. package/src/extension/index.ts +24 -4
  31. package/src/extension/public-execution.ts +12 -9
  32. package/src/extension/rpc.ts +77 -3
  33. package/src/extension/schemas.ts +2 -1
  34. package/src/extension/tool-description.ts +9 -6
  35. package/src/extension/tool-result.ts +19 -0
  36. package/src/inspectors/herdr/client.ts +3 -3
  37. package/src/inspectors/herdr/focus.ts +55 -0
  38. package/src/inspectors/herdr/project-panes.ts +228 -44
  39. package/src/integrations/herdr-status.ts +26 -4
  40. package/src/runs/background/async-execution.ts +112 -13
  41. package/src/runs/background/async-job-tracker.ts +33 -14
  42. package/src/runs/background/async-resume.ts +20 -2
  43. package/src/runs/background/async-retention.ts +1 -1
  44. package/src/runs/background/async-status.ts +10 -0
  45. package/src/runs/background/chain-root-attachment.ts +5 -0
  46. package/src/runs/background/control-channel.ts +98 -10
  47. package/src/runs/background/notify.ts +62 -1
  48. package/src/runs/background/result-watcher.ts +4 -3
  49. package/src/runs/background/run-status.ts +15 -1
  50. package/src/runs/background/stale-run-reconciler.ts +3 -0
  51. package/src/runs/background/subagent-runner.ts +251 -38
  52. package/src/runs/background/wait-completions.ts +2 -0
  53. package/src/runs/background/wait-tool.ts +4 -3
  54. package/src/runs/foreground/async-stop-action.ts +23 -2
  55. package/src/runs/foreground/execution.ts +110 -8
  56. package/src/runs/foreground/subagent-executor.ts +361 -45
  57. package/src/runs/foreground/workflow-detach-reconcile.ts +3 -0
  58. package/src/runs/shared/acceptance.ts +1 -0
  59. package/src/runs/shared/child-identity.ts +36 -0
  60. package/src/runs/shared/completion-guard.ts +50 -1
  61. package/src/runs/shared/external-job-bridge.ts +53 -37
  62. package/src/runs/shared/external-job-runner.ts +126 -23
  63. package/src/runs/shared/model-fallback.ts +46 -15
  64. package/src/runs/shared/model-scope.ts +106 -39
  65. package/src/runs/shared/orca-progress-tabs.ts +71 -9
  66. package/src/runs/shared/parallel-utils.ts +12 -0
  67. package/src/runs/shared/pi-args.ts +31 -1
  68. package/src/runs/shared/subagent-prompt-runtime.ts +39 -10
  69. package/src/runs/shared/tool-availability.ts +1 -3
  70. package/src/shared/launch-contract.ts +6 -5
  71. package/src/shared/thinking-ceiling.ts +52 -0
  72. package/src/shared/types.ts +72 -5
  73. package/src/tui/fleet-status.ts +66 -13
  74. package/src/tui/render.ts +5 -4
  75. package/src/watchdog/permission-arbiter.ts +59 -51
  76. package/src/workflows/scripted-workflow.ts +107 -10
@@ -55,6 +55,8 @@ export interface StopRequest {
55
55
  ts?: number;
56
56
  source?: string;
57
57
  reason?: string;
58
+ targetIndex?: number;
59
+ childId?: string;
58
60
  }
59
61
 
60
62
  export type SteerDeliveryMode = "steer" | "follow_up" | "auto";
@@ -92,6 +94,7 @@ export interface SteerAck {
92
94
  }
93
95
 
94
96
  const STEER_REQUESTS_DIR = "steer-requests";
97
+ const STOP_REQUESTS_DIR = "stop-requests";
95
98
  const REVIVAL_BRIEFS_DIR = "revival-briefs";
96
99
  export const MAX_STEER_QUEUE_SIZE = 20;
97
100
  const STEER_TARGETS_DIR = "steer-targets";
@@ -121,6 +124,11 @@ export function stopRequestPath(asyncDir: string): string {
121
124
  return path.join(controlInboxDir(asyncDir), "stop.json");
122
125
  }
123
126
 
127
+ /** Directory of parent-to-runner stop requests. */
128
+ export function stopRequestsDir(asyncDir: string): string {
129
+ return path.join(controlInboxDir(asyncDir), STOP_REQUESTS_DIR);
130
+ }
131
+
124
132
  /** Directory of parent-to-runner steering requests. */
125
133
  export function steerRequestsDir(asyncDir: string): string {
126
134
  return path.join(controlInboxDir(asyncDir), STEER_REQUESTS_DIR);
@@ -164,13 +172,24 @@ export function steerAckPathFromDir(dir: string, requestId: string): string {
164
172
  }
165
173
 
166
174
  function assertChildIndex(index: number): void {
167
- if (!Number.isInteger(index) || index < 0 || index > 1_000_000) throw new Error("steer child index must be a non-negative integer.");
175
+ if (!Number.isInteger(index) || index < 0 || index > 1_000_000) throw new Error("child index must be a non-negative integer.");
176
+ }
177
+
178
+ function validStopChildId(childId: unknown): childId is string {
179
+ return typeof childId === "string"
180
+ && Boolean(childId.trim())
181
+ && childId.length <= 256
182
+ && !/[\r\n]/.test(childId);
168
183
  }
169
184
 
170
185
  function steerRequestFileName(request: SteerRequest): string {
171
186
  return `${String(request.ts).padStart(13, "0")}-${Buffer.from(request.id).toString("base64url")}.json`;
172
187
  }
173
188
 
189
+ function stopRequestFileName(request: StopRequest): string {
190
+ return `${String(request.ts ?? 0).padStart(13, "0")}-${randomUUID()}.json`;
191
+ }
192
+
174
193
  function validSteerRequest(request: Partial<SteerRequest>): request is SteerRequest {
175
194
  return request.type === "steer"
176
195
  && typeof request.id === "string"
@@ -279,8 +298,12 @@ export function requestAsyncStop(
279
298
  payload: Omit<StopRequest, "type"> = {},
280
299
  deps: { now?: () => number } = {},
281
300
  ): string {
282
- const requestPath = stopRequestPath(asyncDir);
301
+ if (payload.targetIndex !== undefined) assertChildIndex(payload.targetIndex);
302
+ if (payload.childId !== undefined && !validStopChildId(payload.childId)) {
303
+ throw new Error("stop childId must be a non-empty string without newlines and at most 256 characters.");
304
+ }
283
305
  const request: StopRequest = { ...payload, ts: payload.ts ?? deps.now?.() ?? Date.now(), type: "stop" };
306
+ const requestPath = path.join(stopRequestsDir(asyncDir), stopRequestFileName(request));
284
307
  writeAtomicJson(requestPath, request);
285
308
  return requestPath;
286
309
  }
@@ -521,16 +544,78 @@ export function consumeTimeoutRequest(
521
544
 
522
545
  export function consumeStopRequest(
523
546
  asyncDir: string,
524
- fsImpl: Pick<typeof fs, "existsSync" | "rmSync"> = fs,
547
+ fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs,
525
548
  ): boolean {
526
- const requestPath = stopRequestPath(asyncDir);
527
- if (!fsImpl.existsSync(requestPath)) return false;
549
+ return consumeStopRequestPayload(asyncDir, fsImpl) !== undefined;
550
+ }
551
+
552
+ function parseStopRequest(raw: unknown): StopRequest | undefined {
553
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
554
+ const parsed = raw as Partial<StopRequest>;
555
+ if (parsed.type !== "stop") return undefined;
556
+ if (Object.hasOwn(parsed, "targetIndex") && !(Number.isInteger(parsed.targetIndex) && parsed.targetIndex! >= 0 && parsed.targetIndex! <= 1_000_000)) return undefined;
557
+ if (Object.hasOwn(parsed, "childId") && !validStopChildId(parsed.childId)) return undefined;
558
+ return {
559
+ type: "stop",
560
+ ...(typeof parsed.ts === "number" ? { ts: parsed.ts } : {}),
561
+ ...(typeof parsed.source === "string" ? { source: parsed.source } : {}),
562
+ ...(typeof parsed.reason === "string" ? { reason: parsed.reason } : {}),
563
+ ...(parsed.targetIndex !== undefined ? { targetIndex: parsed.targetIndex } : {}),
564
+ ...(validStopChildId(parsed.childId) ? { childId: parsed.childId } : {}),
565
+ };
566
+ }
567
+
568
+ function consumeStopRequestFile(
569
+ requestPath: string,
570
+ fsImpl: Pick<typeof fs, "rmSync" | "readFileSync">,
571
+ ): StopRequest | undefined {
572
+ let request: StopRequest | undefined;
573
+ try {
574
+ request = parseStopRequest(JSON.parse(fsImpl.readFileSync(requestPath, "utf-8")));
575
+ } catch {
576
+ request = undefined;
577
+ }
528
578
  try {
529
579
  fsImpl.rmSync(requestPath, { force: true, recursive: true });
530
580
  } catch {
531
- // Already removed by a concurrent check — still counts as consumed.
581
+ // Already removed by a concurrent check — do not execute it twice.
582
+ return undefined;
532
583
  }
533
- return true;
584
+ return request;
585
+ }
586
+
587
+ export function consumeStopRequestPayloads(
588
+ asyncDir: string,
589
+ fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs,
590
+ ): StopRequest[] {
591
+ const dir = stopRequestsDir(asyncDir);
592
+ const requests: StopRequest[] = [];
593
+ if (fsImpl.existsSync(dir)) {
594
+ let entries: string[];
595
+ try {
596
+ entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort();
597
+ } catch {
598
+ entries = [];
599
+ }
600
+ for (const entry of entries) {
601
+ const request = consumeStopRequestFile(path.join(dir, entry), fsImpl);
602
+ if (request) requests.push(request);
603
+ }
604
+ }
605
+
606
+ const legacyPath = stopRequestPath(asyncDir);
607
+ if (fsImpl.existsSync(legacyPath)) {
608
+ const request = consumeStopRequestFile(legacyPath, fsImpl);
609
+ if (request) requests.push(request);
610
+ }
611
+ return requests.sort((left, right) => (left.ts ?? 0) - (right.ts ?? 0));
612
+ }
613
+
614
+ export function consumeStopRequestPayload(
615
+ asyncDir: string,
616
+ fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs,
617
+ ): StopRequest | undefined {
618
+ return consumeStopRequestPayloads(asyncDir, fsImpl)[0];
534
619
  }
535
620
 
536
621
  /** Parent side: write the authoritative portable interrupt request. */
@@ -560,8 +645,10 @@ export function deliverStopRequest(input: {
560
645
  signal?: NodeJS.Signals;
561
646
  now?: () => number;
562
647
  source?: string;
648
+ targetIndex?: number;
649
+ childId?: string;
563
650
  }): void {
564
- requestAsyncStop(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
651
+ requestAsyncStop(input.asyncDir, { ...(input.source ? { source: input.source } : {}), ...(input.targetIndex !== undefined ? { targetIndex: input.targetIndex } : {}), ...(input.childId ? { childId: input.childId } : {}) }, { now: input.now });
565
652
  }
566
653
 
567
654
  /**
@@ -575,7 +662,7 @@ export function watchAsyncControlInbox(
575
662
  opts: {
576
663
  onInterrupt: () => void;
577
664
  onTimeout?: () => void;
578
- onStop?: () => void;
665
+ onStop?: (request: StopRequest) => void;
579
666
  onSteer?: (request: SteerRequest) => void;
580
667
  onSteerCapability?: (capability: SteerCapability) => void;
581
668
  onSteerAck?: (ack: SteerAck) => void;
@@ -599,7 +686,7 @@ export function watchAsyncControlInbox(
599
686
  const check = (): void => {
600
687
  if (disposed) return;
601
688
  try {
602
- if (consumeStopRequest(asyncDir, fsImpl)) opts.onStop?.();
689
+ for (const stopRequest of consumeStopRequestPayloads(asyncDir, fsImpl)) opts.onStop?.(stopRequest);
603
690
  if (consumeTimeoutRequest(asyncDir, fsImpl)) opts.onTimeout?.();
604
691
  if (consumeInterruptRequest(asyncDir, fsImpl)) opts.onInterrupt();
605
692
  for (const request of consumeSteerRequests(asyncDir, fsImpl)) opts.onSteer?.(request);
@@ -651,6 +738,7 @@ export function watchAsyncControlInbox(
651
738
  try {
652
739
  if (shouldUseNativeFsWatch("runner-control-inbox", opts.platform)) {
653
740
  watchDir(dir);
741
+ watchDir(stopRequestsDir(asyncDir), true);
654
742
  watchDir(steerRequestsDir(asyncDir), true);
655
743
  watchDir(steerCapabilitiesDir(asyncDir), true);
656
744
  watchDir(path.join(dir, STEER_ACKS_DIR), true);
@@ -24,6 +24,9 @@ export interface SubagentNotifyDetails {
24
24
  taskInfo?: string;
25
25
  resultPreview: string;
26
26
  durationMs?: number;
27
+ workflowRunId?: string;
28
+ childRuns?: Array<{ runId: string; workflowKey?: string; agent?: string; status?: string }>;
29
+ reconciledFromDetachedChild?: string;
27
30
  sessionLabel?: string;
28
31
  sessionValue?: string;
29
32
  handoffPath?: string;
@@ -40,12 +43,18 @@ export interface CompletionNotification {
40
43
  summary?: string;
41
44
  exitCode?: number;
42
45
  state?: string;
46
+ mode?: string;
47
+ runId?: string | null;
48
+ reconciledFromDetachedChild?: string;
43
49
  processSignal?: string | null;
44
50
  interrupted?: boolean;
45
51
  timedOut?: boolean;
46
52
  stopped?: boolean;
47
53
  turnBudgetExceeded?: boolean;
48
54
  results?: Array<{
55
+ runId?: string;
56
+ workflowKey?: string;
57
+ agent?: string;
49
58
  status?: string;
50
59
  success?: boolean;
51
60
  exitCode?: number | null;
@@ -94,8 +103,23 @@ function formatSessionLine(details: SubagentNotifyDetails): string | undefined {
94
103
  return details.sessionLabel ? `${details.sessionLabel}: ${details.sessionValue}` : details.sessionValue;
95
104
  }
96
105
 
106
+ function formatChildRun(child: { runId: string; workflowKey?: string; agent?: string; status?: string }): string {
107
+ const label = child.workflowKey ?? child.agent;
108
+ const status = child.status ? ` (${child.status})` : "";
109
+ return `${label ? `${label}=` : ""}${child.runId}${status}`;
110
+ }
111
+
112
+ function formatCorrelationLines(details: SubagentNotifyDetails): string[] {
113
+ return [
114
+ details.workflowRunId ? `Workflow run: ${details.workflowRunId}` : undefined,
115
+ details.childRuns?.length ? `Child runs: ${details.childRuns.map(formatChildRun).join(", ")}` : undefined,
116
+ details.reconciledFromDetachedChild ? `Reconciled detached child: ${details.reconciledFromDetachedChild}` : undefined,
117
+ ].filter((line): line is string => line !== undefined);
118
+ }
119
+
97
120
  export function formatSingleCompletion(details: SubagentNotifyDetails): string {
98
121
  const sessionLine = formatSessionLine(details);
122
+ const correlationLines = formatCorrelationLines(details);
99
123
  const taskKind = details.source === "foreground" ? "Detached foreground task" : "Background task";
100
124
  const scheduleLine = details.scheduleOrigin
101
125
  ? `Scheduled run from **${details.scheduleOrigin.name ?? details.scheduleOrigin.id}** (schedule ${details.scheduleOrigin.id}).`
@@ -108,6 +132,8 @@ export function formatSingleCompletion(details: SubagentNotifyDetails): string {
108
132
  details.resultPreview.trim() ? details.resultPreview : "(no output)",
109
133
  details.handoffPath ? "" : undefined,
110
134
  details.handoffPath ? `Parallel handoff: ${details.handoffPath}` : undefined,
135
+ correlationLines.length && !details.handoffPath ? "" : undefined,
136
+ ...correlationLines,
111
137
  sessionLine ? "" : undefined,
112
138
  sessionLine,
113
139
  ]
@@ -139,11 +165,27 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
139
165
  }
140
166
  const sessionLine = sessionIndex >= 0 ? body[sessionIndex] : undefined;
141
167
  const handoffIndex = body.findIndex((line) => line.startsWith("Parallel handoff: "));
142
- const metadataIndexes = [sessionIndex, handoffIndex].filter((index) => index >= 0);
168
+ const workflowRunIndex = body.findIndex((line) => line.startsWith("Workflow run: "));
169
+ const childRunsIndex = body.findIndex((line) => line.startsWith("Child runs: "));
170
+ const reconciledIndex = body.findIndex((line) => line.startsWith("Reconciled detached child: "));
171
+ const metadataIndexes = [sessionIndex, handoffIndex, workflowRunIndex, childRunsIndex, reconciledIndex].filter((index) => index >= 0);
143
172
  const firstMetadataIndex = metadataIndexes.length ? Math.min(...metadataIndexes) : body.length;
144
173
  const resultEnd = firstMetadataIndex > 0 && body[firstMetadataIndex - 1]?.trim() === "" ? firstMetadataIndex - 1 : firstMetadataIndex;
145
174
  const resultPreview = body.slice(0, resultEnd).join("\n").trim() || "(no output)";
146
175
  const handoffPath = handoffIndex >= 0 ? body[handoffIndex]!.slice("Parallel handoff: ".length).trim() : undefined;
176
+ const workflowRunId = workflowRunIndex >= 0 ? body[workflowRunIndex]!.slice("Workflow run: ".length).trim() : undefined;
177
+ const childRuns = childRunsIndex >= 0
178
+ ? body[childRunsIndex]!.slice("Child runs: ".length).split(", ").map((part) => {
179
+ const trimmed = part.trim();
180
+ const statusMatch = trimmed.match(/^(.*?)(?: \(([^)]*)\))?$/);
181
+ const raw = statusMatch?.[1] ?? trimmed;
182
+ const separator = raw.indexOf("=");
183
+ return separator >= 0
184
+ ? { workflowKey: raw.slice(0, separator), runId: raw.slice(separator + 1), ...(statusMatch?.[2] ? { status: statusMatch[2] } : {}) }
185
+ : { runId: raw, ...(statusMatch?.[2] ? { status: statusMatch[2] } : {}) };
186
+ }).filter((child) => child.runId)
187
+ : undefined;
188
+ const reconciledFromDetachedChild = reconciledIndex >= 0 ? body[reconciledIndex]!.slice("Reconciled detached child: ".length).trim() : undefined;
147
189
  let sessionLabel: string | undefined;
148
190
  let sessionValue: string | undefined;
149
191
  if (sessionLine) {
@@ -159,6 +201,9 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
159
201
  ...(parsedScheduleOrigin ? { scheduleOrigin: parsedScheduleOrigin } : {}),
160
202
  resultPreview,
161
203
  ...(handoffPath ? { handoffPath } : {}),
204
+ ...(workflowRunId ? { workflowRunId } : {}),
205
+ ...(childRuns?.length ? { childRuns } : {}),
206
+ ...(reconciledFromDetachedChild ? { reconciledFromDetachedChild } : {}),
162
207
  ...(sessionLabel && sessionValue ? { sessionLabel, sessionValue } : {}),
163
208
  };
164
209
  }
@@ -173,6 +218,7 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
173
218
  blocks.push(`${index + 1}. ${detail.agent}${detail.taskInfo ?? ""}${detail.scheduleOrigin ? ` — scheduled run from ${detail.scheduleOrigin.name ?? detail.scheduleOrigin.id} (schedule ${detail.scheduleOrigin.id})` : ""}`);
174
219
  blocks.push(detail.resultPreview.trim() ? detail.resultPreview : "(no output)");
175
220
  if (detail.handoffPath) blocks.push(`Parallel handoff: ${detail.handoffPath}`);
221
+ blocks.push(...formatCorrelationLines(detail));
176
222
  if (sessionLine) blocks.push(sessionLine);
177
223
  blocks.push("");
178
224
  }
@@ -237,6 +283,18 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
237
283
  ? result.parallelHandoff as { path?: unknown }
238
284
  : undefined;
239
285
  const handoffPath = typeof parallelHandoff?.path === "string" ? parallelHandoff.path : undefined;
286
+ const rawRunId = typeof result.runId === "string" ? result.runId : typeof result.id === "string" ? result.id : undefined;
287
+ const workflowRunId = (result.mode === "workflow" || agent === "workflow") && rawRunId ? rawRunId : undefined;
288
+ const childRuns = result.results?.flatMap((child) => {
289
+ if (typeof child.runId !== "string" || !child.runId.trim()) return [];
290
+ return [{
291
+ runId: child.runId,
292
+ ...(typeof child.workflowKey === "string" ? { workflowKey: child.workflowKey } : {}),
293
+ ...(typeof child.agent === "string" ? { agent: child.agent } : {}),
294
+ ...(typeof child.status === "string" ? { status: child.status } : {}),
295
+ }];
296
+ }) ?? [];
297
+ const reconciledFromDetachedChild = typeof result.reconciledFromDetachedChild === "string" ? result.reconciledFromDetachedChild : undefined;
240
298
  const session =
241
299
  result.shareUrl
242
300
  ? { label: "Session", value: result.shareUrl }
@@ -258,6 +316,9 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
258
316
  resultPreview: summary,
259
317
  ...(typeof result.durationMs === "number" ? { durationMs: result.durationMs } : {}),
260
318
  ...(handoffPath ? { handoffPath } : {}),
319
+ ...(workflowRunId ? { workflowRunId } : {}),
320
+ ...(childRuns.length ? { childRuns } : {}),
321
+ ...(reconciledFromDetachedChild ? { reconciledFromDetachedChild } : {}),
261
322
  ...(session ? { sessionLabel: session.label, sessionValue: session.value } : {}),
262
323
  };
263
324
  }
@@ -57,7 +57,7 @@ type ResultWatcherDeps = {
57
57
  coalesceDelayMs?: number;
58
58
  /** Returns true while a durable completion source needs periodic delivery checks. */
59
59
  hasDeliveryDemand?: () => boolean;
60
- /** Control how slow result-index scans are logged. Defaults to \"all\". */
60
+ /** Control how slow result-index scans are logged. Defaults to \"activity\". */
61
61
  resultScanLogging?: "all" | "activity" | "off";
62
62
  platform?: NodeJS.Platform;
63
63
  };
@@ -606,12 +606,13 @@ export function createResultWatcher(
606
606
  const logScanStats = (stats: ResultScanStats) => {
607
607
  const elapsed = Date.now() - stats.startedAt;
608
608
  if (elapsed < SLOW_RESULT_SCAN_MS) return;
609
- if (deps.resultScanLogging === "off") return;
609
+ const resultScanLogging = deps.resultScanLogging ?? "activity";
610
+ if (resultScanLogging === "off") return;
610
611
  // A scan that inspected and scheduled nothing is a quiet no-op (e.g. the
611
612
  // healthy periodic rescan while no async runs are pending). Under
612
613
  // "activity", skip it so empty scans do not burn context tokens in the
613
614
  // session transcript.
614
- if (deps.resultScanLogging === "activity" && stats.files === 0 && stats.scheduled === 0) return;
615
+ if (resultScanLogging === "activity" && stats.files === 0 && stats.scheduled === 0) return;
615
616
  console.error(`Subagent result scan inspected ${stats.files} indexed result file(s), scheduled ${stats.scheduled} in ${elapsed}ms (${resultsDir}).`);
616
617
  };
617
618
  const indexedResultCandidates = (observed: ReadonlySet<string>): string[] => {
@@ -22,6 +22,7 @@ import { attachRootChildrenToSteps, findNestedRouteForRootId, projectNestedRegis
22
22
  import { readMissionBinding } from "../../missions/lifecycle.ts";
23
23
  import { formatWorkflowJsonPreview } from "../../workflows/scripted-workflow.ts";
24
24
  import { formatRunFanoutBudget, getRunFanoutBudgetSnapshot, readRunFanoutBudgetDescriptor } from "../shared/run-fanout-budget.ts";
25
+ import { getExternalJobProvider } from "../../api/external-job-provider.ts";
25
26
 
26
27
  interface RunStatusParams {
27
28
  action?: string;
@@ -161,6 +162,14 @@ function formatSteeringSummary(input: { steering?: SteeringStatus }): string | u
161
162
  return `${steering.requested} requested, ${steering.scheduled} scheduled, ${steering.pending} pending, ${steering.delivered} delivered, ${steering.failed} failed, ${steering.recovered} recovered${lateAcknowledgments ? `, ${lateAcknowledgments} late acknowledged` : ""}`;
162
163
  }
163
164
 
165
+ function externalJobFollowUpSupported(provider: string): boolean {
166
+ try {
167
+ return typeof getExternalJobProvider(provider)?.followUp === "function";
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+
164
173
  function rememberedForegroundChildOutput(child: ForegroundResumeRun["children"][number]): string {
165
174
  const outputPath = child.artifactPaths?.outputPath ?? child.savedOutputPath;
166
175
  if (outputPath && fs.existsSync(outputPath)) {
@@ -513,6 +522,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
513
522
  && Boolean(control.workflowSteeringDir && fs.existsSync(control.workflowSteeringDir))
514
523
  && (control.activeChildren?.size ?? 0) > 0)
515
524
  : [];
525
+ let hasExternalJobFollowUpHint = false;
516
526
  for (const [index, step] of (status.steps ?? []).entries()) {
517
527
  const stepActivityText = step.status === "running" ? formatActivityLabel(step.lastActivityAt, step.activityState) : undefined;
518
528
  const modelThinking = formatModelThinking(step.model, step.thinking);
@@ -535,6 +545,10 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
535
545
  if (step.externalJob?.state) lines.push(` Provider state: ${step.externalJob.state}`);
536
546
  if (step.externalJob?.conversationUrl) lines.push(` Conversation: ${step.externalJob.conversationUrl}`);
537
547
  if (step.externalJob?.resultArtifactPath) lines.push(` Result artifact: ${step.externalJob.resultArtifactPath}`);
548
+ if ((step.status === "complete" || step.status === "completed") && step.externalJob?.state === "completed" && step.externalJob.providerJobId && externalJobFollowUpSupported(step.runner.provider)) {
549
+ hasExternalJobFollowUpHint = true;
550
+ lines.push(` Follow-up: subagent({ action: "resume", id: "${status.runId}", index: ${index}, message: "..." })`);
551
+ }
538
552
  }
539
553
  lines.push(...formatNestedRunStatusLines(step.children, { indent: " ", commandHints: true, maxLines: 20 }));
540
554
  const stepOutputPath = path.join(asyncDir, `output-${index}.log`);
@@ -563,7 +577,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
563
577
  if (status.state === "running" && !allExternal && status.mode !== "workflow") lines.push(`Steer running child: subagent({ action: "steer", id: "${status.runId}", message: "..." })`);
564
578
  if (status.state !== "running") {
565
579
  lines.push(allExternal
566
- ? "Resume: unavailable; external runners do not persist Pi sessions."
580
+ ? hasExternalJobFollowUpHint ? "Resume: use the external-job follow-up hint above." : "Resume: unavailable; external runners do not persist Pi sessions."
567
581
  : formatResumeGuidance(status.runId, status.steps ?? [], status.sessionFile, { stopped: status.state === "stopped" || status.stopped === true }));
568
582
  }
569
583
  if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
@@ -97,6 +97,7 @@ interface ResultChildOutcome {
97
97
  thinking?: string;
98
98
  attemptedModels?: string[];
99
99
  modelAttempts?: NonNullable<AsyncStatus["steps"]>[number]["modelAttempts"];
100
+ contextOverflow?: boolean;
100
101
  }
101
102
 
102
103
  interface ResultRepairData {
@@ -163,6 +164,7 @@ function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now:
163
164
  thinking,
164
165
  attemptedModels: child?.attemptedModels ?? step.attemptedModels,
165
166
  modelAttempts: child?.modelAttempts ?? step.modelAttempts,
167
+ contextOverflow: child?.contextOverflow ?? step.contextOverflow,
166
168
  };
167
169
  });
168
170
  const terminalStatus: AsyncStatus = {
@@ -258,6 +260,7 @@ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, r
258
260
  model: step.model,
259
261
  attemptedModels: step.attemptedModels,
260
262
  modelAttempts: step.modelAttempts,
263
+ contextOverflow: step.contextOverflow,
261
264
  sessionFile: step.sessionFile,
262
265
  })),
263
266
  exitCode: 1,