@oh-my-pi/pi-coding-agent 17.0.2 → 17.0.4

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 (80) hide show
  1. package/CHANGELOG.md +80 -20
  2. package/dist/cli.js +4087 -4108
  3. package/dist/types/config/settings-schema.d.ts +7 -3
  4. package/dist/types/eval/agent-bridge.d.ts +7 -19
  5. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +15 -1
  6. package/dist/types/lsp/client.d.ts +2 -0
  7. package/dist/types/lsp/types.d.ts +3 -0
  8. package/dist/types/mcp/transports/stdio.d.ts +29 -0
  9. package/dist/types/mnemopi/state.d.ts +32 -9
  10. package/dist/types/modes/components/agent-hub.d.ts +15 -0
  11. package/dist/types/registry/persisted-agents.d.ts +3 -0
  12. package/dist/types/sdk.d.ts +14 -3
  13. package/dist/types/session/session-entries.d.ts +6 -1
  14. package/dist/types/session/session-manager.d.ts +5 -0
  15. package/dist/types/task/executor.d.ts +26 -1
  16. package/dist/types/task/index.d.ts +1 -1
  17. package/dist/types/task/parallel.d.ts +14 -0
  18. package/dist/types/task/structured-subagent.d.ts +111 -0
  19. package/dist/types/task/types.d.ts +51 -0
  20. package/dist/types/tools/fetch.d.ts +4 -5
  21. package/dist/types/tools/hub/messaging.d.ts +5 -1
  22. package/dist/types/tools/index.d.ts +16 -1
  23. package/dist/types/tools/todo.d.ts +31 -0
  24. package/dist/types/tui/tree-list.d.ts +7 -0
  25. package/dist/types/utils/markit.d.ts +11 -0
  26. package/package.json +12 -12
  27. package/src/cli/file-processor.ts +1 -2
  28. package/src/config/settings-schema.ts +4 -3
  29. package/src/eval/__tests__/agent-bridge.test.ts +133 -47
  30. package/src/eval/__tests__/prelude-agent.test.ts +29 -0
  31. package/src/eval/agent-bridge.ts +104 -477
  32. package/src/eval/jl/prelude.jl +7 -6
  33. package/src/eval/js/shared/prelude.txt +5 -4
  34. package/src/eval/py/prelude.py +11 -39
  35. package/src/eval/rb/prelude.rb +5 -6
  36. package/src/extensibility/legacy-pi-coding-agent-shim.ts +53 -7
  37. package/src/lsp/client.ts +57 -0
  38. package/src/lsp/index.ts +62 -6
  39. package/src/lsp/types.ts +3 -0
  40. package/src/mcp/oauth-flow.ts +20 -0
  41. package/src/mcp/transports/stdio.test.ts +269 -1
  42. package/src/mcp/transports/stdio.ts +152 -1
  43. package/src/mnemopi/backend.ts +1 -1
  44. package/src/mnemopi/embed-worker.ts +8 -7
  45. package/src/mnemopi/state.ts +45 -18
  46. package/src/modes/components/agent-hub.ts +1 -72
  47. package/src/modes/components/bash-execution.ts +7 -3
  48. package/src/modes/components/eval-execution.ts +3 -1
  49. package/src/modes/components/transcript-container.ts +13 -7
  50. package/src/modes/interactive-mode.ts +30 -8
  51. package/src/prompts/system/system-prompt.md +1 -1
  52. package/src/prompts/tools/eval.md +5 -2
  53. package/src/prompts/tools/read.md +1 -1
  54. package/src/prompts/tools/task.md +12 -8
  55. package/src/prompts/tools/write.md +1 -1
  56. package/src/registry/persisted-agents.ts +74 -0
  57. package/src/sdk.ts +129 -87
  58. package/src/session/agent-session.ts +75 -21
  59. package/src/session/session-entries.ts +6 -1
  60. package/src/session/session-loader.ts +31 -3
  61. package/src/session/session-manager.ts +9 -0
  62. package/src/session/streaming-output.ts +41 -1
  63. package/src/stt/recorder.ts +68 -55
  64. package/src/system-prompt.ts +7 -2
  65. package/src/task/executor.ts +99 -21
  66. package/src/task/index.ts +258 -429
  67. package/src/task/parallel.ts +43 -0
  68. package/src/task/persisted-revive.ts +19 -7
  69. package/src/task/structured-subagent.ts +642 -0
  70. package/src/task/types.ts +61 -0
  71. package/src/tools/__tests__/eval-description.test.ts +1 -1
  72. package/src/tools/fetch.ts +28 -105
  73. package/src/tools/hub/messaging.ts +16 -3
  74. package/src/tools/index.ts +47 -14
  75. package/src/tools/path-utils.ts +1 -0
  76. package/src/tools/read.ts +14 -26
  77. package/src/tools/todo.ts +126 -13
  78. package/src/tui/tree-list.ts +39 -0
  79. package/src/utils/markit.ts +12 -0
  80. package/src/utils/zip.ts +16 -1
@@ -18,6 +18,7 @@ import {
18
18
  stringifyJson,
19
19
  toError,
20
20
  } from "@oh-my-pi/pi-utils";
21
+ import type { StructuredSubagentSchemaMode } from "../task/types";
21
22
  import { ArtifactManager } from "./artifacts";
22
23
  import { type BlobPutOptions, type BlobPutResult, BlobStore } from "./blob-store";
23
24
  import {
@@ -1558,6 +1559,8 @@ export class SessionManager {
1558
1559
  task: string;
1559
1560
  tools: string[];
1560
1561
  outputSchema?: unknown;
1562
+ outputSchemaMode?: StructuredSubagentSchemaMode;
1563
+ restrictToolNames?: boolean;
1561
1564
  spawns?: string;
1562
1565
  readSummarize?: boolean;
1563
1566
  }): string {
@@ -1996,6 +1999,8 @@ export class SessionManager {
1996
1999
  task: string;
1997
2000
  tools: string[];
1998
2001
  outputSchema?: unknown;
2002
+ outputSchemaMode?: StructuredSubagentSchemaMode;
2003
+ restrictToolNames?: boolean;
1999
2004
  spawns?: string;
2000
2005
  readSummarize?: boolean;
2001
2006
  } | null;
@@ -2014,6 +2019,8 @@ export class SessionManager {
2014
2019
  task: string;
2015
2020
  tools: string[];
2016
2021
  outputSchema?: unknown;
2022
+ outputSchemaMode?: StructuredSubagentSchemaMode;
2023
+ restrictToolNames?: boolean;
2017
2024
  spawns?: string;
2018
2025
  readSummarize?: boolean;
2019
2026
  } | null = null;
@@ -2025,6 +2032,8 @@ export class SessionManager {
2025
2032
  task: entry.task,
2026
2033
  tools: entry.tools,
2027
2034
  outputSchema: entry.outputSchema,
2035
+ outputSchemaMode: entry.outputSchemaMode,
2036
+ restrictToolNames: entry.restrictToolNames,
2028
2037
  readSummarize: entry.readSummarize,
2029
2038
  spawns: entry.spawns,
2030
2039
  };
@@ -22,6 +22,7 @@ export const ARTIFACT_DEFAULT_MAX_BYTES = 0;
22
22
  export const ARTIFACT_DEFAULT_HEAD_BYTES = 3 * 1024 * 1024; // 3 MiB
23
23
 
24
24
  const NL = "\n";
25
+ const CR = "\r";
25
26
  const ELLIPSIS = "…";
26
27
 
27
28
  // =============================================================================
@@ -737,6 +738,7 @@ export class OutputSink {
737
738
  #truncated = false;
738
739
  #lastChunkTime = 0;
739
740
  #pendingChunk = "";
741
+ #pendingCarriageReturn = false;
740
742
  #pendingChunkTimer: Timer | undefined;
741
743
 
742
744
  // Per-line column cap streaming state (persists across `push` calls so a
@@ -802,12 +804,45 @@ export class OutputSink {
802
804
  this.#artifactTailBudget = Math.max(0, this.#artifactMaxBytes - this.#artifactHeadBudget);
803
805
  }
804
806
 
807
+ /**
808
+ * Converts carriage-return progress updates into line boundaries while
809
+ * collapsing CRLF to one newline. A trailing CR is held until the next
810
+ * chunk so split CRLF sequences do not create blank lines.
811
+ */
812
+ #normalizeCarriageReturns(text: string): string {
813
+ if (text.length === 0 || (!this.#pendingCarriageReturn && !text.includes(CR))) return text;
814
+
815
+ let cursor = 0;
816
+ let normalized = "";
817
+ if (this.#pendingCarriageReturn) {
818
+ this.#pendingCarriageReturn = false;
819
+ normalized = NL;
820
+ if (text.startsWith(NL)) cursor = 1;
821
+ }
822
+
823
+ while (cursor < text.length) {
824
+ const carriageReturn = text.indexOf(CR, cursor);
825
+ if (carriageReturn === -1) {
826
+ normalized += text.substring(cursor);
827
+ break;
828
+ }
829
+ normalized += text.substring(cursor, carriageReturn);
830
+ if (carriageReturn === text.length - 1) {
831
+ this.#pendingCarriageReturn = true;
832
+ break;
833
+ }
834
+ normalized += NL;
835
+ cursor = text.startsWith(NL, carriageReturn + 1) ? carriageReturn + 2 : carriageReturn + 1;
836
+ }
837
+ return normalized;
838
+ }
839
+
805
840
  /**
806
841
  * Push a chunk of output. The buffer management and onChunk callback run
807
842
  * synchronously. File sink writes are deferred and serialized internally.
808
843
  */
809
844
  push(chunk: string): void {
810
- chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
845
+ chunk = sanitizeWithOptionalSixelPassthrough(chunk, text => sanitizeText(this.#normalizeCarriageReturns(text)));
811
846
 
812
847
  // Throttled onChunk: coalesce chunks arriving inside the throttle window.
813
848
  // A timer flushes quiet tails at the throttle boundary; dump() catches a
@@ -1137,6 +1172,7 @@ export class OutputSink {
1137
1172
  this.#columnDroppedBytes = 0;
1138
1173
  this.#columnTruncatedLines = 0;
1139
1174
  this.#pendingChunk = "";
1175
+ this.#pendingCarriageReturn = false;
1140
1176
  }
1141
1177
 
1142
1178
  #clearPendingChunkTimer(): void {
@@ -1208,6 +1244,10 @@ export class OutputSink {
1208
1244
  }
1209
1245
 
1210
1246
  async dump(notice?: string): Promise<OutputSummary> {
1247
+ if (this.#pendingCarriageReturn) {
1248
+ this.#pendingCarriageReturn = false;
1249
+ this.push(NL);
1250
+ }
1211
1251
  const noticeLine = notice ? `[${notice}]\n` : "";
1212
1252
 
1213
1253
  // Flush any chunk still held back by the throttle so the live preview
@@ -11,6 +11,8 @@ export interface RecordingHandle {
11
11
  }
12
12
 
13
13
  const isWindows = process.platform === "win32";
14
+ const linuxFFmpegFormats = new Map<string, "pulse" | "alsa">();
15
+ const ffmpegCaptureFlags = ["-hide_banner", "-loglevel", "error", "-nostats"];
14
16
 
15
17
  /**
16
18
  * Returns available recording tools in priority order.
@@ -36,6 +38,36 @@ async function detectWindowsAudioDevice(bin: string): Promise<string> {
36
38
  return audioDevices[0];
37
39
  }
38
40
 
41
+ async function ffmpegInputArgs(bin: string): Promise<string[]> {
42
+ if (isWindows) {
43
+ return ["-f", "dshow", "-i", `audio=${await detectWindowsAudioDevice(bin)}`];
44
+ }
45
+ if (process.platform === "darwin") {
46
+ return ["-f", "avfoundation", "-i", ":default"];
47
+ }
48
+
49
+ let format = linuxFFmpegFormats.get(bin);
50
+ if (!format) {
51
+ const result = await $`${bin} -hide_banner -demuxers`.quiet().nothrow();
52
+ if (result.exitCode !== 0) {
53
+ const stderr = result.stderr.toString().trim();
54
+ throw new Error(
55
+ `Could not inspect ffmpeg input formats (code ${result.exitCode}): ${stderr || "(no output)"}`,
56
+ );
57
+ }
58
+ const demuxers = result.stdout.toString();
59
+ if (/^\s*D\s+(?:d\s+)?pulse(?:\s|$)/m.test(demuxers)) {
60
+ format = "pulse";
61
+ } else if (/^\s*D\s+(?:d\s+)?alsa(?:\s|$)/m.test(demuxers)) {
62
+ format = "alsa";
63
+ } else {
64
+ throw new Error("ffmpeg supports neither PulseAudio nor ALSA input on Linux");
65
+ }
66
+ linuxFFmpegFormats.set(bin, format);
67
+ }
68
+ return ["-f", format, "-i", "default"];
69
+ }
70
+
39
71
  // ── Recording implementations ──────────────────────────────────────
40
72
 
41
73
  async function startSoxRecording(bin: string, outputPath: string): Promise<RecordingHandle> {
@@ -44,7 +76,7 @@ async function startSoxRecording(bin: string, outputPath: string): Promise<Recor
44
76
 
45
77
  const proc = Bun.spawn([bin, ...inputArgs, "-r", "16000", "-c", "1", "-b", "16", "-t", "wav", outputPath], {
46
78
  stdout: "pipe",
47
- stderr: "ignore",
79
+ stderr: "pipe",
48
80
  });
49
81
  await verifyProcessAlive(proc, "sox");
50
82
  return {
@@ -56,48 +88,24 @@ async function startSoxRecording(bin: string, outputPath: string): Promise<Recor
56
88
  }
57
89
 
58
90
  async function startFFmpegRecording(bin: string, outputPath: string): Promise<RecordingHandle> {
59
- let args: string[];
60
- if (isWindows) {
61
- const device = await detectWindowsAudioDevice(bin);
62
- args = [
63
- bin,
64
- "-f",
65
- "dshow",
66
- "-i",
67
- `audio=${device}`,
68
- "-ar",
69
- "16000",
70
- "-ac",
71
- "1",
72
- "-sample_fmt",
73
- "s16",
74
- "-y",
75
- outputPath,
76
- ];
77
- } else if (process.platform === "darwin") {
78
- args = [
79
- bin,
80
- "-f",
81
- "avfoundation",
82
- "-i",
83
- ":default",
84
- "-ar",
85
- "16000",
86
- "-ac",
87
- "1",
88
- "-sample_fmt",
89
- "s16",
90
- "-y",
91
- outputPath,
92
- ];
93
- } else {
94
- args = [bin, "-f", "pulse", "-i", "default", "-ar", "16000", "-ac", "1", "-sample_fmt", "s16", "-y", outputPath];
95
- }
91
+ const args = [
92
+ bin,
93
+ ...ffmpegCaptureFlags,
94
+ ...(await ffmpegInputArgs(bin)),
95
+ "-ar",
96
+ "16000",
97
+ "-ac",
98
+ "1",
99
+ "-sample_fmt",
100
+ "s16",
101
+ "-y",
102
+ outputPath,
103
+ ];
96
104
 
97
105
  const proc = Bun.spawn(args, {
98
106
  stdin: "pipe",
99
107
  stdout: "pipe",
100
- stderr: "ignore",
108
+ stderr: "pipe",
101
109
  });
102
110
  await verifyProcessAlive(proc, "ffmpeg");
103
111
 
@@ -119,7 +127,7 @@ async function startFFmpegRecording(bin: string, outputPath: string): Promise<Re
119
127
  async function startArecordRecording(bin: string, outputPath: string): Promise<RecordingHandle> {
120
128
  const proc = Bun.spawn([bin, "-f", "S16_LE", "-r", "16000", "-c", "1", outputPath], {
121
129
  stdout: "pipe",
122
- stderr: "ignore",
130
+ stderr: "pipe",
123
131
  });
124
132
  await verifyProcessAlive(proc, "arecord");
125
133
  return {
@@ -260,20 +268,19 @@ async function startPowerShellRecording(outputPath: string): Promise<RecordingHa
260
268
 
261
269
  // ── Health check ───────────────────────────────────────────────────
262
270
 
263
- type RecorderProcess = Subprocess<"ignore" | "pipe", "pipe", "ignore">;
271
+ type RecorderProcess = Subprocess<"ignore" | "pipe", "pipe", "pipe">;
264
272
 
265
273
  async function verifyProcessAlive(proc: RecorderProcess, tool: string): Promise<void> {
266
274
  await Bun.sleep(300);
267
275
 
268
276
  const exited = await Promise.race([proc.exited.then(code => code), Bun.sleep(0).then(() => "running" as const)]);
269
-
270
- if (exited !== "running") {
271
- let stderr = "";
272
- if (proc.stderr && typeof proc.stderr !== "number") {
273
- stderr = await new Response(proc.stderr as ReadableStream).text();
274
- }
275
- throw new Error(`${tool} exited immediately (code ${exited}): ${stderr.trim() || "(no output)"}`);
277
+ if (exited === "running") {
278
+ void proc.stderr.pipeTo(new WritableStream<Uint8Array>()).catch(() => {});
279
+ return;
276
280
  }
281
+
282
+ const stderr = await new Response(proc.stderr).text();
283
+ throw new Error(`${tool} exited immediately (code ${exited}): ${stderr.trim() || "(no output)"}`);
277
284
  }
278
285
 
279
286
  // ── Public API ─────────────────────────────────────────────────────
@@ -415,12 +422,18 @@ async function streamingRecorderArgs(recorder: ResolvedRecorder): Promise<string
415
422
  case "arecord":
416
423
  return [bin, "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "raw", "-"];
417
424
  case "ffmpeg": {
418
- const input = isWindows
419
- ? ["-f", "dshow", "-i", `audio=${await detectWindowsAudioDevice(bin)}`]
420
- : process.platform === "darwin"
421
- ? ["-f", "avfoundation", "-i", ":default"]
422
- : ["-f", "pulse", "-i", "default"];
423
- return [bin, ...input, "-ar", "16000", "-ac", "1", "-f", "s16le", "pipe:1"];
425
+ return [
426
+ bin,
427
+ ...ffmpegCaptureFlags,
428
+ ...(await ffmpegInputArgs(bin)),
429
+ "-ar",
430
+ "16000",
431
+ "-ac",
432
+ "1",
433
+ "-f",
434
+ "s16le",
435
+ "pipe:1",
436
+ ];
424
437
  }
425
438
  case "powershell":
426
439
  throw new Error("PowerShell recorder cannot stream PCM to a pipe");
@@ -439,7 +452,7 @@ async function startStreamingRecordingWithRecorder(
439
452
  ): Promise<StreamingRecordingHandle> {
440
453
  const args = await streamingRecorderArgs(recorder);
441
454
  logger.debug("Starting streaming audio recording", { tool: recorder.tool, bin: recorder.bin });
442
- const proc = Bun.spawn(args, { stdin: "pipe", stdout: "pipe", stderr: "ignore" });
455
+ const proc = Bun.spawn(args, { stdin: "pipe", stdout: "pipe", stderr: "pipe" });
443
456
 
444
457
  // Read s16le bytes off stdout, carrying any trailing odd byte across chunk
445
458
  // boundaries so a sample is never split. Runs until the process closes stdout.
@@ -729,13 +729,18 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
729
729
  if (!toolPromptNames.has(mounted.name)) toolPromptNames.set(mounted.name, mounted.name);
730
730
  }
731
731
  const toolRefs = Object.fromEntries(toolPromptNames.entries());
732
- const toolInfo = toolNames.map(name => ({
732
+ const xdevToolNames = new Set(xdevTools.map(mounted => mounted.name));
733
+ // A direct custom tool can share a name with a retained built-in device.
734
+ // Presence in both toolNames and tools proves it still has a top-level definition.
735
+ const inventoryToolNames =
736
+ xdevToolNames.size === 0 ? toolNames : toolNames.filter(name => tools?.has(name) || !xdevToolNames.has(name));
737
+ const toolInfo = inventoryToolNames.map(name => ({
733
738
  name: toolPromptNames.get(name) ?? name,
734
739
  internalName: name,
735
740
  label: tools?.get(name)?.label ?? "",
736
741
  description: tools?.get(name)?.description ?? "",
737
742
  }));
738
- const inventoryTools = toolNames.map(name => {
743
+ const inventoryTools = inventoryToolNames.map(name => {
739
744
  const meta = tools?.get(name);
740
745
  return {
741
746
  name: toolPromptNames.get(name) ?? name,
@@ -61,6 +61,9 @@ import {
61
61
  MAX_OUTPUT_BYTES,
62
62
  MAX_OUTPUT_LINES,
63
63
  type SingleResult,
64
+ type StructuredSubagentOutput,
65
+ type StructuredSubagentSchemaMode,
66
+ type StructuredSubagentSchemaSource,
64
67
  TASK_SUBAGENT_EVENT_CHANNEL,
65
68
  TASK_SUBAGENT_LIFECYCLE_CHANNEL,
66
69
  TASK_SUBAGENT_PROGRESS_CHANNEL,
@@ -292,7 +295,12 @@ export interface ExecutorOptions {
292
295
  */
293
296
  parentActiveModelPattern?: string;
294
297
  thinkingLevel?: ConfiguredThinkingLevel;
298
+ /** Schema used to validate the final structured completion. */
295
299
  outputSchema?: unknown;
300
+ /** Enforcement policy for {@link outputSchema}; defaults to legacy permissive behavior. */
301
+ outputSchemaMode?: StructuredSubagentSchemaMode;
302
+ /** Origin of the selected schema, preserved in {@link SingleResult.structuredOutput}. */
303
+ outputSchemaSource?: StructuredSubagentSchemaSource;
296
304
  /**
297
305
  * Caller supplied a schema that supersedes the agent's native output prompt.
298
306
  * Eval `agent(..., schema=...)` sets this so built-in agents ignore stale yield labels.
@@ -307,7 +315,20 @@ export interface ExecutorOptions {
307
315
  * watchdog is already suspended for the call's duration.
308
316
  */
309
317
  maxRuntimeMs?: number;
318
+ /** Include IRC only when the invocation policy permits collaboration. */
319
+ enableIrc?: boolean;
310
320
  enableLsp?: boolean;
321
+ /**
322
+ * Enable MCP capabilities for this child. `false` suppresses both inherited
323
+ * MCP proxy tools and session MCP discovery; it never consults the
324
+ * process-global MCP manager. Defaults to `true`.
325
+ */
326
+ enableMCP?: boolean;
327
+ /**
328
+ * Limit the child to its explicit host tool names and the required yield
329
+ * tool, suppressing discovered and always-included capabilities.
330
+ */
331
+ restrictToolNames?: boolean;
311
332
  signal?: AbortSignal;
312
333
  onProgress?: (progress: AgentProgress) => void;
313
334
  /**
@@ -450,6 +471,8 @@ interface FinalizeSubprocessOutputArgs {
450
471
  signalAborted: boolean;
451
472
  yieldItems?: YieldItem[];
452
473
  outputSchema: unknown;
474
+ outputSchemaMode?: StructuredSubagentSchemaMode;
475
+ outputSchemaSource?: StructuredSubagentSchemaSource;
453
476
  lastAssistantText?: string;
454
477
  }
455
478
 
@@ -459,6 +482,7 @@ interface FinalizeSubprocessOutputResult {
459
482
  stderr: string;
460
483
  abortedViaYield: boolean;
461
484
  hasYield: boolean;
485
+ structuredOutput?: StructuredSubagentOutput;
462
486
  }
463
487
  export const SUBAGENT_WARNING_SCHEMA_OVERRIDDEN =
464
488
  "SYSTEM WARNING: Subagent exhausted schema-retry budget; result was accepted despite failing the output schema.";
@@ -494,6 +518,10 @@ function buildSchemaViolationOutcome(
494
518
  export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): FinalizeSubprocessOutputResult {
495
519
  let { rawOutput, exitCode, stderr } = args;
496
520
  const { yieldItems, doneAborted, signalAborted, outputSchema, lastAssistantText } = args;
521
+ const mode = args.outputSchemaMode ?? "permissive";
522
+ const source = args.outputSchemaSource ?? (outputSchema === undefined ? "none" : "session");
523
+ const includeStructuredOutput = source !== "none";
524
+ let structuredOutput: StructuredSubagentOutput | undefined;
497
525
  let abortedViaYield = false;
498
526
  const hasYield = Array.isArray(yieldItems) && yieldItems.length > 0;
499
527
  const hadFailureBeforeYield = exitCode !== 0 && stderr.trim().length > 0;
@@ -514,15 +542,35 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
514
542
  if (!assembled || assembled.missingData) {
515
543
  rawOutput = rawOutput ? `${SUBAGENT_WARNING_NULL_YIELD}\n\n${rawOutput}` : SUBAGENT_WARNING_NULL_YIELD;
516
544
  } else {
517
- const { validator, error: schemaError } = buildOutputValidator(outputSchema);
545
+ const { validator, error: schemaError, normalized } = buildOutputValidator(outputSchema);
518
546
  const completeData = assembled.rawText ? assembled.data : parseStringifiedJson(assembled.data ?? null);
519
- const result =
520
- schemaError || assembled.schemaOverridden
521
- ? { success: true as const }
522
- : (validator?.validate(completeData) ?? { success: true as const });
523
- if (!result.success) {
524
- const summary = summarizeValidationFailure(result, completeData, validator?.requiredFields ?? []);
525
- const outcome = buildSchemaViolationOutcome(summary, completeData);
547
+ const validation = validator?.validate(completeData);
548
+ const failure =
549
+ validation && !validation.success
550
+ ? summarizeValidationFailure(validation, completeData, validator?.requiredFields ?? [])
551
+ : assembled.schemaOverridden
552
+ ? { message: SUBAGENT_WARNING_SCHEMA_OVERRIDDEN, missingRequired: [] }
553
+ : schemaError
554
+ ? { message: `invalid output schema: ${schemaError}`, missingRequired: [] }
555
+ : undefined;
556
+ if (includeStructuredOutput) {
557
+ structuredOutput =
558
+ schemaError || normalized === undefined
559
+ ? {
560
+ source,
561
+ mode,
562
+ status: "unavailable",
563
+ data: completeData,
564
+ error: schemaError ? `invalid output schema: ${schemaError}` : undefined,
565
+ }
566
+ : failure
567
+ ? { source, mode, status: "invalid", data: completeData, error: failure.message }
568
+ : { source, mode, status: "valid", data: completeData };
569
+ }
570
+ const mustReject =
571
+ failure !== undefined && (mode === "strict" || (!assembled.schemaOverridden && !schemaError));
572
+ if (mustReject && failure) {
573
+ const outcome = buildSchemaViolationOutcome(failure, completeData);
526
574
  rawOutput = outcome.rawOutput;
527
575
  stderr = outcome.stderr;
528
576
  exitCode = outcome.exitCode;
@@ -540,9 +588,7 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
540
588
  exitCode = 0;
541
589
  stderr = assembled.schemaOverridden
542
590
  ? SUBAGENT_WARNING_SCHEMA_OVERRIDDEN
543
- : schemaError
544
- ? `invalid output schema: ${schemaError}`
545
- : "";
591
+ : (structuredOutput?.error ?? "");
546
592
  } else if (!stderr) {
547
593
  stderr = "Subagent failed after yielding a result.";
548
594
  }
@@ -560,11 +606,22 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
560
606
  const result = validator?.validate(completeData) ?? { success: true as const };
561
607
  if (!result.success) {
562
608
  const summary = summarizeValidationFailure(result, completeData, validator?.requiredFields ?? []);
609
+ if (includeStructuredOutput) {
610
+ structuredOutput = { source, mode, status: "invalid", data: completeData, error: summary.message };
611
+ }
563
612
  const outcome = buildSchemaViolationOutcome(summary, completeData);
564
613
  rawOutput = outcome.rawOutput;
565
614
  stderr = outcome.stderr;
566
615
  exitCode = outcome.exitCode;
567
616
  } else {
617
+ if (includeStructuredOutput) {
618
+ structuredOutput = {
619
+ source,
620
+ mode,
621
+ status: "valid",
622
+ data: completeData,
623
+ };
624
+ }
568
625
  try {
569
626
  rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
570
627
  } catch (err) {
@@ -587,7 +644,7 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
587
644
  }
588
645
  }
589
646
 
590
- return { rawOutput, exitCode, stderr, abortedViaYield, hasYield };
647
+ return { rawOutput, exitCode, stderr, abortedViaYield, hasYield, structuredOutput };
591
648
  }
592
649
 
593
650
  /**
@@ -1710,6 +1767,8 @@ interface FinalizeRunArgs {
1710
1767
  assignment?: string;
1711
1768
  modelOverride?: string | string[];
1712
1769
  outputSchema?: unknown;
1770
+ outputSchemaMode?: StructuredSubagentSchemaMode;
1771
+ outputSchemaSource?: StructuredSubagentSchemaSource;
1713
1772
  signal?: AbortSignal;
1714
1773
  artifactsDir?: string;
1715
1774
  eventBus?: EventBus;
@@ -1747,6 +1806,8 @@ async function finalizeRunResult(args: FinalizeRunArgs): Promise<SingleResult> {
1747
1806
  signalAborted: Boolean(signal?.aborted),
1748
1807
  yieldItems,
1749
1808
  outputSchema: args.outputSchema,
1809
+ outputSchemaMode: args.outputSchemaMode,
1810
+ outputSchemaSource: args.outputSchemaSource,
1750
1811
  lastAssistantText: monitor.lastAssistantSalvageText(),
1751
1812
  });
1752
1813
  } finally {
@@ -1841,6 +1902,7 @@ async function finalizeRunResult(args: FinalizeRunArgs): Promise<SingleResult> {
1841
1902
  output: truncatedOutput,
1842
1903
  stderr,
1843
1904
  truncated: Boolean(truncated),
1905
+ ...(finalized.structuredOutput ? { structuredOutput: finalized.structuredOutput } : {}),
1844
1906
  durationMs: Date.now() - args.startTime,
1845
1907
  tokens: progress.tokens,
1846
1908
  requests: progress.requests,
@@ -1935,6 +1997,10 @@ export interface FollowUpTurnOptions {
1935
1997
  message: string;
1936
1998
  index?: number;
1937
1999
  description?: string;
2000
+ /** Structured-output state retained from the original invocation. */
2001
+ outputSchema?: unknown;
2002
+ outputSchemaMode?: StructuredSubagentSchemaMode;
2003
+ outputSchemaSource?: StructuredSubagentSchemaSource;
1938
2004
  signal?: AbortSignal;
1939
2005
  onProgress?: (progress: AgentProgress) => void;
1940
2006
  eventBus?: EventBus;
@@ -2019,6 +2085,9 @@ export async function runSubagentFollowUpTurn(options: FollowUpTurnOptions): Pro
2019
2085
  id,
2020
2086
  agent,
2021
2087
  task: message,
2088
+ outputSchema: options.outputSchema,
2089
+ outputSchemaMode: options.outputSchemaMode,
2090
+ outputSchemaSource: options.outputSchemaSource,
2022
2091
  signal,
2023
2092
  artifactsDir: options.artifactsDir,
2024
2093
  eventBus: options.eventBus,
@@ -2107,6 +2176,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2107
2176
  const parentDepth = options.taskDepth ?? 0;
2108
2177
  const childDepth = parentDepth + 1;
2109
2178
  const atMaxDepth = maxRecursionDepth >= 0 && childDepth >= maxRecursionDepth;
2179
+ const ircEnabled = options.enableIrc !== false && isIrcEnabled(subagentSettings, childDepth);
2110
2180
 
2111
2181
  // Add tools if specified
2112
2182
  let toolNames: string[] | undefined;
@@ -2121,9 +2191,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2121
2191
  if (atMaxDepth && toolNames?.includes("task")) {
2122
2192
  toolNames = toolNames.filter(name => name !== "task");
2123
2193
  }
2124
- // The hub is always available; the COOP prompt section advertises messaging,
2125
- // so a restricted whitelist must still carry `hub` for the subagent to use it.
2126
- if (toolNames && !toolNames.includes("hub")) {
2194
+ // Ordinary agents retain the host's always-on collaboration capability.
2195
+ // Restricted sessions must not widen their explicit host tool list with hub.
2196
+ if (toolNames && !options.restrictToolNames && !toolNames.includes("hub")) {
2127
2197
  toolNames = [...toolNames, "hub"];
2128
2198
  }
2129
2199
  if (toolNames?.includes("exec")) {
@@ -2145,7 +2215,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2145
2215
  : agent.spawns.join(",");
2146
2216
 
2147
2217
  const lspEnabled = enableLsp ?? true;
2148
- const ircEnabled = isIrcEnabled(subagentSettings, childDepth);
2149
2218
  const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("eval");
2150
2219
 
2151
2220
  const monitor = createSubagentRunMonitor({
@@ -2354,8 +2423,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2354
2423
  }
2355
2424
  sessionOpenedAt = performance.now();
2356
2425
 
2357
- const mcpProxyTools = options.mcpManager ? createMCPProxyTools(options.mcpManager) : [];
2358
- const enableMCP = !options.mcpManager;
2426
+ const restrictToolNames = options.restrictToolNames === true;
2427
+ const enableMCP = !restrictToolNames && (options.enableMCP ?? true);
2428
+ const mcpManager = enableMCP ? options.mcpManager : undefined;
2429
+ const mcpProxyTools = mcpManager ? createMCPProxyTools(mcpManager) : [];
2359
2430
 
2360
2431
  // Derive subagent-scoped telemetry from the parent's config so the
2361
2432
  // child loop's spans nest under the parent's active execute_tool span
@@ -2411,14 +2482,16 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2411
2482
  thinkingLevel: effectiveThinkingLevel,
2412
2483
  toolNames,
2413
2484
  outputSchema,
2485
+ outputSchemaMode: options.outputSchemaMode,
2486
+ restrictToolNames: options.restrictToolNames,
2414
2487
  requireYieldTool: true,
2415
2488
  contextFiles: options.contextFiles,
2416
2489
  skills: options.skills,
2417
2490
  promptTemplates: options.promptTemplates,
2418
2491
  workspaceTree: options.workspaceTree,
2419
2492
  rules: options.rules,
2420
- preloadedExtensionPaths: options.preloadedExtensionPaths,
2421
- preloadedCustomToolPaths: options.preloadedCustomToolPaths,
2493
+ preloadedExtensionPaths: restrictToolNames ? [] : options.preloadedExtensionPaths,
2494
+ preloadedCustomToolPaths: restrictToolNames ? [] : options.preloadedCustomToolPaths,
2422
2495
  systemPrompt: defaultPrompt => {
2423
2496
  const subagentPrompt = prompt.render(subagentSystemPromptTemplate, {
2424
2497
  agent: agent.systemPrompt,
@@ -2447,9 +2520,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2447
2520
  agentId: id,
2448
2521
  agentDisplayName: agent.name,
2449
2522
  enableLsp: lspEnabled,
2523
+ enableIrc: options.enableIrc,
2450
2524
  skipPythonPreflight,
2451
2525
  enableMCP,
2452
- mcpManager: options.mcpManager,
2526
+ mcpManager,
2453
2527
  customTools: mcpProxyTools.length > 0 ? mcpProxyTools : undefined,
2454
2528
  localProtocolOptions: options.localProtocolOptions,
2455
2529
  telemetry: subagentTelemetry,
@@ -2524,6 +2598,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2524
2598
  spawns: spawnsEnv,
2525
2599
  readSummarize: agent.readSummarize,
2526
2600
  outputSchema,
2601
+ outputSchemaMode: options.outputSchemaMode,
2602
+ restrictToolNames: restrictToolNames || undefined,
2527
2603
  });
2528
2604
 
2529
2605
  abortSignal.addEventListener(
@@ -2720,6 +2796,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2720
2796
  assignment,
2721
2797
  modelOverride,
2722
2798
  outputSchema,
2799
+ outputSchemaMode: options.outputSchemaMode,
2800
+ outputSchemaSource: options.outputSchemaSource,
2723
2801
  signal,
2724
2802
  artifactsDir: options.artifactsDir,
2725
2803
  eventBus: options.eventBus,