@oh-my-pi/pi-coding-agent 17.0.2 → 17.0.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +70 -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/modes/components/agent-hub.d.ts +15 -0
  10. package/dist/types/registry/persisted-agents.d.ts +3 -0
  11. package/dist/types/sdk.d.ts +14 -3
  12. package/dist/types/session/session-entries.d.ts +6 -1
  13. package/dist/types/session/session-manager.d.ts +5 -0
  14. package/dist/types/task/executor.d.ts +26 -1
  15. package/dist/types/task/index.d.ts +1 -1
  16. package/dist/types/task/parallel.d.ts +14 -0
  17. package/dist/types/task/structured-subagent.d.ts +111 -0
  18. package/dist/types/task/types.d.ts +51 -0
  19. package/dist/types/tools/fetch.d.ts +4 -5
  20. package/dist/types/tools/hub/messaging.d.ts +5 -1
  21. package/dist/types/tools/index.d.ts +16 -1
  22. package/dist/types/tools/todo.d.ts +31 -0
  23. package/dist/types/tui/tree-list.d.ts +7 -0
  24. package/dist/types/utils/markit.d.ts +11 -0
  25. package/package.json +12 -12
  26. package/src/cli/file-processor.ts +1 -2
  27. package/src/config/settings-schema.ts +4 -3
  28. package/src/eval/__tests__/agent-bridge.test.ts +133 -47
  29. package/src/eval/__tests__/prelude-agent.test.ts +29 -0
  30. package/src/eval/agent-bridge.ts +104 -477
  31. package/src/eval/jl/prelude.jl +7 -6
  32. package/src/eval/js/shared/prelude.txt +5 -4
  33. package/src/eval/py/prelude.py +11 -39
  34. package/src/eval/rb/prelude.rb +5 -6
  35. package/src/extensibility/legacy-pi-coding-agent-shim.ts +53 -7
  36. package/src/lsp/client.ts +57 -0
  37. package/src/lsp/index.ts +62 -6
  38. package/src/lsp/types.ts +3 -0
  39. package/src/mcp/oauth-flow.ts +20 -0
  40. package/src/mcp/transports/stdio.test.ts +269 -1
  41. package/src/mcp/transports/stdio.ts +152 -1
  42. package/src/modes/components/agent-hub.ts +1 -72
  43. package/src/modes/components/bash-execution.ts +7 -3
  44. package/src/modes/components/eval-execution.ts +3 -1
  45. package/src/modes/interactive-mode.ts +30 -8
  46. package/src/prompts/system/system-prompt.md +1 -1
  47. package/src/prompts/tools/eval.md +5 -2
  48. package/src/prompts/tools/read.md +1 -1
  49. package/src/prompts/tools/task.md +12 -8
  50. package/src/prompts/tools/write.md +1 -1
  51. package/src/registry/persisted-agents.ts +74 -0
  52. package/src/sdk.ts +129 -87
  53. package/src/session/agent-session.ts +75 -21
  54. package/src/session/session-entries.ts +6 -1
  55. package/src/session/session-manager.ts +9 -0
  56. package/src/session/streaming-output.ts +41 -1
  57. package/src/system-prompt.ts +7 -2
  58. package/src/task/executor.ts +99 -21
  59. package/src/task/index.ts +258 -429
  60. package/src/task/parallel.ts +43 -0
  61. package/src/task/persisted-revive.ts +19 -7
  62. package/src/task/structured-subagent.ts +642 -0
  63. package/src/task/types.ts +58 -0
  64. package/src/tools/__tests__/eval-description.test.ts +1 -1
  65. package/src/tools/fetch.ts +28 -105
  66. package/src/tools/hub/messaging.ts +16 -3
  67. package/src/tools/index.ts +47 -14
  68. package/src/tools/path-utils.ts +1 -0
  69. package/src/tools/read.ts +14 -26
  70. package/src/tools/todo.ts +126 -13
  71. package/src/tui/tree-list.ts +39 -0
  72. package/src/utils/markit.ts +12 -0
  73. package/src/utils/zip.ts +16 -1
@@ -2095,6 +2095,9 @@ export class AgentSession {
2095
2095
  #xdevRegistry: XdevRegistry | undefined;
2096
2096
  /** Names of discoverable tools currently mounted under `xd://` (dynamic mounts only, not built-in devices). */
2097
2097
  #mountedXdevToolNames = new Set<string>();
2098
+ /** Coalesced xd:// mount delta not yet announced to the model; delivered as a
2099
+ * hidden notice alongside the next prompt (see {@link #notifyXdevMountDelta}). */
2100
+ #pendingXdevMountDelta: { added: Set<string>; removed: Set<string> } | undefined;
2098
2101
 
2099
2102
  // TTSR manager for time-traveling stream rules
2100
2103
  #ttsrManager: TtsrManager | undefined = undefined;
@@ -2242,6 +2245,17 @@ export class AgentSession {
2242
2245
  * queue was consumed normally or a new turn already started. */
2243
2246
  #drainStrandedQueuedMessages(): void {
2244
2247
  if (this.#abortInProgress) return;
2248
+ // Session transitions (newSession/`/new`, compact, model-switch, session-switch,
2249
+ // dispose) call #disconnectFromAgent() BEFORE `await abort()`, so abort's own
2250
+ // finally lands here with no listener attached. Auto-resuming now would snapshot
2251
+ // the still-old context (the transition hasn't reached agent.reset() yet), start a
2252
+ // stale provider turn that races the reset, and — once reconnected — append its
2253
+ // output to the fresh session (issue #5800). A disconnected session never owns the
2254
+ // queue: the transition does. newSession/switchSession drop the queue (reset /
2255
+ // clearAllQueues), so nothing survives; compaction preserves it and re-drains itself
2256
+ // after #reconnectToAgent (see compact()'s finally); an explicit prompt flushes it
2257
+ // in every case.
2258
+ if (this.#unsubscribeAgent === undefined) return;
2245
2259
  // A concern steered into a resumed streaming run after a user interrupt can
2246
2260
  // strand at the turn tail (steered past the loop's final boundary poll). While
2247
2261
  // that interrupt's suppression is still in effect, reclaim such advisor steers
@@ -7307,35 +7321,62 @@ export class AgentSession {
7307
7321
  }
7308
7322
 
7309
7323
  /**
7310
- * Announce a mid-session `xd://` mount delta to the model as a steered
7311
- * system notice instead of rewriting the system prompt: the prompt (and
7312
- * its provider cache prefix) stays byte-stable across MCP connects and
7313
- * disconnects, and the model learns about new devices from the notice
7314
- * (docs + schema stay one `read xd://<tool>` away). The full docs join
7315
- * the system prompt opportunistically on the next unrelated rebuild.
7324
+ * Record a mid-session `xd://` mount delta for the model without rewriting
7325
+ * the system prompt: the prompt (and its provider cache prefix) stays
7326
+ * byte-stable across MCP connects and disconnects. The delta is NOT steered
7327
+ * immediately a steered notice landing at a run's stop boundary (or while
7328
+ * the session is idle) forces an unsolicited extra assistant turn it is
7329
+ * coalesced into {@link #pendingXdevMountDelta} and rides along with the
7330
+ * next prompt (docs + schema stay one `read xd://<tool>` away). The full
7331
+ * docs join the system prompt opportunistically on the next unrelated
7332
+ * rebuild.
7316
7333
  */
7317
7334
  #notifyXdevMountDelta(previousMounted: ReadonlySet<string>): void {
7318
7335
  const registry = this.#xdevRegistry;
7319
7336
  if (!registry) return;
7320
7337
  const current = this.#mountedXdevToolNames;
7321
7338
  const addedNames = [...current].filter(name => !previousMounted.has(name));
7322
- const removed = [...previousMounted].filter(name => !current.has(name)).map(name => ({ name }));
7323
- if (addedNames.length === 0 && removed.length === 0) return;
7324
- const summaries = new Map(registry.entries().map(entry => [entry.name, entry.summary]));
7325
- const added = addedNames.map(name => ({ name, summary: summaries.get(name) ?? "" }));
7326
- this.agent.steer({
7339
+ const removedNames = [...previousMounted].filter(name => !current.has(name));
7340
+ if (addedNames.length === 0 && removedNames.length === 0) return;
7341
+ // Coalesce against the unannounced delta: an unmount cancels a pending
7342
+ // mount the model never learned about, and a remount cancels a pending
7343
+ // unmount.
7344
+ const pending = this.#pendingXdevMountDelta ?? { added: new Set<string>(), removed: new Set<string>() };
7345
+ for (const name of addedNames) {
7346
+ if (!pending.removed.delete(name)) pending.added.add(name);
7347
+ }
7348
+ for (const name of removedNames) {
7349
+ if (!pending.added.delete(name)) pending.removed.add(name);
7350
+ }
7351
+ this.#pendingXdevMountDelta = pending.added.size > 0 || pending.removed.size > 0 ? pending : undefined;
7352
+ if (this.settings.get("startup.quiet")) return;
7353
+ const parts: string[] = [];
7354
+ if (addedNames.length > 0) parts.push(`mounted ${addedNames.join(", ")}`);
7355
+ if (removedNames.length > 0) parts.push(`unmounted ${removedNames.join(", ")}`);
7356
+ this.emitNotice("info", `xd://: ${parts.join("; ")}`, "xdev");
7357
+ }
7358
+
7359
+ /**
7360
+ * Render and consume the pending xd:// mount delta as a hidden notice, or
7361
+ * `undefined` when nothing unannounced is queued. Called from the prompt
7362
+ * paths so the notice rides along with user input instead of forcing its
7363
+ * own model turn.
7364
+ */
7365
+ #takePendingXdevMountNotice(): CustomMessage | undefined {
7366
+ const pending = this.#pendingXdevMountDelta;
7367
+ if (!pending) return undefined;
7368
+ this.#pendingXdevMountDelta = undefined;
7369
+ const summaries = new Map(this.#xdevRegistry?.entries().map(entry => [entry.name, entry.summary]) ?? []);
7370
+ const added = [...pending.added].map(name => ({ name, summary: summaries.get(name) ?? "" }));
7371
+ const removed = [...pending.removed].map(name => ({ name }));
7372
+ return {
7327
7373
  role: "custom",
7328
7374
  customType: XDEV_MOUNT_NOTICE_MESSAGE_TYPE,
7329
7375
  content: prompt.render(xdevMountNoticePrompt, { added, removed }),
7330
7376
  attribution: "agent",
7331
7377
  display: false,
7332
7378
  timestamp: Date.now(),
7333
- });
7334
- if (this.settings.get("startup.quiet")) return;
7335
- const parts: string[] = [];
7336
- if (added.length > 0) parts.push(`mounted ${added.map(entry => entry.name).join(", ")}`);
7337
- if (removed.length > 0) parts.push(`unmounted ${removed.map(entry => entry.name).join(", ")}`);
7338
- this.emitNotice("info", `xd://: ${parts.join("; ")}`, "xdev");
7379
+ };
7339
7380
  }
7340
7381
 
7341
7382
  /**
@@ -8693,14 +8734,19 @@ export class AgentSession {
8693
8734
  messages.push(...options.prependMessages);
8694
8735
  }
8695
8736
 
8696
- messages.push(message);
8697
-
8698
8737
  // Early bail-out: if a newer abort/prompt cycle started during setup,
8699
8738
  // return before mutating shared state (nextTurn messages, system prompt).
8700
8739
  if (this.#promptGeneration !== generation) {
8701
8740
  return;
8702
8741
  }
8703
8742
 
8743
+ // A pending xd:// delta accompanies the next user-authored prompt,
8744
+ // never an agent-initiated continuation.
8745
+ const xdevMountNotice = isUserQueuedMessage(message) ? this.#takePendingXdevMountNotice() : undefined;
8746
+ if (xdevMountNotice) {
8747
+ messages.push(xdevMountNotice);
8748
+ }
8749
+ messages.push(message);
8704
8750
  // Inject any pending "nextTurn" messages as context alongside the user message
8705
8751
  for (const msg of this.#pendingNextTurnMessages) {
8706
8752
  messages.push(msg);
@@ -11014,6 +11060,13 @@ export class AgentSession {
11014
11060
  this.#compactionAbortController = undefined;
11015
11061
  }
11016
11062
  this.#reconnectToAgent();
11063
+ // Compaction disconnected before `await abort()`, so abort's finally drain
11064
+ // (and any steer/follow-up that arrived mid-compaction — async IRC, an
11065
+ // `xd://` mount notice, an SDK/RPC steer) was suppressed while disconnected
11066
+ // (issue #5800). Unlike `/new`/switchSession, compaction preserves the agent
11067
+ // queues, so nothing else resumes them: re-drain now that the listener is back
11068
+ // and `isCompacting` is false, or the queued turn hangs until the next prompt.
11069
+ this.#drainStrandedQueuedMessages();
11017
11070
  }
11018
11071
  }
11019
11072
 
@@ -11912,11 +11965,12 @@ export class AgentSession {
11912
11965
  #isEmptyAssistantStop(assistantMessage: AssistantMessage): boolean {
11913
11966
  switch (assistantMessage.stopReason) {
11914
11967
  case "stop":
11915
- // Reasoning/thinking-only turns are not actionable: they do not
11916
- // answer the user and do not give the agent loop a tool call to run.
11968
+ // Unsigned thinking alone is not actionable, but a signature is
11969
+ // provider-authenticated content and makes the stop terminal.
11917
11970
  for (const content of assistantMessage.content) {
11918
11971
  if (content.type === "toolCall") return false;
11919
11972
  if (content.type === "text" && hasNonWhitespace(content.text)) return false;
11973
+ if (content.type === "thinking" && hasNonWhitespace(content.thinkingSignature ?? "")) return false;
11920
11974
  }
11921
11975
  return true;
11922
11976
  case "toolUse":
@@ -1,5 +1,6 @@
1
1
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import type { ImageContent, MessageAttribution, ServiceTierByFamily, TextContent } from "@oh-my-pi/pi-ai";
3
+ import type { StructuredSubagentSchemaMode } from "../task/types";
3
4
 
4
5
  export const CURRENT_SESSION_VERSION = 3;
5
6
 
@@ -164,8 +165,12 @@ export interface SessionInitEntry extends SessionEntryBase {
164
165
  task: string;
165
166
  /** Tools available to the agent */
166
167
  tools: string[];
167
- /** Output schema if structured output was requested */
168
+ /** Output schema if structured output was requested. */
168
169
  outputSchema?: unknown;
170
+ /** Enforcement policy recorded with the output schema for faithful revival. */
171
+ outputSchemaMode?: StructuredSubagentSchemaMode;
172
+ /** Whether revival must retain only the explicitly persisted tool names. */
173
+ restrictToolNames?: boolean;
169
174
  /** Spawn allowlist the subagent ran with ("" = none, "*" = any, else CSV); absent on pre-spawns files. */
170
175
  spawns?: string;
171
176
  /** The agent's `readSummarize` setting (`false` = read summarization disabled); absent uses the session default. */
@@ -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
@@ -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,