pi-subagents 0.54.0 → 0.56.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 (71) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/agents/reviewer.md +12 -2
  3. package/docs/agents.md +4 -4
  4. package/docs/configuration.md +4 -4
  5. package/docs/extension-api.md +14 -4
  6. package/docs/models.md +30 -3
  7. package/docs/observability.md +14 -3
  8. package/docs/tool-reference.md +21 -6
  9. package/docs/workflows.md +7 -1
  10. package/package.json +1 -1
  11. package/prompts/council.md +9 -0
  12. package/prompts/parallel-review.md +5 -1
  13. package/prompts/review-loop.md +9 -5
  14. package/skills/council-mode/SKILL.md +33 -10
  15. package/skills/pi-subagents/references/constraints-and-recipes.md +16 -1
  16. package/skills/pi-subagents/references/execution-controls.md +11 -4
  17. package/skills/pi-subagents/references/multi-lane-orchestration.md +3 -3
  18. package/skills/pi-subagents/references/prompting-and-roles.md +8 -8
  19. package/src/agents/agent-management.ts +30 -9
  20. package/src/agents/agent-serializer.ts +4 -2
  21. package/src/agents/agents.ts +168 -41
  22. package/src/api/external-job-provider.ts +10 -1
  23. package/src/api/preflight.ts +45 -17
  24. package/src/api/project-panes.ts +2 -0
  25. package/src/extension/index.ts +20 -1
  26. package/src/extension/public-execution.ts +12 -9
  27. package/src/extension/rpc.ts +77 -3
  28. package/src/extension/schemas.ts +7 -1
  29. package/src/extension/tool-description.ts +9 -6
  30. package/src/inspectors/herdr/focus.ts +55 -0
  31. package/src/inspectors/herdr/project-panes.ts +228 -44
  32. package/src/integrations/herdr-status.ts +26 -4
  33. package/src/runs/background/async-execution.ts +137 -13
  34. package/src/runs/background/async-job-tracker.ts +33 -14
  35. package/src/runs/background/async-resume.ts +23 -2
  36. package/src/runs/background/async-status.ts +10 -0
  37. package/src/runs/background/control-channel.ts +98 -10
  38. package/src/runs/background/notify.ts +62 -1
  39. package/src/runs/background/run-status.ts +15 -1
  40. package/src/runs/background/subagent-runner.ts +308 -47
  41. package/src/runs/foreground/async-stop-action.ts +23 -2
  42. package/src/runs/foreground/execution.ts +115 -12
  43. package/src/runs/foreground/foreground-history.ts +3 -2
  44. package/src/runs/foreground/subagent-executor.ts +404 -45
  45. package/src/runs/foreground/workflow-detach-reconcile.ts +18 -6
  46. package/src/runs/shared/acceptance.ts +11 -5
  47. package/src/runs/shared/agent-contract.ts +1 -1
  48. package/src/runs/shared/child-identity.ts +36 -0
  49. package/src/runs/shared/completion-guard.ts +52 -2
  50. package/src/runs/shared/dynamic-fanout.ts +1 -1
  51. package/src/runs/shared/extension-bindings.ts +78 -0
  52. package/src/runs/shared/external-cli-runner.ts +2 -0
  53. package/src/runs/shared/external-job-bridge.ts +53 -37
  54. package/src/runs/shared/external-job-runner.ts +126 -23
  55. package/src/runs/shared/fast-mode-extension.ts +10 -0
  56. package/src/runs/shared/model-exclusions.ts +2 -1
  57. package/src/runs/shared/model-fallback.ts +12 -7
  58. package/src/runs/shared/mutation-evidence.ts +145 -0
  59. package/src/runs/shared/orca-progress-tabs.ts +71 -9
  60. package/src/runs/shared/parallel-utils.ts +15 -0
  61. package/src/runs/shared/pi-args.ts +56 -12
  62. package/src/runs/shared/structured-output.ts +18 -4
  63. package/src/runs/shared/subagent-prompt-runtime.ts +12 -6
  64. package/src/runs/shared/tool-availability.ts +1 -3
  65. package/src/shared/launch-contract.ts +12 -5
  66. package/src/shared/settings.ts +6 -1
  67. package/src/shared/thinking-ceiling.ts +52 -0
  68. package/src/shared/types.ts +107 -2
  69. package/src/tui/fleet-status.ts +66 -13
  70. package/src/tui/render.ts +5 -4
  71. package/src/workflows/scripted-workflow.ts +156 -12
@@ -15,14 +15,18 @@ import { readProcessTerminal, sanitizeProcessTerminal } from "./process-terminal
15
15
  import { ACTIVE_RUN_INDEX_DIR, DEFAULT_STALE_TERMINAL_ACTIVE_MARKER_MS, activeRunMarkerAgeMs, isActiveAsyncState, readActiveRunIndex, releaseActiveRunIndex, updateActiveRunIndex } from "./active-run-index.ts";
16
16
  import { readRecentTerminalRunIndex, TERMINAL_RUN_INDEX_DIR } from "./terminal-run-index.ts";
17
17
  import { canScanAsyncRunPrefix } from "./run-id-query.ts";
18
+ import { asyncStatusChildIdentity } from "../shared/child-identity.ts";
18
19
 
19
20
  interface AsyncRunStepSummary {
20
21
  index: number;
22
+ childId?: string;
21
23
  agent: string;
22
24
  context?: ContextMode;
23
25
  label?: string;
24
26
  description?: string;
25
27
  phase?: string;
28
+ workflowKey?: string;
29
+ runId?: string;
26
30
  outputName?: string;
27
31
  structured?: boolean;
28
32
  status: AsyncJobStep["status"];
@@ -251,11 +255,14 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
251
255
  const stepLastActivityAt = step.lastActivityAt;
252
256
  return {
253
257
  index,
258
+ childId: asyncStatusChildIdentity(step, index),
254
259
  agent: step.agent,
255
260
  ...(step.context ? { context: step.context } : {}),
256
261
  ...(step.label ? { label: step.label } : {}),
257
262
  ...(step.description ? { description: step.description } : {}),
258
263
  ...(step.phase ? { phase: step.phase } : {}),
264
+ ...(step.workflowKey ? { workflowKey: step.workflowKey } : {}),
265
+ ...(step.runId ? { runId: step.runId } : {}),
259
266
  ...(step.outputName ? { outputName: step.outputName } : {}),
260
267
  ...(step.structured ? { structured: step.structured } : {}),
261
268
  status: step.status,
@@ -277,12 +284,15 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
277
284
  ...(step.skills ? { skills: step.skills } : {}),
278
285
  ...(step.model ? { model: step.model } : {}),
279
286
  ...(step.thinking ? { thinking: step.thinking } : {}),
287
+ ...(step.thinkingCeiling ? { thinkingCeiling: step.thinkingCeiling } : {}),
280
288
  ...(step.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
281
289
  ...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
282
290
  ...(step.transcriptPath ? { transcriptPath: step.transcriptPath } : {}),
283
291
  ...(step.error ? { error: step.error } : {}),
284
292
  ...(step.timedOut !== undefined ? { timedOut: step.timedOut } : {}),
285
293
  ...(step.stopped !== undefined ? { stopped: step.stopped } : {}),
294
+ ...(step.stopRequested !== undefined ? { stopRequested: step.stopRequested } : {}),
295
+ ...(step.stopRequestedAt !== undefined ? { stopRequestedAt: step.stopRequestedAt } : {}),
286
296
  ...(step.turnBudget ? { turnBudget: step.turnBudget } : {}),
287
297
  ...(step.turnBudgetExceeded !== undefined ? { turnBudgetExceeded: step.turnBudgetExceeded } : {}),
288
298
  ...(step.wrapUpRequested !== undefined ? { wrapUpRequested: step.wrapUpRequested } : {}),
@@ -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
  }
@@ -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}`);