pi-subagents 0.43.0 → 0.45.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.
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { DIRS, type SubagentState } from "../../shared/types.ts";
4
+ import { readStatus } from "../../shared/utils.ts";
4
5
  import { findAsyncRunPrefixMatches, type AsyncRunLocation } from "./async-resume.ts";
5
6
  import { assertSafeNestedId, findNestedRunMatchesById, type NestedRoute, type NestedRunMatch, type NestedRunResolutionScope } from "../shared/nested-events.ts";
6
7
 
@@ -27,6 +28,70 @@ function exactAsyncLocation(id: string, asyncDirRoot: string, resultsDir: string
27
28
  };
28
29
  }
29
30
 
31
+ type AsyncRunMatch = { id: string; location: AsyncRunLocation };
32
+
33
+ type WorkflowResultIdentity = {
34
+ id?: string;
35
+ runId?: string;
36
+ toolCallId?: string;
37
+ };
38
+
39
+ function readWorkflowResultIdentity(resultPath: string): WorkflowResultIdentity | undefined {
40
+ let parsed: unknown;
41
+ try {
42
+ parsed = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as unknown;
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
47
+ const record = parsed as Record<string, unknown>;
48
+ return {
49
+ ...(typeof record.id === "string" ? { id: record.id } : {}),
50
+ ...(typeof record.runId === "string" ? { runId: record.runId } : {}),
51
+ ...(typeof record.toolCallId === "string" ? { toolCallId: record.toolCallId } : {}),
52
+ };
53
+ }
54
+
55
+ function directoryEntries(root: string): string[] {
56
+ try {
57
+ return fs.readdirSync(root);
58
+ } catch (error) {
59
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ function resultPathFor(resultsDir: string, runId: string): string | null {
65
+ const resultPath = path.join(resultsDir, `${runId}.json`);
66
+ return fs.existsSync(resultPath) ? resultPath : null;
67
+ }
68
+
69
+ function toolCallIdMatches(value: string | undefined, query: string, options: { prefix?: boolean }): boolean {
70
+ if (value === undefined) return false;
71
+ return options.prefix === true ? value.startsWith(query) : value === query;
72
+ }
73
+
74
+ function toolCallIdAsyncLocations(toolCallId: string, asyncDirRoot: string, resultsDir: string, options: { prefix?: boolean } = {}): AsyncRunMatch[] {
75
+ const byId = new Map<string, AsyncRunLocation>();
76
+ for (const entry of directoryEntries(asyncDirRoot)) {
77
+ const asyncDir = path.join(asyncDirRoot, entry);
78
+ const status = readStatus(asyncDir);
79
+ if (!status || !toolCallIdMatches(status.toolCallId, toolCallId, options)) continue;
80
+ const runId = status.runId || entry;
81
+ byId.set(runId, { asyncDir, resultPath: resultPathFor(resultsDir, runId), resolvedId: runId });
82
+ }
83
+ for (const entry of directoryEntries(resultsDir)) {
84
+ if (!entry.endsWith(".json")) continue;
85
+ const resultPath = path.join(resultsDir, entry);
86
+ const identity = readWorkflowResultIdentity(resultPath);
87
+ if (!identity || !toolCallIdMatches(identity.toolCallId, toolCallId, options)) continue;
88
+ const runId = identity.runId ?? identity.id ?? entry.slice(0, -".json".length);
89
+ const asyncDir = path.join(asyncDirRoot, runId);
90
+ byId.set(runId, { asyncDir: fs.existsSync(asyncDir) ? asyncDir : null, resultPath, resolvedId: runId });
91
+ }
92
+ return [...byId.entries()].map(([id, location]) => ({ id, location }));
93
+ }
94
+
30
95
  function foregroundIds(state: SubagentState | undefined): string[] {
31
96
  if (!state) return [];
32
97
  const remembered = state.currentSessionId
@@ -73,6 +138,9 @@ export function resolveSubagentRunId(id: string, deps: ResolveSubagentRunIdDeps
73
138
  if (hasExactForegroundId(deps.state, id)) return { kind: "foreground", id };
74
139
  const exactAsync = exactAsyncLocation(id, asyncDirRoot, resultsDir);
75
140
  if (exactAsync) return { kind: "async", id, location: exactAsync };
141
+ const exactToolCallIdMatches = toolCallIdAsyncLocations(id, asyncDirRoot, resultsDir);
142
+ if (exactToolCallIdMatches.length > 1) throw new Error(`Subagent tool-call id '${id}' is ambiguous across async runs. Use the returned asyncId instead.`);
143
+ if (exactToolCallIdMatches[0]) return { kind: "async", id: exactToolCallIdMatches[0].id, location: exactToolCallIdMatches[0].location };
76
144
  const exactNested = findNestedRunMatchesById(id, nestedScope ? { scope: nestedScope } : {});
77
145
  if (exactNested.length > 1) throw new Error(`Nested run id '${id}' is ambiguous across authorized registries. Provide the full id after stale registries are cleaned up.`);
78
146
  if (exactNested[0]) return { kind: "nested", id, match: exactNested[0] };
@@ -84,6 +152,9 @@ export function resolveSubagentRunId(id: string, deps: ResolveSubagentRunIdDeps
84
152
  for (const match of asyncPrefixMatches(id, asyncDirRoot, resultsDir)) {
85
153
  matches.push({ kind: "async", id: match.id, location: match.location });
86
154
  }
155
+ for (const match of toolCallIdAsyncLocations(id, asyncDirRoot, resultsDir, { prefix: true })) {
156
+ matches.push({ kind: "async", id: match.id, location: match.location });
157
+ }
87
158
  for (const match of findNestedRunMatchesById(id, nestedScope ? { prefix: true, scope: nestedScope } : { prefix: true })) {
88
159
  matches.push({ kind: "nested", id: match.run.id, match });
89
160
  }
@@ -382,6 +382,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
382
382
  const workflowEmitPreview = status.workflow?.emits.length ? formatWorkflowJsonPreview(status.workflow.emits.at(-1), 240) : undefined;
383
383
  const lines = [
384
384
  `Run: ${status.runId}`,
385
+ status.toolCallId ? `Tool call: ${status.toolCallId}` : undefined,
385
386
  missionId ? `Mission: ${missionId}` : undefined,
386
387
  `State: ${status.state}`,
387
388
  processTerminal ? `Process terminal: ${processTerminal.state}${processTerminal.reason ? ` (${processTerminal.reason})` : ""}` : undefined,
@@ -454,7 +455,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
454
455
  if (resultPath) {
455
456
  try {
456
457
  const raw = fs.readFileSync(resultPath, "utf-8");
457
- const data = JSON.parse(raw) as { id?: string; runId?: string; agent?: string; success?: boolean; summary?: string; output?: string; exitCode?: number; state?: string; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; processSignal?: string | null; sessionFile?: string; parallelHandoff?: { path?: string }; results?: Array<{ agent?: string; output?: string; summary?: string; sessionFile?: string; state?: string; success?: boolean; exitCode?: number | null; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; interrupted?: boolean; processSignal?: string | null }> };
458
+ const data = JSON.parse(raw) as { id?: string; runId?: string; toolCallId?: string; agent?: string; success?: boolean; summary?: string; output?: string; exitCode?: number; state?: string; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; processSignal?: string | null; sessionFile?: string; parallelHandoff?: { path?: string }; results?: Array<{ agent?: string; output?: string; summary?: string; sessionFile?: string; state?: string; success?: boolean; exitCode?: number | null; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; interrupted?: boolean; processSignal?: string | null }> };
458
459
  if (params.view === "transcript") {
459
460
  try {
460
461
  return { content: [{ type: "text", text: formatAsyncResultTranscript(data, resultPath, { index: params.index, lines: params.lines }) }], details: { mode: "single", results: [] } };
@@ -479,7 +480,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
479
480
  ? "stopped"
480
481
  : data.success ? "complete" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
481
482
  const runId = data.runId ?? data.id ?? resolvedId;
482
- const lines = [`Run: ${runId}`, `State: ${status}`, `Result: ${resultPath}`];
483
+ const lines = [`Run: ${runId}`, data.toolCallId ? `Tool call: ${data.toolCallId}` : undefined, `State: ${status}`, `Result: ${resultPath}`].filter((line): line is string => Boolean(line));
483
484
  if (data.parallelHandoff?.path) lines.push(`Parallel handoff: ${data.parallelHandoff.path}`);
484
485
  const children = Array.isArray(data.results) ? data.results : data.agent ? [{ agent: data.agent, sessionFile: data.sessionFile }] : [];
485
486
  lines.push(formatResumeGuidance(runId, children, data.sessionFile, { stopped: status === "stopped" }));
@@ -69,7 +69,7 @@ import {
69
69
  import { applyThinkingSuffix, buildPiArgs, cleanupTempDir, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan } from "../shared/pi-args.ts";
70
70
  import { readRuntimeAcknowledgedExtensions } from "../shared/runtime-acknowledged-extensions.ts";
71
71
  import { outputEntryFromAsyncResult, resolveOutputReferences } from "../shared/chain-outputs.ts";
72
- import { createStructuredOutputRuntime, readStructuredOutput } from "../shared/structured-output.ts";
72
+ import { createStructuredOutputRuntime, MISSING_STRUCTURED_OUTPUT_CALL_ERROR, readStructuredOutput } from "../shared/structured-output.ts";
73
73
  import { formatProcessSignalError, isUnexplainedProcessSignal } from "../shared/process-signal.ts";
74
74
  import { readChildToolDiagnosticError } from "../shared/tool-availability.ts";
75
75
  import { collectDynamicResults, DynamicFanoutError, materializeDynamicParallelStep, validateDynamicCollection } from "../shared/dynamic-fanout.ts";
@@ -503,6 +503,8 @@ interface RunPiStreamingResult {
503
503
  toolBudget?: ToolBudgetState;
504
504
  toolBudgetBlocked?: boolean;
505
505
  observedMutationAttempt?: boolean;
506
+ structuredOutputToolInvoked?: boolean;
507
+ structuredOutputMessageStartIndex?: number;
506
508
  watchdog?: ChildWatchdogStateSnapshot;
507
509
  runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensionsV1;
508
510
  processInstanceId: string;
@@ -568,6 +570,8 @@ function runPiStreaming(
568
570
  let turnBudgetMessage: string | undefined;
569
571
  let turnBudget: TurnBudgetState | undefined;
570
572
  let observedMutationAttempt = false;
573
+ let structuredOutputToolInvoked = false;
574
+ let structuredOutputMessageStartIndex: number | undefined;
571
575
  let toolCount = 0;
572
576
  const childWatchdogConfig = decodeChildWatchdogConfig(env?.[CHILD_WATCHDOG_CONFIG_ENV]);
573
577
  let childWatchdogState: ChildWatchdogStateSnapshot | undefined;
@@ -658,6 +662,10 @@ function runPiStreaming(
658
662
 
659
663
  if (event.type === "tool_execution_start" && event.toolName) {
660
664
  toolCount += 1;
665
+ if (event.toolName === "structured_output") {
666
+ structuredOutputToolInvoked = true;
667
+ structuredOutputMessageStartIndex = messages.length;
668
+ }
661
669
  observedMutationAttempt = observedMutationAttempt || isMutatingTool(event.toolName, event.args);
662
670
  const toolArgs = extractToolArgsPreview(event.args ?? {});
663
671
  writeOutputLine(toolArgs ? `${event.toolName}: ${toolArgs}` : event.toolName);
@@ -923,6 +931,8 @@ function runPiStreaming(
923
931
  turnBudgetExceeded,
924
932
  wrapUpRequested: turnBudget?.outcome === "wrap-up-requested" || turnBudget?.outcome === "termination-deferred" || turnBudgetExceeded || undefined,
925
933
  observedMutationAttempt,
934
+ structuredOutputToolInvoked,
935
+ structuredOutputMessageStartIndex,
926
936
  watchdog: childWatchdogState,
927
937
  processInstanceId,
928
938
  processCloseObservedAt,
@@ -949,7 +959,7 @@ function runPiStreaming(
949
959
  const stderr = stderrTail.text();
950
960
  const finalOutput = getFinalOutput(messages) || rawStdoutTail.text().trim();
951
961
  const spawnErrorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
952
- resolve(omitUndefinedProperties({ stderr, exitCode: 1, messages, usage, toolCount, durationMs: Date.now() - startedAt, model, error: stopped ? (stopMessage ?? "Subagent stopped by user.") : timedOut ? (timeoutMessage ?? "Subagent timed out.") : turnBudgetExceeded ? turnBudgetMessage : error ?? assistantError ?? spawnErrorMessage, protocolError, finalOutput: (timedOut || stopped) && !finalOutput.trim() ? (stopped ? stopMessage ?? "Subagent stopped by user." : timeoutMessage ?? "Subagent timed out.") : finalOutput, outputState: finalOutput.trim() ? "present" : "absent", timedOut, stopped, turnBudget, turnBudgetExceeded, wrapUpRequested: turnBudget?.outcome === "wrap-up-requested" || turnBudget?.outcome === "termination-deferred" || turnBudgetExceeded || undefined, observedMutationAttempt, watchdog: childWatchdogState, processInstanceId }));
962
+ resolve(omitUndefinedProperties({ stderr, exitCode: 1, messages, usage, toolCount, durationMs: Date.now() - startedAt, model, error: stopped ? (stopMessage ?? "Subagent stopped by user.") : timedOut ? (timeoutMessage ?? "Subagent timed out.") : turnBudgetExceeded ? turnBudgetMessage : error ?? assistantError ?? spawnErrorMessage, protocolError, finalOutput: (timedOut || stopped) && !finalOutput.trim() ? (stopped ? stopMessage ?? "Subagent stopped by user." : timeoutMessage ?? "Subagent timed out.") : finalOutput, outputState: finalOutput.trim() ? "present" : "absent", timedOut, stopped, turnBudget, turnBudgetExceeded, wrapUpRequested: turnBudget?.outcome === "wrap-up-requested" || turnBudget?.outcome === "termination-deferred" || turnBudgetExceeded || undefined, observedMutationAttempt, structuredOutputToolInvoked, structuredOutputMessageStartIndex, watchdog: childWatchdogState, processInstanceId }));
953
963
  });
954
964
  });
955
965
  }
@@ -1444,31 +1454,42 @@ async function runSingleStep(
1444
1454
  const runtimeAcknowledgedExtensions = readRuntimeAcknowledgedExtensions(runtimeAcknowledgedExtensionsPath);
1445
1455
  cleanupTempDir(tempDir);
1446
1456
 
1447
- const hiddenError = run.exitCode === 0 && !run.error && !toolAvailabilityError ? detectSubagentError(run.messages) : null;
1448
- const missingStructuredOutput = effectiveStructuredOutput
1449
- ? !fs.existsSync(effectiveStructuredOutput.outputPath)
1450
- : false;
1457
+ let structuredOutput: unknown;
1458
+ let structuredError: string | undefined;
1459
+ let validatedStructuredOutput = false;
1460
+ if (effectiveStructuredOutput && run.exitCode === 0 && !run.error && !toolAvailabilityError) {
1461
+ if (!run.structuredOutputToolInvoked) {
1462
+ structuredError = MISSING_STRUCTURED_OUTPUT_CALL_ERROR;
1463
+ } else {
1464
+ const structured = await readStructuredOutput({
1465
+ schema: effectiveStructuredOutput.schema,
1466
+ schemaPath: effectiveStructuredOutput.schemaPath,
1467
+ outputPath: effectiveStructuredOutput.outputPath,
1468
+ });
1469
+ if (structured.error) structuredError = structured.error;
1470
+ else {
1471
+ structuredOutput = structured.value;
1472
+ validatedStructuredOutput = true;
1473
+ }
1474
+ }
1475
+ }
1476
+ const errorMessages = validatedStructuredOutput
1477
+ ? run.messages.slice(run.structuredOutputMessageStartIndex ?? run.messages.length)
1478
+ : run.messages;
1479
+ const hiddenError = run.exitCode === 0 && !run.error && !toolAvailabilityError && !structuredError
1480
+ ? detectSubagentError(errorMessages)
1481
+ : null;
1451
1482
  const emptyOutputError = run.exitCode === 0
1452
1483
  && !run.error
1453
1484
  && !toolAvailabilityError
1485
+ && !structuredError
1454
1486
  && !run.finalOutput.trim()
1455
- && (!effectiveStructuredOutput || missingStructuredOutput)
1487
+ && !validatedStructuredOutput
1456
1488
  && (!hiddenError?.hasError || hasEmptyTerminalAssistantResponse(run.messages))
1457
1489
  ? "Subagent produced no output (possible model cold-start or empty response)."
1458
1490
  : undefined;
1459
- let structuredOutput: unknown;
1460
- let structuredError: string | undefined;
1461
- if (effectiveStructuredOutput && run.exitCode === 0 && !run.error && !toolAvailabilityError && !hiddenError?.hasError && !emptyOutputError) {
1462
- const structured = await readStructuredOutput({
1463
- schema: effectiveStructuredOutput.schema,
1464
- schemaPath: effectiveStructuredOutput.schemaPath,
1465
- outputPath: effectiveStructuredOutput.outputPath,
1466
- });
1467
- if (structured.error) structuredError = structured.error;
1468
- else structuredOutput = structured.value;
1469
- }
1470
1491
  const completionGuardEnabled = isAgentContractV1(step.agentContract) ? step.completionGuard === true : step.completionGuard !== false;
1471
- const completionGuard = run.exitCode === 0 && !run.error && !toolAvailabilityError && !hiddenError?.hasError && !emptyOutputError && completionGuardEnabled
1492
+ const completionGuard = run.exitCode === 0 && !run.error && !toolAvailabilityError && !structuredError && !hiddenError?.hasError && !emptyOutputError && completionGuardEnabled
1472
1493
  ? evaluateCompletionMutationGuard(omitUndefinedProperties({
1473
1494
  agent: step.agent,
1474
1495
  task: taskForCompletionGuard,
@@ -4418,40 +4439,6 @@ async function runSubagent(
4418
4439
  statusPayload.error = `Step failed: ${failedStep.agent}`;
4419
4440
  }
4420
4441
  }
4421
- writeStatusPayload();
4422
- appendJsonl(
4423
- eventsPath,
4424
- JSON.stringify({
4425
- type: "subagent.run.completed",
4426
- lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
4427
- ts: runEndedAt,
4428
- runId: id,
4429
- status: statusPayload.state,
4430
- durationMs: runEndedAt - overallStartTime,
4431
- totalTokens: statusPayload.totalTokens,
4432
- totalCost: finalTotalCost,
4433
- usageBudget: statusPayload.usageBudget,
4434
- }),
4435
- );
4436
- writeRunLog(logPath, omitUndefinedProperties({
4437
- id,
4438
- mode: statusPayload.mode,
4439
- cwd,
4440
- startedAt: overallStartTime,
4441
- endedAt: runEndedAt,
4442
- steps: statusPayload.steps.map((step) => omitUndefinedProperties({
4443
- agent: step.agent,
4444
- status: step.status,
4445
- durationMs: step.durationMs,
4446
- })),
4447
- summary,
4448
- truncated,
4449
- artifactsDir,
4450
- sessionFile: effectiveSessionFile,
4451
- shareUrl,
4452
- shareError,
4453
- }));
4454
-
4455
4442
  try {
4456
4443
  writeAtomicJson(resultPath, {
4457
4444
  lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
@@ -4551,6 +4538,38 @@ async function runSubagent(
4551
4538
  } catch (err) {
4552
4539
  console.error(`Failed to write result file ${resultPath}:`, err);
4553
4540
  }
4541
+ appendJsonl(
4542
+ eventsPath,
4543
+ JSON.stringify({
4544
+ type: "subagent.run.completed",
4545
+ lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
4546
+ ts: runEndedAt,
4547
+ runId: id,
4548
+ status: statusPayload.state,
4549
+ durationMs: runEndedAt - overallStartTime,
4550
+ totalTokens: statusPayload.totalTokens,
4551
+ totalCost: finalTotalCost,
4552
+ usageBudget: statusPayload.usageBudget,
4553
+ }),
4554
+ );
4555
+ writeRunLog(logPath, omitUndefinedProperties({
4556
+ id,
4557
+ mode: statusPayload.mode,
4558
+ cwd,
4559
+ startedAt: overallStartTime,
4560
+ endedAt: runEndedAt,
4561
+ steps: statusPayload.steps.map((step) => omitUndefinedProperties({
4562
+ agent: step.agent,
4563
+ status: step.status,
4564
+ durationMs: step.durationMs,
4565
+ })),
4566
+ summary,
4567
+ truncated,
4568
+ artifactsDir,
4569
+ sessionFile: effectiveSessionFile,
4570
+ shareUrl,
4571
+ shareError,
4572
+ }));
4554
4573
  if (config.runnerProcessInstanceId) {
4555
4574
  const writers: Record<string, Array<{ processInstanceId: string; kind: "pi-writer"; attempt: number; closeObservedAt: number; exitCode: number | null; signal: string | null }>> = {};
4556
4575
  const expectedWriters: Record<string, number> = {};
@@ -4573,6 +4592,7 @@ async function runSubagent(
4573
4592
  console.error(`Failed to write process-terminal candidate for '${id}':`, error);
4574
4593
  }
4575
4594
  }
4595
+ writeStatusPayload();
4576
4596
  }
4577
4597
 
4578
4598
  async function waitForStartupControl(
@@ -58,8 +58,10 @@ import {
58
58
  type Details,
59
59
  type ForegroundResumeRun,
60
60
  type SubagentState,
61
+ type WaitCompletion,
61
62
  } from "../../shared/types.ts";
62
63
  import { formatDuration, shortenPath } from "../../shared/formatters.ts";
64
+ import { collectWaitCompletions } from "./wait-completions.ts";
63
65
  export { WAIT_TOOL_ENABLED_ENV, resolveWaitToolConfig, type ResolvedWaitToolConfig } from "./wait-config.ts";
64
66
 
65
67
  /** States that mean a run is still in flight (not yet resolved). */
@@ -303,11 +305,15 @@ function summarizeTerminalRuns(runs: AsyncRunSummary[], providerFinishedCount =
303
305
  return parts.join(", ");
304
306
  }
305
307
 
306
- function result(text: string, isError = false): AgentToolResult<Details> {
308
+ function result(text: string, isError = false, completions?: WaitCompletion[]): AgentToolResult<Details> {
307
309
  return {
308
310
  content: [{ type: "text", text }],
309
311
  ...(isError ? { isError: true } : {}),
310
- details: { mode: "management", results: [] },
312
+ details: {
313
+ mode: "management",
314
+ results: [],
315
+ ...(completions && completions.length > 0 ? { completions } : {}),
316
+ },
311
317
  };
312
318
  }
313
319
 
@@ -597,6 +603,7 @@ export async function waitForSubagents(
597
603
  let terminalSummary: string;
598
604
  let finishedAsyncCount: number;
599
605
  let failedAsyncCount: number;
606
+ let completions: WaitCompletion[] | undefined;
600
607
  const activeProviderIds = new Set(providerActive.map(backgroundWorkIdentity));
601
608
  const providerFinishedCount = [...initialProviderIds].filter((id) => !activeProviderIds.has(id)).length;
602
609
  try {
@@ -605,6 +612,7 @@ export async function waitForSubagents(
605
612
  finishedAsyncCount = terminal.length;
606
613
  failedAsyncCount = terminal.filter((run) => run.state === "failed").length;
607
614
  terminalSummary = summarizeTerminalRuns(terminal, providerFinishedCount);
615
+ completions = collectWaitCompletions(terminal, deps.state, deps.resultsDir ?? DIRS.results);
608
616
  } catch (error) {
609
617
  return result(error instanceof Error ? error.message : String(error), true);
610
618
  }
@@ -631,6 +639,7 @@ export async function waitForSubagents(
631
639
  return result(
632
640
  `Waited ${elapsed} for ${scope}; ${status}.${outcome}${attentionNote} Completion/control events have been observed; inspect status if a notification is not visible yet.`,
633
641
  deps.failOnFailedRuns === true && failedAsyncCount > 0,
642
+ completions,
634
643
  );
635
644
  }
636
645
 
@@ -647,5 +656,6 @@ export async function waitForSubagents(
647
656
  return result(
648
657
  `Waited ${elapsed}; ${progress}.${outcome}${attentionNote}${remainder} Relevant completion/control events have been observed; inspect status if a notification is not visible yet.`,
649
658
  deps.failOnFailedRuns === true && failedAsyncCount > 0,
659
+ completions,
650
660
  );
651
661
  }
@@ -0,0 +1,112 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ArtifactPaths, SubagentState, WaitCompletion, WaitCompletionChild } from "../../shared/types.ts";
4
+ import type { AsyncRunSummary } from "./async-status.ts";
5
+
6
+ function asNonEmptyString(value: unknown): string | undefined {
7
+ return typeof value === "string" && value ? value : undefined;
8
+ }
9
+
10
+ function errorCode(error: unknown): string | undefined {
11
+ return typeof error === "object" && error !== null && "code" in error
12
+ ? (error as NodeJS.ErrnoException).code
13
+ : undefined;
14
+ }
15
+
16
+ function errorMessage(error: unknown): string {
17
+ return error instanceof Error ? error.message : String(error);
18
+ }
19
+
20
+ /**
21
+ * Project a terminal result payload into the slim shape that is safe to surface in
22
+ * tool_result details: run identity, per-child outcome, and the artifact trail.
23
+ * Output text is deliberately excluded — it already travels in the tool result
24
+ * content, and duplicating it in details would double the payload for every wait.
25
+ */
26
+ export function toWaitCompletion(data: Record<string, unknown>, runId: string): WaitCompletion {
27
+ const results = Array.isArray(data.results)
28
+ ? data.results.flatMap((entry): WaitCompletionChild[] => {
29
+ if (entry === null || typeof entry !== "object") return [];
30
+ const child = entry as Record<string, unknown>;
31
+ const outputState = child.outputState === "present" || child.outputState === "absent" || child.outputState === "unknown"
32
+ ? child.outputState
33
+ : undefined;
34
+ const artifactPaths = child.artifactPaths !== null && typeof child.artifactPaths === "object"
35
+ ? (child.artifactPaths as Partial<ArtifactPaths>)
36
+ : undefined;
37
+ const agent = asNonEmptyString(child.agent);
38
+ const childRunId = asNonEmptyString(child.runId);
39
+ const error = asNonEmptyString(child.error);
40
+ const model = asNonEmptyString(child.model);
41
+ return [{
42
+ ...(agent ? { agent } : {}),
43
+ ...(childRunId ? { runId: childRunId } : {}),
44
+ ...(typeof child.success === "boolean" ? { success: child.success } : {}),
45
+ ...(outputState ? { outputState } : {}),
46
+ ...(error ? { error } : {}),
47
+ ...(model ? { model } : {}),
48
+ ...(artifactPaths ? { artifactPaths } : {}),
49
+ }];
50
+ })
51
+ : undefined;
52
+ const agent = asNonEmptyString(data.agent);
53
+ const mode = asNonEmptyString(data.mode);
54
+ const state = asNonEmptyString(data.state);
55
+ return {
56
+ runId,
57
+ ...(agent ? { agent } : {}),
58
+ ...(mode ? { mode } : {}),
59
+ ...(state ? { state } : {}),
60
+ ...(typeof data.success === "boolean" ? { success: data.success } : {}),
61
+ ...(results && results.length > 0 ? { results } : {}),
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Record a consumed terminal payload for later surfacing by subagent_wait, pruning
67
+ * stale entries with the same TTL that dedupes completion notifications. The result
68
+ * file is deleted after delivery, so this record is the only in-process source once
69
+ * the watcher has consumed it.
70
+ */
71
+ export function recordWaitCompletion(state: SubagentState, runId: string, data: Record<string, unknown>, now: number, ttlMs: number): void {
72
+ const store = state.completedResults ??= new Map();
73
+ for (const [key, entry] of store) {
74
+ if (now - entry.seenAt > ttlMs) store.delete(key);
75
+ }
76
+ store.set(runId, { seenAt: now, completion: toWaitCompletion(data, runId) });
77
+ }
78
+
79
+ /**
80
+ * Terminal payloads for the runs a wait covered: the watcher's in-memory record
81
+ * first, then the not-yet-consumed result file. Result files are written atomically,
82
+ * so a direct read never observes a torn write; the read is deliberately read-only —
83
+ * the watcher owns notification and cleanup.
84
+ */
85
+ export function collectWaitCompletions(terminal: AsyncRunSummary[], state: SubagentState, resultsDir: string): WaitCompletion[] | undefined {
86
+ if (terminal.length === 0) return undefined;
87
+ const completions: WaitCompletion[] = [];
88
+ for (const run of terminal) {
89
+ const recorded = state.completedResults?.get(run.id);
90
+ if (recorded) {
91
+ completions.push(recorded.completion);
92
+ continue;
93
+ }
94
+ const resultPath = path.join(resultsDir, `${run.id}.json`);
95
+ try {
96
+ const raw = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as Record<string, unknown>;
97
+ completions.push(toWaitCompletion(raw, run.id));
98
+ } catch (error) {
99
+ if (errorCode(error) !== "ENOENT") {
100
+ throw new Error(`Failed to read subagent result '${resultPath}': ${errorMessage(error)}`, {
101
+ cause: error instanceof Error ? error : undefined,
102
+ });
103
+ }
104
+ // The watcher may have consumed the file between the store check and the
105
+ // read; its record is authoritative when present, otherwise the payload
106
+ // is gone and the text summary remains the only surface for this run.
107
+ const late = state.completedResults?.get(run.id);
108
+ if (late) completions.push(late.completion);
109
+ }
110
+ }
111
+ return completions.length > 0 ? completions : undefined;
112
+ }
@@ -532,7 +532,7 @@ export class ChainClarifyComponent implements Component {
532
532
  buffer = template.split("\n")[0] ?? "";
533
533
  } else if (mode === "output") {
534
534
  const behavior = this.getEffectiveBehavior(this.selectedStep);
535
- buffer = behavior.output === false ? "" : (behavior.output || "");
535
+ buffer = typeof behavior.output === "string" ? behavior.output : "";
536
536
  } else if (mode === "reads") {
537
537
  const behavior = this.getEffectiveBehavior(this.selectedStep);
538
538
  buffer = behavior.reads === false ? "" : (behavior.reads?.join(", ") || "");
@@ -1165,7 +1165,9 @@ export class ChainClarifyComponent implements Component {
1165
1165
 
1166
1166
  const writesValue = behavior.output === false
1167
1167
  ? th.fg("dim", "(disabled)")
1168
- : (behavior.output || th.fg("dim", "(none)"));
1168
+ : (typeof behavior.output === "string" && behavior.output
1169
+ ? behavior.output
1170
+ : th.fg("dim", "(none)"));
1169
1171
  const writesLabel = th.fg("dim", "writes: ");
1170
1172
  lines.push(this.row(` ${writesLabel}${truncateToWidth(writesValue, innerW - 14)}`));
1171
1173
 
@@ -1296,7 +1298,9 @@ export class ChainClarifyComponent implements Component {
1296
1298
 
1297
1299
  const writesValue = behavior.output === false
1298
1300
  ? th.fg("dim", "(disabled)")
1299
- : (behavior.output || th.fg("dim", "(none)"));
1301
+ : (typeof behavior.output === "string" && behavior.output
1302
+ ? behavior.output
1303
+ : th.fg("dim", "(none)"));
1300
1304
  const writesLabel = th.fg("dim", "writes: ");
1301
1305
  lines.push(this.row(` ${writesLabel}${truncateToWidth(writesValue, innerW - 14)}`));
1302
1306
 
@@ -459,6 +459,7 @@ async function runSingleAttempt(
459
459
  const spawnEnv = { ...process.env, ...sharedEnv, ...getSubagentDepthEnv(options.maxSubagentDepth) };
460
460
  let observedMutationAttempt = false;
461
461
  let structuredOutputToolInvoked = false;
462
+ let structuredOutputMessageStartIndex: number | undefined;
462
463
 
463
464
  const exitCode = await new Promise<number>((resolve) => {
464
465
  const spawnSpec = getPiSpawnCommand(args);
@@ -874,7 +875,10 @@ async function runSingleAttempt(
874
875
  const toolArgs = evt.args && typeof evt.args === "object" && !Array.isArray(evt.args)
875
876
  ? evt.args as Record<string, unknown>
876
877
  : {};
877
- if (options.structuredOutput && evt.toolName === "structured_output") structuredOutputToolInvoked = true;
878
+ if (options.structuredOutput && evt.toolName === "structured_output") {
879
+ structuredOutputToolInvoked = true;
880
+ structuredOutputMessageStartIndex = result.messages?.length ?? 0;
881
+ }
878
882
  if (options.allowIntercomDetach && (evt.toolName === "intercom" || evt.toolName === "contact_supervisor")) {
879
883
  intercomStarted = true;
880
884
  }
@@ -1183,24 +1187,7 @@ async function runSingleAttempt(
1183
1187
  if (result.error && result.exitCode === 0) {
1184
1188
  result.exitCode = 1;
1185
1189
  }
1186
- if (result.exitCode === 0 && !result.error) {
1187
- const messages = result.messages ?? [];
1188
- const finalText = getFinalOutput(messages);
1189
- const missingStructuredOutput = options.structuredOutput
1190
- ? !existsSync(options.structuredOutput.outputPath)
1191
- : false;
1192
- const errInfo = detectSubagentError(messages);
1193
- const missingOutput = !finalText?.trim() && (!options.structuredOutput || missingStructuredOutput);
1194
- if (missingOutput && (!errInfo.hasError || hasEmptyTerminalAssistantResponse(messages))) {
1195
- result.exitCode = 1;
1196
- result.error = "Subagent produced no output (possible model cold-start or empty response).";
1197
- } else if (errInfo.hasError) {
1198
- result.exitCode = errInfo.exitCode ?? 1;
1199
- result.error = errInfo.details
1200
- ? `${errInfo.errorType} failed (exit ${errInfo.exitCode}): ${errInfo.details}`
1201
- : `${errInfo.errorType} failed with exit code ${errInfo.exitCode}`;
1202
- }
1203
- }
1190
+ let validatedStructuredOutput = false;
1204
1191
  if (options.structuredOutput && result.exitCode === 0 && !result.error) {
1205
1192
  result.structuredOutputSchemaPath = options.structuredOutput.schemaPath;
1206
1193
  result.structuredOutputPath = options.structuredOutput.outputPath;
@@ -1220,9 +1207,28 @@ async function runSingleAttempt(
1220
1207
  result.structuredOutputFailed = true;
1221
1208
  } else {
1222
1209
  result.structuredOutput = structured.value;
1210
+ validatedStructuredOutput = true;
1223
1211
  }
1224
1212
  }
1225
1213
  }
1214
+ if (result.exitCode === 0 && !result.error) {
1215
+ const messages = result.messages ?? [];
1216
+ const finalText = getFinalOutput(messages);
1217
+ const errorMessages = validatedStructuredOutput
1218
+ ? messages.slice(structuredOutputMessageStartIndex ?? messages.length)
1219
+ : messages;
1220
+ const errInfo = detectSubagentError(errorMessages);
1221
+ const missingOutput = !finalText?.trim() && !validatedStructuredOutput;
1222
+ if (missingOutput && (!errInfo.hasError || hasEmptyTerminalAssistantResponse(messages))) {
1223
+ result.exitCode = 1;
1224
+ result.error = "Subagent produced no output (possible model cold-start or empty response).";
1225
+ } else if (errInfo.hasError) {
1226
+ result.exitCode = errInfo.exitCode ?? 1;
1227
+ result.error = errInfo.details
1228
+ ? `${errInfo.errorType} failed (exit ${errInfo.exitCode}): ${errInfo.details}`
1229
+ : `${errInfo.errorType} failed with exit code ${errInfo.exitCode}`;
1230
+ }
1231
+ }
1226
1232
 
1227
1233
  progress.status = result.exitCode === 0 ? "completed" : "failed";
1228
1234
  progress.durationMs = Date.now() - startTime;