pi-cohort 7.4.1 → 7.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [7.4.3] - 2026-10-20
4
+
5
+ ### Changed
6
+ - `handoff` skill sets `disable-model-invocation: true`: it runs only via an explicit `/skill:handoff`, never by model selection.
7
+
8
+ ## [7.4.2] - 2026-10-19
9
+
10
+ ### Added
11
+ - Children with `outputSchema` get one in-process follow-up after a normal completion without a `structured_output` call; a valid follow-up call succeeds like a first-turn call. (#25)
12
+
13
+ ### Changed
14
+ - Pi >=1.3.0 is required. (#25)
15
+
3
16
  ## [7.4.1] - 2026-10-19
4
17
 
5
18
  ### Fixed
package/README.md CHANGED
@@ -33,6 +33,8 @@ Four independent extensions for the [pi coding agent](https://github.com/earendi
33
33
 
34
34
  ## Installation
35
35
 
36
+ Requires Pi >=1.3.0.
37
+
36
38
  ```bash
37
39
  pi install npm:pi-cohort
38
40
  ```
@@ -45,7 +47,7 @@ Children and detached coordinators use the originating parent's resolved Node ex
45
47
 
46
48
  External execution backends are currently unsupported on Windows, for both foreground and background runs. Native execution remains supported.
47
49
 
48
- `pi-cohort/execution-backend` requires Pi >=0.85.0 and is the public API for Pi extensions that add child execution surfaces. Pi's TypeScript-aware runtime loads the API; bare Node is not a supported execution path. Consumers outside Pi must supply a TypeScript-aware loader such as [jiti](https://github.com/unjs/jiti).
50
+ `pi-cohort/execution-backend` requires Pi >=1.3.0 and is the public API for Pi extensions that add child execution surfaces. Pi's TypeScript-aware runtime loads the API; bare Node is not a supported execution path. Consumers outside Pi must supply a TypeScript-aware loader such as [jiti](https://github.com/unjs/jiti).
49
51
 
50
52
  An adapter registers its backend in extension load order. To make that backend available to background runs, registration also supplies an optional reload descriptor:
51
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cohort",
3
- "version": "7.4.1",
3
+ "version": "7.4.3",
4
4
  "description": "Delegate Pi work to focused child agents: code review, scouting, implementation, parallel audits, saved chains, and background jobs.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
@@ -63,7 +63,7 @@
63
63
  "peerDependencies": {
64
64
  "@earendil-works/pi-agent-core": "*",
65
65
  "@earendil-works/pi-ai": "*",
66
- "@earendil-works/pi-coding-agent": ">=0.85.0",
66
+ "@earendil-works/pi-coding-agent": ">=1.3.0",
67
67
  "@earendil-works/pi-tui": "*",
68
68
  "typebox": "*"
69
69
  },
@@ -88,10 +88,10 @@
88
88
  "jiti": "^2.7.0"
89
89
  },
90
90
  "devDependencies": {
91
- "@earendil-works/pi-agent-core": "^0.85.0",
92
- "@earendil-works/pi-ai": "^0.85.0",
93
- "@earendil-works/pi-coding-agent": "^0.85.0",
94
- "@earendil-works/pi-tui": "^0.85.0",
91
+ "@earendil-works/pi-agent-core": "^1.3.0",
92
+ "@earendil-works/pi-ai": "^1.3.0",
93
+ "@earendil-works/pi-coding-agent": "^1.3.0",
94
+ "@earendil-works/pi-tui": "^1.3.0",
95
95
  "typebox": "^1.3.11"
96
96
  }
97
97
  }
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: handoff
3
3
  description: Use when the context window is nearly full and the work must continue in a fresh session.
4
+ disable-model-invocation: true
4
5
  ---
5
6
 
6
7
  # Handoff brief
@@ -228,6 +228,7 @@ type ChildMessage = Message & {
228
228
 
229
229
  interface ChildEvent {
230
230
  type?: string;
231
+ aborted?: boolean;
231
232
  message?: ChildMessage;
232
233
  toolName?: string;
233
234
  args?: Record<string, unknown>;
@@ -255,6 +256,8 @@ function runPiStreaming(
255
256
  childEventContext?: ChildEventContext,
256
257
  registerInterrupt?: (interrupt: (() => void) | undefined) => void,
257
258
  onChildEvent?: (event: ChildEvent) => void,
259
+ structuredRun = false,
260
+ controlConfig: ResolvedControlConfig = DEFAULT_CONTROL_CONFIG,
258
261
  ): Promise<RunPiStreamingResult> {
259
262
  return new Promise((resolve, reject) => {
260
263
  const spawnSpec = getPiSpawnCommand(args, runtime);
@@ -321,6 +324,13 @@ function runPiStreaming(
321
324
  appendChildEvent(event);
322
325
  onChildEvent?.(event);
323
326
 
327
+ if (structuredRun && event.type === "agent_settled") {
328
+ clearSettlementWait();
329
+ cleanTerminalAssistantStopReceived = !event.aborted && !assistantError && !error && !interrupted;
330
+ if (event.aborted && !error) error = "Subagent aborted before structured settlement.";
331
+ startFinalDrain();
332
+ }
333
+
324
334
  if (event.type === "tool_execution_start" && event.toolName) {
325
335
  observedMutationAttempt = observedMutationAttempt || isMutatingTool(event.toolName, event.args);
326
336
  const toolArgs = extractToolArgsPreview(event.args ?? {});
@@ -351,7 +361,8 @@ function runPiStreaming(
351
361
  if (stopReason === "stop" && !hasToolCall) {
352
362
  if (!event.message.errorMessage && extractTextFromContent(event.message.content).trim()) assistantError = undefined;
353
363
  cleanTerminalAssistantStopReceived ||= !event.message.errorMessage;
354
- startFinalDrain();
364
+ if (structuredRun) startSettlementWait();
365
+ else startFinalDrain();
355
366
  }
356
367
  }
357
368
  };
@@ -379,8 +390,31 @@ function runPiStreaming(
379
390
  let finalDrainTimer: NodeJS.Timeout | undefined;
380
391
  let finalHardKillTimer: NodeJS.Timeout | undefined;
381
392
  let settled = false;
393
+ let lastStreamActivityAt = Date.now();
394
+ let settlementWaitTimer: NodeJS.Timeout | undefined;
395
+ function clearSettlementWait(): void {
396
+ if (settlementWaitTimer) clearInterval(settlementWaitTimer);
397
+ settlementWaitTimer = undefined;
398
+ }
399
+ function startSettlementWait(): void {
400
+ if (settlementWaitTimer || settled || childExited) return;
401
+ settlementWaitTimer = setInterval(() => {
402
+ if (settled || childExited || interrupted) return;
403
+ const silenceMs = Date.now() - lastStreamActivityAt;
404
+ if (silenceMs >= controlConfig.inFlightSilenceCeilingMs) {
405
+ error ??= "Subagent awaiting structured settlement exceeded its silence ceiling. Forcing termination.";
406
+ cleanTerminalAssistantStopReceived = false;
407
+ startFinalDrain();
408
+ }
409
+ if (silenceMs >= controlConfig.inFlightSilenceKillMs) {
410
+ forcedTerminationSignal = trySignalChild(child, "SIGKILL") || forcedTerminationSignal;
411
+ }
412
+ }, Math.min(1000, controlConfig.inFlightSilenceCeilingMs / 4));
413
+ settlementWaitTimer.unref?.();
414
+ }
382
415
  const clearStdioGuard = attachPostExitStdioGuard(child, { idleMs: 2000, hardMs: 8000 });
383
416
  child.stdout.on("data", (chunk: Buffer) => {
417
+ lastStreamActivityAt = Date.now();
384
418
  const text = chunk.toString();
385
419
  stdoutBuf += text;
386
420
  const lines = stdoutBuf.split("\n");
@@ -389,11 +423,13 @@ function runPiStreaming(
389
423
  });
390
424
 
391
425
  child.stderr.on("data", (chunk: Buffer) => {
426
+ lastStreamActivityAt = Date.now();
392
427
  processStderrText(chunk.toString());
393
428
  });
394
429
  registerInterrupt?.(() => {
395
430
  if (settled) return;
396
431
  interrupted = true;
432
+ clearSettlementWait();
397
433
  if (!error) error = "Interrupted. Waiting for explicit next action.";
398
434
  trySignalChild(child, "SIGINT");
399
435
  setTimeout(() => {
@@ -401,6 +437,7 @@ function runPiStreaming(
401
437
  }, 1000).unref?.();
402
438
  });
403
439
  const clearDrainTimers = () => {
440
+ clearSettlementWait();
404
441
  if (finalDrainTimer) {
405
442
  clearTimeout(finalDrainTimer);
406
443
  finalDrainTimer = undefined;
@@ -590,6 +627,7 @@ export interface SingleStepContext {
590
627
  onChildEvent?: (event: ChildEvent) => void;
591
628
  executionBackendCoordinator?: DetachedExecutionBackendCoordinator;
592
629
  executionBackendConfig?: DetachedExecutionBackendConfig;
630
+ controlConfig?: ResolvedControlConfig;
593
631
  }
594
632
 
595
633
  /** Execute one detached-runner leaf. */
@@ -909,6 +947,8 @@ export async function runSingleStep(
909
947
  { eventsPath, runId: ctx.id, stepIndex: ctx.flatIndex, agent: step.agent },
910
948
  ctx.registerInterrupt,
911
949
  ctx.onChildEvent,
950
+ effectiveStructuredOutput !== undefined,
951
+ ctx.controlConfig ?? DEFAULT_CONTROL_CONFIG,
912
952
  );
913
953
  } catch (error) {
914
954
  if (!(error instanceof PiRuntimeLaunchError)) throw error;
@@ -1927,6 +1967,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1927
1967
  flatIndex: fi, flatStepCount: Math.max(statusPayload.steps.length, 1),
1928
1968
  outputFile: path.join(asyncDir, `output-${fi}.log`),
1929
1969
  runtime: config.runtime,
1970
+ controlConfig: config.controlConfig,
1930
1971
  forwardedFlags: config.forwardedFlags,
1931
1972
  executionBackendCoordinator,
1932
1973
  executionBackendConfig,
@@ -2183,6 +2224,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2183
2224
  flatIndex: fi, flatStepCount: flatSteps.length,
2184
2225
  outputFile: path.join(asyncDir, `output-${fi}.log`),
2185
2226
  runtime: config.runtime,
2227
+ controlConfig: config.controlConfig,
2186
2228
  forwardedFlags: config.forwardedFlags,
2187
2229
  executionBackendCoordinator,
2188
2230
  executionBackendConfig,
@@ -2361,6 +2403,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2361
2403
  flatIndex, flatStepCount: flatSteps.length,
2362
2404
  outputFile: path.join(asyncDir, `output-${flatIndex}.log`),
2363
2405
  runtime: config.runtime,
2406
+ controlConfig: config.controlConfig,
2364
2407
  forwardedFlags: config.forwardedFlags,
2365
2408
  executionBackendCoordinator,
2366
2409
  executionBackendConfig,
@@ -192,13 +192,18 @@ async function runSingleAttempt(
192
192
  let activityTimer: NodeJS.Timeout | undefined;
193
193
 
194
194
 
195
- // If the child emits a terminal assistant stop but never exits,
196
- // give it a short grace period to flush naturally, then clean it up.
195
+ // Structured continuations can outlive a prose stop; drain only after settlement.
197
196
  const FINAL_STOP_GRACE_MS = 1000;
198
197
  const HARD_KILL_MS = 3000;
199
198
  let childExited = false;
200
199
  let forcedTerminationSignal = false;
201
200
  let cleanTerminalAssistantStopReceived = false;
201
+ let structuredSettlementPending = Boolean(options.structuredOutput);
202
+ let cleanStructuredSettlementReceived = false;
203
+ let silenceHardKillTimer: NodeJS.Timeout | undefined;
204
+ const cleanCompletionReceived = () => options.structuredOutput
205
+ ? cleanStructuredSettlementReceived && !options.signal?.aborted && !interruptedByControl && !silenceKillRequested
206
+ : cleanTerminalAssistantStopReceived;
202
207
  let finalDrainTimer: NodeJS.Timeout | undefined;
203
208
  let finalHardKillTimer: NodeJS.Timeout | undefined;
204
209
  const clearFinalDrainTimers = () => {
@@ -218,7 +223,7 @@ async function runSingleAttempt(
218
223
  const termSent = trySignalChild(proc, "SIGTERM");
219
224
  if (!termSent) return;
220
225
  forcedTerminationSignal = true;
221
- if (!cleanTerminalAssistantStopReceived && !assistantError) {
226
+ if (!cleanCompletionReceived() && !assistantError) {
222
227
  result.error = result.error ?? `Subagent process did not exit within ${FINAL_STOP_GRACE_MS}ms after its final message. Forcing termination.`;
223
228
  }
224
229
  finalHardKillTimer = setTimeout(() => {
@@ -240,17 +245,19 @@ async function runSingleAttempt(
240
245
  result.error = result.error ??
241
246
  `Subagent killed: in-flight turn produced no output for ${Math.round(silenceMs / 1000)}s ` +
242
247
  `(exceeded inFlightSilenceKillMs=${controlConfig.inFlightSilenceKillMs}ms). Likely wedged in a tool call.`;
243
- const hardKill = setTimeout(() => {
248
+ silenceHardKillTimer = setTimeout(() => {
244
249
  if (settled || processClosed) return;
245
250
  forcedTerminationSignal = trySignalChild(proc, "SIGKILL") || forcedTerminationSignal;
246
251
  }, HARD_KILL_MS);
247
- hardKill.unref?.();
252
+ silenceHardKillTimer.unref?.();
248
253
  };
249
254
 
250
255
 
251
256
  const finish = (code: number) => {
252
257
  if (settled) return;
253
258
  settled = true;
259
+ structuredSettlementPending = false;
260
+ if (silenceHardKillTimer) clearTimeout(silenceHardKillTimer);
254
261
  clearFinalDrainTimers();
255
262
  clearStdioGuard();
256
263
  if (activityTimer) {
@@ -326,19 +333,33 @@ async function runSingleAttempt(
326
333
  return true;
327
334
  };
328
335
  const updateActivityState = (now: number): boolean => {
336
+ if (structuredSettlementPending
337
+ && now - (progress.lastProductiveSignalAt ?? startTime) >= controlConfig.inFlightSilenceCeilingMs) {
338
+ result.error ??= "Subagent awaiting structured settlement exceeded its silence ceiling. Forcing termination.";
339
+ startFinalDrain();
340
+ }
341
+ if (!controlConfig.enabled && structuredSettlementPending && shouldSilenceKill({
342
+ turnOpen: progress.turnOpen || structuredSettlementPending,
343
+ lastProductiveSignalAt: progress.lastProductiveSignalAt,
344
+ startedAt: startTime,
345
+ now,
346
+ killMs: controlConfig.inFlightSilenceKillMs,
347
+ })) {
348
+ requestSilenceKill(now - (progress.lastProductiveSignalAt ?? startTime));
349
+ }
329
350
  if (!controlConfig.enabled) return false;
330
351
  const idleState = deriveActivityState({
331
352
  config: controlConfig,
332
353
  startedAt: startTime,
333
354
  lastActivityAt: progress.lastActivityAt,
334
355
  now,
335
- inFlightTurn: progress.turnOpen,
356
+ inFlightTurn: progress.turnOpen || structuredSettlementPending,
336
357
  lastProductiveSignalAt: progress.lastProductiveSignalAt,
337
358
  });
338
359
  if (idleState === "needs_attention") {
339
360
  const notified = progress.activityState === "needs_attention" ? false : emitNeedsAttention(now);
340
361
  if (shouldSilenceKill({
341
- turnOpen: progress.turnOpen,
362
+ turnOpen: progress.turnOpen || structuredSettlementPending,
342
363
  lastProductiveSignalAt: progress.lastProductiveSignalAt,
343
364
  startedAt: startTime,
344
365
  now,
@@ -383,9 +404,9 @@ async function runSingleAttempt(
383
404
  const processLine = (line: string) => {
384
405
  if (!line.trim()) return;
385
406
  jsonlWriter.writeLine(line);
386
- let evt: { type?: string; message?: Message; toolName?: string; args?: unknown };
407
+ let evt: { type?: string; message?: Message; toolName?: string; args?: unknown; aborted?: boolean };
387
408
  try {
388
- evt = JSON.parse(line) as { type?: string; message?: Message; toolName?: string; args?: unknown };
409
+ evt = JSON.parse(line) as typeof evt;
389
410
  } catch {
390
411
  // Non-JSON stdout lines are expected; only structured events are parsed.
391
412
  return;
@@ -407,6 +428,14 @@ async function runSingleAttempt(
407
428
  progress.lastProductiveSignalAt = lifecycle.lastProductiveSignalAt;
408
429
  updateActivityState(now);
409
430
 
431
+ if (options.structuredOutput && evt.type === "agent_settled") {
432
+ structuredSettlementPending = false;
433
+ cleanStructuredSettlementReceived = !evt.aborted && !assistantError
434
+ && !options.signal?.aborted && !interruptedByControl && !silenceKillRequested;
435
+ if (evt.aborted) result.error ??= "Subagent settlement aborted.";
436
+ startFinalDrain();
437
+ }
438
+
410
439
  if (evt.type === "tool_execution_start") {
411
440
  const toolArgs = evt.args && typeof evt.args === "object" && !Array.isArray(evt.args)
412
441
  ? evt.args as Record<string, unknown>
@@ -455,14 +484,14 @@ async function runSingleAttempt(
455
484
  if (evt.message.errorMessage) assistantError = evt.message.errorMessage;
456
485
  const assistantText = extractTextFromContent(evt.message.content);
457
486
  appendRecentOutput(progress, assistantText.split("\n").slice(-10));
458
- // Final assistant message: start the exit drain window.
487
+ // Unstructured runs still drain on the final assistant message.
459
488
  const stopReason = (evt.message as { stopReason?: string }).stopReason;
460
489
  const hasToolCall = Array.isArray(evt.message.content)
461
490
  && evt.message.content.some((part) => (part as { type?: string }).type === "toolCall");
462
491
  if (stopReason === "stop" && !hasToolCall) {
463
492
  if (!evt.message.errorMessage && assistantText.trim()) assistantError = undefined;
464
493
  cleanTerminalAssistantStopReceived ||= !evt.message.errorMessage;
465
- startFinalDrain();
494
+ if (!options.structuredOutput) startFinalDrain();
466
495
  }
467
496
  }
468
497
  updateActivityState(now);
@@ -499,7 +528,7 @@ async function runSingleAttempt(
499
528
  }
500
529
  };
501
530
 
502
- if (controlConfig.enabled) {
531
+ if (controlConfig.enabled || options.structuredOutput) {
503
532
  activityTimer = setInterval(() => {
504
533
  if (processClosed || settled) return;
505
534
  const now = Date.now();
@@ -514,13 +543,20 @@ async function runSingleAttempt(
514
543
  let stderrBuf = "";
515
544
 
516
545
  const clearStdioGuard = attachPostExitStdioGuard(proc, { idleMs: 2000, hardMs: 8000 });
546
+ const refreshStructuredWait = () => {
547
+ if (!structuredSettlementPending) return;
548
+ progress.lastActivityAt = Date.now();
549
+ progress.lastProductiveSignalAt = progress.lastActivityAt;
550
+ };
517
551
  proc.stdout.on("data", (d) => {
552
+ refreshStructuredWait();
518
553
  buf += d.toString();
519
554
  const lines = buf.split("\n");
520
555
  buf = lines.pop() || "";
521
556
  lines.forEach(processLine);
522
557
  });
523
558
  proc.stderr.on("data", (d) => {
559
+ refreshStructuredWait();
524
560
  stderrBuf += d.toString();
525
561
  });
526
562
  proc.on("exit", () => {
@@ -537,7 +573,7 @@ async function runSingleAttempt(
537
573
  processClosed = true;
538
574
  if (buf.trim()) processLine(buf);
539
575
  if (!result.error && assistantError) result.error = assistantError;
540
- const forcedDrainAfterFinalSuccess = forcedTerminationSignal && cleanTerminalAssistantStopReceived && !result.error;
576
+ const forcedDrainAfterFinalSuccess = forcedTerminationSignal && cleanCompletionReceived() && !result.error;
541
577
  if (code !== 0 && stderrBuf.trim() && !result.error && !forcedDrainAfterFinalSuccess) {
542
578
  result.error = stderrBuf.trim();
543
579
  }
@@ -3,7 +3,7 @@ export interface ForwardFlagsConfig {
3
3
  }
4
4
 
5
5
  // Core pi flag names -> value arity. This is the UNION of recognized core flags
6
- // across the supported pi range (0.74-0.85), transcribed from
6
+ // across the supported pi range (0.74-1.3), transcribed from
7
7
  // @earendil-works/pi-coding-agent dist/cli/args.js. A name present here is never
8
8
  // forwarded to children. Direction matters: an entry that is core in a NEWER pi
9
9
  // but absent from an OLDER installed pi is safe (worst case we skip forwarding a
@@ -14,7 +14,7 @@ type Arity = "boolean" | "value" | "value-guarded" | "value-guarded-print";
14
14
  export const RECOGNIZED_PI_FLAGS: Record<string, Arity> = {
15
15
  help: "boolean", version: "boolean", continue: "boolean", resume: "boolean",
16
16
  "no-session": "boolean", "no-tools": "boolean", "no-builtin-tools": "boolean",
17
- "no-extensions": "boolean", "no-skills": "boolean", "no-prompt-templates": "boolean",
17
+ "no-extensions": "boolean", "no-mcp": "boolean", "no-skills": "boolean", "no-prompt-templates": "boolean",
18
18
  "no-themes": "boolean", "no-context-files": "boolean", verbose: "boolean",
19
19
  approve: "boolean", "no-approve": "boolean", offline: "boolean",
20
20
  mode: "value", provider: "value", model: "value", "api-key": "value",
@@ -55,13 +55,48 @@ export function validateStructuredOutputValue(schema: JsonSchemaObject, value: u
55
55
  return { status: "invalid", message: errors.join("; ") || "schema validation failed" };
56
56
  }
57
57
 
58
+ const VALIDATION_PREFIX = "Structured output validation failed:";
59
+ const PARAMETER_VALIDATION_PREFIX = 'Validation failed for tool "structured_output":';
60
+ const PARSE_VALIDATION_PREFIX = 'Invalid arguments for tool "structured_output":';
61
+ const INVALID_ARGUMENTS = "Invalid structured_output arguments";
62
+
63
+ function normalizeValidationDiagnostic(content: string): string {
64
+ let diagnostic = content.split("Received arguments:", 1)[0].trim();
65
+ for (;;) {
66
+ if (diagnostic.startsWith(PARSE_VALIDATION_PREFIX)) return INVALID_ARGUMENTS;
67
+ const prefix = [VALIDATION_PREFIX, PARAMETER_VALIDATION_PREFIX].find((prefix) => diagnostic.startsWith(prefix));
68
+ if (!prefix) break;
69
+ diagnostic = diagnostic.slice(prefix.length).trim();
70
+ }
71
+ return diagnostic || INVALID_ARGUMENTS;
72
+ }
73
+
74
+ export function getStructuredOutputValidationDiagnostic(content: unknown): string | undefined {
75
+ if (typeof content !== "string") return undefined;
76
+ if (![VALIDATION_PREFIX, PARAMETER_VALIDATION_PREFIX, PARSE_VALIDATION_PREFIX].some((prefix) => content.startsWith(prefix))) return undefined;
77
+ return normalizeValidationDiagnostic(content);
78
+ }
79
+
58
80
  export function readStructuredOutput(runtime: StructuredOutputRuntime): { value?: unknown; error?: string } {
59
81
  if (!fs.existsSync(runtime.outputPath)) {
60
82
  return { error: "Missing structured_output call; this step has outputSchema and must finish by calling structured_output." };
61
83
  }
62
84
  let value: unknown;
63
85
  try {
64
- value = JSON.parse(fs.readFileSync(runtime.outputPath, "utf-8"));
86
+ const envelope: unknown = JSON.parse(fs.readFileSync(runtime.outputPath, "utf-8"));
87
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
88
+ throw new Error("Expected a capture envelope object.");
89
+ }
90
+ const capture = envelope as Record<string, unknown>;
91
+ if (capture.status === "error") {
92
+ if (typeof capture.message !== "string" || !capture.message.trim()) {
93
+ throw new Error("Error capture requires a nonempty message.");
94
+ }
95
+ return { error: `${VALIDATION_PREFIX} ${normalizeValidationDiagnostic(capture.message)}` };
96
+ }
97
+ if (capture.status !== "success") throw new Error("Unknown capture status.");
98
+ if (!Object.hasOwn(capture, "value")) throw new Error("Success capture requires a value.");
99
+ value = capture.value;
65
100
  } catch (error) {
66
101
  return { error: `Failed to read structured output: ${error instanceof Error ? error.message : String(error)}` };
67
102
  }
@@ -2,11 +2,30 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { SUBAGENT_FANOUT_CHILD_ENV } from "./pi-args.ts";
5
- import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, STRUCTURED_OUTPUT_TOOL_NAME, validateStructuredOutputValue } from "./structured-output.ts";
5
+ import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, STRUCTURED_OUTPUT_TOOL_NAME, getStructuredOutputValidationDiagnostic, validateStructuredOutputValue } from "./structured-output.ts";
6
6
  import type { JsonSchemaObject } from "../../shared/types.ts";
7
7
 
8
8
  const SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV = "PI_SUBAGENT_INHERIT_PROJECT_CONTEXT";
9
9
  const SUBAGENT_INHERIT_SKILLS_ENV = "PI_SUBAGENT_INHERIT_SKILLS";
10
+ let reasked = false;
11
+ let attempted = false;
12
+
13
+ function lastAssistantStopReason(messages: unknown[]): string | undefined {
14
+ for (let i = messages.length - 1; i >= 0; i--) {
15
+ const message = messages[i] as { role?: string; stopReason?: string };
16
+ if (message?.role === "assistant") return message.stopReason;
17
+ }
18
+ return undefined;
19
+ }
20
+
21
+ function reaskMessage(schema: JsonSchemaObject) {
22
+ return {
23
+ type: "custom_message",
24
+ customType: "pi-cohort:structured-output-reask",
25
+ display: true,
26
+ content: "You finished without calling `structured_output`. Call `structured_output` now with a `value` matching this schema, and send no other text:\n\n" + JSON.stringify(schema, null, 2),
27
+ };
28
+ }
10
29
 
11
30
  const STRUCTURED_OUTPUT_INSTRUCTIONS = [
12
31
  "This subagent step has a strict structured output contract.",
@@ -176,6 +195,10 @@ function collectSubagentToolCallIds(entries: readonly unknown[]): Set<string> {
176
195
  }
177
196
 
178
197
  export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
198
+ const onRuntimeEvent = pi.on as unknown as (
199
+ event: string,
200
+ handler: (event: unknown, ctx?: { sessionManager?: { getBranch(): unknown[] } }) => unknown,
201
+ ) => void;
179
202
  const structuredOutputPath = process.env[STRUCTURED_OUTPUT_CAPTURE_ENV];
180
203
  const structuredSchemaPath = process.env[STRUCTURED_OUTPUT_SCHEMA_ENV];
181
204
  if (structuredOutputPath && structuredSchemaPath) {
@@ -199,12 +222,13 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
199
222
  description: "Submit the required final structured output for this subagent step. This terminates the step.",
200
223
  parameters: parameters as never,
201
224
  async execute(_id: string, params: { value: unknown }) {
225
+ attempted = true;
202
226
  const validation = validateStructuredOutputValue(schema, params.value);
203
227
  if (validation.status === "invalid") {
204
228
  throw new Error(`Structured output validation failed: ${validation.message}`);
205
229
  }
206
230
  fs.mkdirSync(path.dirname(structuredOutputPath), { recursive: true });
207
- fs.writeFileSync(structuredOutputPath, JSON.stringify(params.value), { mode: 0o600 });
231
+ fs.writeFileSync(structuredOutputPath, JSON.stringify({ status: "success", value: params.value }), { mode: 0o600 });
208
232
  return {
209
233
  content: [{ type: "text", text: "Structured output captured." }],
210
234
  details: { path: structuredOutputPath },
@@ -212,13 +236,47 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
212
236
  };
213
237
  },
214
238
  });
239
+ onRuntimeEvent("tool_execution_start", (event) => {
240
+ const e = event as { toolName: string };
241
+ if (e.toolName === STRUCTURED_OUTPUT_TOOL_NAME) attempted = true;
242
+ });
243
+ onRuntimeEvent("tool_execution_end", (event) => {
244
+ const e = event as { toolName: string; isError: boolean; result?: { content?: { type: string; text?: string }[] } };
245
+ if (e.toolName !== STRUCTURED_OUTPUT_TOOL_NAME) return;
246
+ attempted = true;
247
+ if (!e.isError) return;
248
+ const text = e.result?.content?.filter((block) => block.type === "text").map((block) => block.text ?? "").join("\n");
249
+ const message = getStructuredOutputValidationDiagnostic(text);
250
+ if (message === undefined) return;
251
+ try {
252
+ const capture = JSON.parse(fs.readFileSync(structuredOutputPath, "utf-8"));
253
+ if (capture?.status === "success") return;
254
+ } catch (error) {
255
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") return;
256
+ }
257
+ try {
258
+ fs.writeFileSync(structuredOutputPath, JSON.stringify({ status: "error", message }), { mode: 0o600 });
259
+ } catch {
260
+ // The parent owns capture-read errors when recording the attempt fails.
261
+ }
262
+ });
263
+ onRuntimeEvent("agent_before_settle", (event) => {
264
+ const e = event as { outcome: string; entries: unknown[]; context: { contextMessages: unknown[] } };
265
+ if (reasked || attempted) return undefined;
266
+ if (e.outcome !== "completed") return undefined;
267
+ if (lastAssistantStopReason(e.context.contextMessages) === "length") return undefined;
268
+ try {
269
+ fs.statSync(structuredOutputPath);
270
+ return undefined;
271
+ } catch (error) {
272
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") return undefined;
273
+ }
274
+ reasked = true;
275
+ return { entries: [...e.entries, reaskMessage(schema)], continue: true };
276
+ });
215
277
  }
216
278
 
217
279
  let inheritedSubagentToolCallIds: ReadonlySet<string> = new Set<string>();
218
- const onRuntimeEvent = pi.on as unknown as (
219
- event: string,
220
- handler: (event: unknown, ctx?: { sessionManager?: { getBranch(): unknown[] } }) => unknown,
221
- ) => void;
222
280
  onRuntimeEvent("session_start", (_event: unknown, ctx?: { sessionManager?: { getBranch(): unknown[] } }) => {
223
281
  // Establishes the inherited/own boundary once, before the child's own first turn: any
224
282
  // `subagent` call already in the branch at this point came from the (forked) parent.
@@ -6,6 +6,7 @@ import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import type { Message } from "@earendil-works/pi-ai";
9
+ import { getStructuredOutputValidationDiagnostic, STRUCTURED_OUTPUT_TOOL_NAME } from "../runs/shared/structured-output.ts";
9
10
  import { formatToolCall } from "./formatters.ts";
10
11
  import type { AgentProgress, AsyncStatus, Details, DisplayItem, ErrorInfo, SingleResult, ToolCallSummary } from "./types.ts";
11
12
 
@@ -293,6 +294,7 @@ export function detectSubagentError(messages: Message[]): ErrorInfo {
293
294
  }
294
295
 
295
296
  const scanStart = lastAssistantTextIndex >= 0 ? lastAssistantTextIndex + 1 : 0;
297
+ let hasLaterStructuredOutputSuccess = false;
296
298
 
297
299
  for (let i = messages.length - 1; i >= scanStart; i--) {
298
300
  const msg = messages[i];
@@ -300,9 +302,15 @@ export function detectSubagentError(messages: Message[]): ErrorInfo {
300
302
  const toolName = "toolName" in msg && typeof msg.toolName === "string" ? msg.toolName : undefined;
301
303
  const isError = "isError" in msg && msg.isError === true;
302
304
 
305
+ if (toolName === STRUCTURED_OUTPUT_TOOL_NAME && !isError) {
306
+ hasLaterStructuredOutputSuccess = true;
307
+ }
308
+
303
309
  if (isError) {
304
310
  const text = msg.content.find((c) => c.type === "text");
305
311
  const details = text && "text" in text ? text.text : undefined;
312
+ if (toolName === STRUCTURED_OUTPUT_TOOL_NAME && hasLaterStructuredOutputSuccess
313
+ && getStructuredOutputValidationDiagnostic(details) !== undefined) continue;
306
314
  const exitMatch = details?.match(/exit(?:ed)?\s*(?:with\s*)?(?:code|status)?\s*[:\s]?\s*(\d+)/i);
307
315
  return {
308
316
  hasError: true,