@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
package/src/task/types.ts CHANGED
@@ -7,6 +7,35 @@ import type { NestedRepoPatch } from "./worktree";
7
7
 
8
8
  /** Source of an agent definition */
9
9
  export type AgentSource = "bundled" | "user" | "project";
10
+ /**
11
+ * Enforcement policy for a structured subagent output schema.
12
+ *
13
+ * `permissive` preserves legacy retry-budget overrides; `strict` turns every
14
+ * invalid final payload, including an exhausted retry override, into a failed
15
+ * `schema_violation` result.
16
+ */
17
+ export type StructuredSubagentSchemaMode = "permissive" | "strict";
18
+
19
+ /** Origin of the schema selected for a structured subagent invocation. */
20
+ export type StructuredSubagentSchemaSource = "caller" | "agent" | "session" | "none";
21
+
22
+ /** Final validation state of a structured subagent invocation. */
23
+ export type StructuredSubagentValidationStatus = "valid" | "invalid" | "unavailable";
24
+
25
+ /**
26
+ * Parsed structured completion and its schema-validation metadata.
27
+ *
28
+ * `data` is present whenever a payload could be assembled or parsed, even when
29
+ * strict validation rejects it. `error` explains unavailable or invalid
30
+ * validation without requiring consumers to parse presentation text.
31
+ */
32
+ export interface StructuredSubagentOutput {
33
+ source: StructuredSubagentSchemaSource;
34
+ mode: StructuredSubagentSchemaMode;
35
+ status: StructuredSubagentValidationStatus;
36
+ data?: unknown;
37
+ error?: string;
38
+ }
10
39
 
11
40
  const parseNumber = (value: string | undefined, defaultValue: number): number => {
12
41
  if (value) {
@@ -81,12 +110,16 @@ export const taskItemSchema = type({
81
110
  "name?": "string",
82
111
  agent: "string = 'task'",
83
112
  task: "string",
113
+ "outputSchema?": "unknown",
114
+ "schemaMode?": '"permissive" | "strict"',
84
115
  "+": "delete",
85
116
  });
86
117
  const taskItemSchemaIsolated = type({
87
118
  "name?": "string",
88
119
  agent: "string = 'task'",
89
120
  task: "string",
121
+ "outputSchema?": "unknown",
122
+ "schemaMode?": '"permissive" | "strict"',
90
123
  "isolated?": "boolean",
91
124
  "+": "delete",
92
125
  });
@@ -99,6 +132,10 @@ export interface TaskItem {
99
132
  agent?: string;
100
133
  /** The work; required by the schema. */
101
134
  task?: string;
135
+ /** Caller-provided output schema; its presence overrides the selected agent's schema. */
136
+ outputSchema?: unknown;
137
+ /** Validation behavior for a caller-provided or inherited output schema. */
138
+ schemaMode?: "permissive" | "strict";
102
139
  /** Run this spawn in an isolated worktree (batch form; flat form carries it top-level). */
103
140
  isolated?: boolean;
104
141
  }
@@ -107,6 +144,8 @@ export const taskSchema = type({
107
144
  "name?": "string",
108
145
  agent: "string = 'task'",
109
146
  task: "string",
147
+ "outputSchema?": "unknown",
148
+ "schemaMode?": '"permissive" | "strict"',
110
149
  "isolated?": "boolean",
111
150
  "+": "delete",
112
151
  });
@@ -114,6 +153,8 @@ const taskSchemaNoIsolation = type({
114
153
  "name?": "string",
115
154
  agent: "string = 'task'",
116
155
  task: "string",
156
+ "outputSchema?": "unknown",
157
+ "schemaMode?": '"permissive" | "strict"',
117
158
  "+": "delete",
118
159
  });
119
160
  const taskSchemaBatch = type({
@@ -156,6 +197,8 @@ function createTaskSchema(options: {
156
197
  "name?": "string",
157
198
  agent,
158
199
  task: "string",
200
+ "outputSchema?": "unknown",
201
+ "schemaMode?": '"permissive" | "strict"',
159
202
  "isolated?": "boolean",
160
203
  "+": "delete",
161
204
  });
@@ -169,6 +212,8 @@ function createTaskSchema(options: {
169
212
  "name?": "string",
170
213
  agent,
171
214
  task: "string",
215
+ "outputSchema?": "unknown",
216
+ "schemaMode?": '"permissive" | "strict"',
172
217
  "+": "delete",
173
218
  });
174
219
  return type.raw({
@@ -182,6 +227,8 @@ function createTaskSchema(options: {
182
227
  "name?": "string",
183
228
  agent,
184
229
  task: "string",
230
+ "outputSchema?": "unknown",
231
+ "schemaMode?": '"permissive" | "strict"',
185
232
  "isolated?": "boolean",
186
233
  "+": "delete",
187
234
  });
@@ -190,6 +237,8 @@ function createTaskSchema(options: {
190
237
  "name?": "string",
191
238
  agent,
192
239
  task: "string",
240
+ "outputSchema?": "unknown",
241
+ "schemaMode?": '"permissive" | "strict"',
193
242
  "+": "delete",
194
243
  });
195
244
  }
@@ -231,6 +280,10 @@ export interface TaskParams {
231
280
  agent?: string;
232
281
  /** The work (flat form). */
233
282
  task?: string;
283
+ /** Caller-provided output schema; its presence overrides the selected agent's schema. */
284
+ outputSchema?: unknown;
285
+ /** Validation behavior for a caller-provided or inherited output schema. */
286
+ schemaMode?: "permissive" | "strict";
234
287
  /** Batch form (`task.batch`): one subagent per item. */
235
288
  tasks?: TaskItem[];
236
289
  /** Batch form: shared background prepended to every assignment; required by the batch schema. */
@@ -413,6 +466,11 @@ export interface SingleResult {
413
466
  output: string;
414
467
  stderr: string;
415
468
  truncated: boolean;
469
+ /**
470
+ * Parsed structured completion and validation metadata, when this invocation
471
+ * selected an output schema or strict schema mode.
472
+ */
473
+ structuredOutput?: StructuredSubagentOutput;
416
474
  durationMs: number;
417
475
  /** Cumulative input + output + cacheWrite tokens across all turns. Excludes cacheRead (re-reads cached context every turn, making cumulative sum misleading). */
418
476
  tokens: number;
@@ -6,7 +6,7 @@ describe("eval tool description", () => {
6
6
  const description = getEvalToolDescription({ py: true, js: false, spawns: "fact-finder,oracle" });
7
7
 
8
8
  expect(description).toContain('agent(prompt, agent?="fact-finder"');
9
- expect(description).toContain("Allowed: `fact-finder`, `oracle`.");
9
+ expect(description).toContain("Allowed agents: `fact-finder`, `oracle`.");
10
10
  });
11
11
 
12
12
  it("omits agent() when spawning is disabled", () => {
@@ -7,7 +7,6 @@ import type { FetchImpl, ImageContent, TextContent } from "@oh-my-pi/pi-ai";
7
7
  import { htmlToMarkdown } from "@oh-my-pi/pi-natives";
8
8
  import { type Component, Text } from "@oh-my-pi/pi-tui";
9
9
  import { $which, ptree, truncate } from "@oh-my-pi/pi-utils";
10
- import { LRUCache } from "lru-cache/raw";
11
10
  import type { Settings } from "../config/settings";
12
11
  import { readEditableNotebookText } from "../edit/notebook";
13
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -19,6 +18,7 @@ import { renderStatusLine, urlHyperlink } from "../tui";
19
18
  import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
20
19
  import { webpExclusionForModel } from "../utils/image-loading";
21
20
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
21
+ import { CONVERTIBLE_EXTENSIONS } from "../utils/markit";
22
22
  import { ensureTool } from "../utils/tools-manager";
23
23
  import { type ArchiveFormat, listArchiveRoot, sniffArchiveFormat } from "../utils/zip";
24
24
  import { extractWithParallel, findParallelApiKey, getParallelExtractContent } from "../web/parallel";
@@ -39,21 +39,17 @@ import { clampTimeout } from "./tool-timeouts";
39
39
  // =============================================================================
40
40
 
41
41
  const FETCH_DEFAULT_MAX_LINES = 300;
42
- // Convertible document types handled by markit.
42
+ // MIME types markit can convert — one per registered converter (pdf, docx,
43
+ // pptx, xlsx, epub). Legacy `application/msword`, `application/vnd.ms-*`, and
44
+ // `application/rtf` are intentionally absent: markit has no converter for them.
43
45
  const CONVERTIBLE_MIMES = new Set([
44
46
  "application/pdf",
45
- "application/msword",
46
- "application/vnd.ms-powerpoint",
47
- "application/vnd.ms-excel",
48
47
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
49
48
  "application/vnd.openxmlformats-officedocument.presentationml.presentation",
50
49
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
51
- "application/rtf",
52
50
  "application/epub+zip",
53
51
  ]);
54
52
 
55
- const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]);
56
-
57
53
  const NOTEBOOK_MIMES = new Set(["application/x-ipynb+json"]);
58
54
  const NOTEBOOK_EXTENSIONS = new Set([".ipynb"]);
59
55
 
@@ -1553,24 +1549,15 @@ export interface ReadUrlToolDetails {
1553
1549
  meta?: OutputMeta;
1554
1550
  }
1555
1551
 
1556
- interface ReadUrlCacheEntry {
1552
+ interface ReadUrlEntry {
1557
1553
  artifactId?: string;
1558
1554
  artifactPath?: string;
1559
- contentPath?: string;
1560
1555
  details: ReadUrlToolDetails;
1561
1556
  image?: FetchImagePayload;
1562
1557
  output: string;
1563
1558
  content: string;
1564
1559
  }
1565
1560
 
1566
- const READ_URL_CACHE_MAX_ENTRIES = 100;
1567
- const readUrlCache = new LRUCache<string, ReadUrlCacheEntry>({ max: READ_URL_CACHE_MAX_ENTRIES });
1568
-
1569
- function getReadUrlCacheKey(session: ToolSession, requestedUrl: string, raw: boolean): string {
1570
- const scope = session.getSessionFile() ?? session.cwd;
1571
- return `${scope}::${raw ? "raw" : "rendered"}::${normalizeUrl(requestedUrl)}`;
1572
- }
1573
-
1574
1561
  async function findArtifactPath(session: ToolSession, artifactId: string): Promise<string | null> {
1575
1562
  const artifactsDir = session.getArtifactsDir?.();
1576
1563
  if (!artifactsDir) return null;
@@ -1584,25 +1571,6 @@ async function findArtifactPath(session: ToolSession, artifactId: string): Promi
1584
1571
  }
1585
1572
  }
1586
1573
 
1587
- async function readArtifactOutput(session: ToolSession, artifactId: string): Promise<string | null> {
1588
- const artifactPath = await findArtifactPath(session, artifactId);
1589
- return artifactPath ? await Bun.file(artifactPath).text() : null;
1590
- }
1591
-
1592
- async function materializeReadUrlCacheEntry(
1593
- session: ToolSession,
1594
- entry: ReadUrlCacheEntry,
1595
- ): Promise<ReadUrlCacheEntry | null> {
1596
- if (entry.artifactId) {
1597
- const artifactOutput = await readArtifactOutput(session, entry.artifactId);
1598
- if (artifactOutput !== null) {
1599
- return { ...entry, output: artifactOutput };
1600
- }
1601
- }
1602
-
1603
- return entry.output.length > 0 ? entry : null;
1604
- }
1605
-
1606
1574
  async function persistReadUrlArtifact(
1607
1575
  session: ToolSession,
1608
1576
  output: string,
@@ -1613,7 +1581,7 @@ async function persistReadUrlArtifact(
1613
1581
  return artifact;
1614
1582
  }
1615
1583
 
1616
- async function ensureReadUrlCacheArtifact(session: ToolSession, entry: ReadUrlCacheEntry): Promise<ReadUrlCacheEntry> {
1584
+ async function ensureReadUrlArtifact(session: ToolSession, entry: ReadUrlEntry): Promise<ReadUrlEntry> {
1617
1585
  if (entry.artifactId && entry.artifactPath) return entry;
1618
1586
  if (entry.artifactId) {
1619
1587
  const artifactPath = await findArtifactPath(session, entry.artifactId);
@@ -1632,19 +1600,7 @@ function readUrlContentExtension(finalUrl: string): string {
1632
1600
  }
1633
1601
  }
1634
1602
 
1635
- async function ensureReadUrlContentFile(
1636
- session: ToolSession,
1637
- entry: ReadUrlCacheEntry,
1638
- raw: boolean,
1639
- ): Promise<ReadUrlCacheEntry> {
1640
- if (entry.contentPath) {
1641
- try {
1642
- await Bun.file(entry.contentPath).stat();
1643
- return entry;
1644
- } catch {
1645
- // Recreate below when the cached scratch file was removed.
1646
- }
1647
- }
1603
+ async function materializeReadUrlContent(session: ToolSession, entry: ReadUrlEntry, raw: boolean): Promise<string> {
1648
1604
  const root = session.getArtifactsDir?.();
1649
1605
  if (!root) {
1650
1606
  throw new ToolError("Cannot search URL output because this session cannot materialize read artifacts.");
@@ -1654,20 +1610,16 @@ async function ensureReadUrlContentFile(
1654
1610
  const hash = Bun.hash(`${raw ? "raw" : "rendered"}:${entry.details.finalUrl}`).toString(36);
1655
1611
  const contentPath = path.join(dir, `${hash}${readUrlContentExtension(entry.details.finalUrl)}`);
1656
1612
  await Bun.write(contentPath, entry.content);
1657
- return { ...entry, contentPath };
1658
- }
1659
-
1660
- function cacheReadUrlEntry(session: ToolSession, requestedUrl: string, raw: boolean, entry: ReadUrlCacheEntry): void {
1661
- readUrlCache.set(getReadUrlCacheKey(session, requestedUrl, raw), entry);
1662
- readUrlCache.set(getReadUrlCacheKey(session, entry.details.finalUrl, raw), entry);
1613
+ return contentPath;
1663
1614
  }
1664
1615
 
1665
- async function buildReadUrlCacheEntry(
1616
+ /** Fetch and render a URL for a read or search operation. */
1617
+ export async function fetchReadUrl(
1666
1618
  session: ToolSession,
1667
1619
  params: { path: string; raw?: boolean },
1668
1620
  signal?: AbortSignal,
1669
1621
  options?: { ensureArtifact?: boolean },
1670
- ): Promise<ReadUrlCacheEntry> {
1622
+ ): Promise<ReadUrlEntry> {
1671
1623
  const { path: url, raw = false } = params;
1672
1624
 
1673
1625
  const effectiveTimeout = clampTimeout("fetch", 30);
@@ -1708,30 +1660,6 @@ async function buildReadUrlCacheEntry(
1708
1660
  };
1709
1661
  }
1710
1662
 
1711
- export async function loadReadUrlCacheEntry(
1712
- session: ToolSession,
1713
- params: { path: string; raw?: boolean },
1714
- signal?: AbortSignal,
1715
- options?: { ensureArtifact?: boolean; preferCached?: boolean },
1716
- ): Promise<ReadUrlCacheEntry> {
1717
- const raw = params.raw ?? false;
1718
- const cached = readUrlCache.get(getReadUrlCacheKey(session, params.path, raw));
1719
- if (options?.preferCached && cached) {
1720
- const prepared = options.ensureArtifact ? await ensureReadUrlCacheArtifact(session, cached) : cached;
1721
- const materialized = await materializeReadUrlCacheEntry(session, prepared);
1722
- if (materialized) {
1723
- cacheReadUrlEntry(session, params.path, raw, materialized);
1724
- return materialized;
1725
- }
1726
- }
1727
-
1728
- const fresh = await buildReadUrlCacheEntry(session, params, signal, {
1729
- ensureArtifact: options?.ensureArtifact,
1730
- });
1731
- cacheReadUrlEntry(session, params.path, raw, fresh);
1732
- return fresh;
1733
- }
1734
-
1735
1663
  /** Materialize rendered URL body text to a local file for tools that require filesystem paths. */
1736
1664
  export async function materializeReadUrlToFile(
1737
1665
  session: ToolSession,
@@ -1741,13 +1669,9 @@ export async function materializeReadUrlToFile(
1741
1669
  if (!session.settings.get("fetch.enabled")) {
1742
1670
  throw new ToolError("URL reads are disabled by settings.");
1743
1671
  }
1744
- const cacheEntry = await loadReadUrlCacheEntry(session, params, signal, { preferCached: true });
1745
- const materialized = await ensureReadUrlContentFile(session, cacheEntry, params.raw ?? false);
1746
- cacheReadUrlEntry(session, params.path, params.raw ?? false, materialized);
1747
- if (!materialized.contentPath) {
1748
- throw new ToolError("Cannot search URL output because this session cannot materialize read artifacts.");
1749
- }
1750
- return { path: materialized.contentPath, details: materialized.details };
1672
+ const entry = await fetchReadUrl(session, params, signal);
1673
+ const contentPath = await materializeReadUrlContent(session, entry, params.raw ?? false);
1674
+ return { path: contentPath, details: entry.details };
1751
1675
  }
1752
1676
 
1753
1677
  function buildUrlReadOutput(result: FetchRenderResult, content: string): string {
@@ -1768,36 +1692,35 @@ export async function executeReadUrl(
1768
1692
  params: { path: string; raw?: boolean },
1769
1693
  signal?: AbortSignal,
1770
1694
  ): Promise<AgentToolResult<ReadUrlToolDetails>> {
1771
- let cacheEntry = await loadReadUrlCacheEntry(session, params, signal, { preferCached: true });
1772
- const truncation = truncateHead(cacheEntry.output, {
1695
+ let entry = await fetchReadUrl(session, params, signal);
1696
+ const truncation = truncateHead(entry.output, {
1773
1697
  maxBytes: DEFAULT_MAX_BYTES,
1774
1698
  maxLines: FETCH_DEFAULT_MAX_LINES,
1775
1699
  });
1776
1700
  const needsArtifact = truncation.truncated;
1777
- if (needsArtifact && !cacheEntry.artifactId) {
1778
- cacheEntry = await ensureReadUrlCacheArtifact(session, cacheEntry);
1779
- cacheReadUrlEntry(session, params.path, params.raw ?? false, cacheEntry);
1701
+ if (needsArtifact && !entry.artifactId) {
1702
+ entry = await ensureReadUrlArtifact(session, entry);
1780
1703
  }
1781
- const output = needsArtifact ? truncation.content : cacheEntry.output;
1704
+ const output = needsArtifact ? truncation.content : entry.output;
1782
1705
  const details: ReadUrlToolDetails = {
1783
- ...cacheEntry.details,
1784
- truncated: Boolean(cacheEntry.details.truncated || needsArtifact),
1706
+ ...entry.details,
1707
+ truncated: Boolean(entry.details.truncated || needsArtifact),
1785
1708
  };
1786
1709
 
1787
1710
  const contentBlocks: Array<TextContent | ImageContent> = [{ type: "text", text: output }];
1788
- if (cacheEntry.image) {
1789
- contentBlocks.push({ type: "image", data: cacheEntry.image.data, mimeType: cacheEntry.image.mimeType });
1711
+ if (entry.image) {
1712
+ contentBlocks.push({ type: "image", data: entry.image.data, mimeType: entry.image.mimeType });
1790
1713
  }
1791
1714
 
1792
1715
  const resultBuilder = toolResult(details).content(contentBlocks).sourceUrl(details.finalUrl);
1793
1716
  if (needsArtifact) {
1794
- resultBuilder.truncation(truncation, { direction: "head", artifactId: cacheEntry.artifactId });
1795
- } else if (cacheEntry.details.truncated) {
1796
- const outputLines = cacheEntry.output.split("\n").length;
1797
- const outputBytes = Buffer.byteLength(cacheEntry.output, "utf-8");
1717
+ resultBuilder.truncation(truncation, { direction: "head", artifactId: entry.artifactId });
1718
+ } else if (entry.details.truncated) {
1719
+ const outputLines = entry.output.split("\n").length;
1720
+ const outputBytes = Buffer.byteLength(entry.output, "utf-8");
1798
1721
  const totalBytes = Math.max(outputBytes + 1, MAX_OUTPUT_CHARS + 1);
1799
1722
  const totalLines = outputLines + 1;
1800
- resultBuilder.truncationFromText(cacheEntry.output, {
1723
+ resultBuilder.truncationFromText(entry.output, {
1801
1724
  direction: "tail",
1802
1725
  totalLines,
1803
1726
  totalBytes,
@@ -17,6 +17,7 @@ import type { RenderResultOptions } from "../../extensibility/custom-tools/types
17
17
  import { IrcBus, type IrcDeliveryReceipt, type IrcMessage } from "../../irc/bus";
18
18
  import type { Theme } from "../../modes/theme/theme";
19
19
  import { type AgentRegistry, MAIN_AGENT_ID } from "../../registry/agent-registry";
20
+ import { registerPersistedSubagents } from "../../registry/persisted-agents";
20
21
  import { canSpawnAtDepth } from "../../task/types";
21
22
  import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../../tui";
22
23
  import {
@@ -81,10 +82,22 @@ export function messageResult(senderId: string, waited: IrcMessage): AgentToolRe
81
82
  };
82
83
  }
83
84
 
84
- export function executeList(registry: AgentRegistry, senderId: string): AgentToolResult<CoordinationDetails> {
85
+ /**
86
+ * List every addressable peer, restoring parked refs from disk when a resumed
87
+ * session has no in-memory roster.
88
+ */
89
+ export async function executeList(
90
+ registry: AgentRegistry,
91
+ senderId: string,
92
+ ): Promise<AgentToolResult<CoordinationDetails>> {
93
+ let refs = registry.list();
94
+ if (!refs.some(ref => ref.id !== senderId && ref.status !== "aborted" && ref.kind !== "advisor")) {
95
+ await registerPersistedSubagents(registry, registry.get(senderId)?.sessionFile);
96
+ refs = registry.list();
97
+ }
98
+
85
99
  const bus = IrcBus.global();
86
- const peers = registry
87
- .list()
100
+ const peers = refs
88
101
  .filter(ref => ref.id !== senderId && ref.status !== "aborted" && ref.kind !== "advisor")
89
102
  .map(ref => ({
90
103
  id: ref.id,
@@ -28,7 +28,7 @@ import type { UsageStatistics } from "../session/session-entries";
28
28
  import type { ToolChoiceQueue } from "../session/tool-choice-queue";
29
29
  import { TaskTool } from "../task";
30
30
  import type { AgentOutputManager } from "../task/output-manager";
31
- import { canSpawnAtDepth } from "../task/types";
31
+ import { canSpawnAtDepth, type StructuredSubagentSchemaMode } from "../task/types";
32
32
  import type { EventBus } from "../utils/event-bus";
33
33
  import { WebSearchTool } from "../web/search";
34
34
  import type { WorkspaceTree } from "../workspace-tree";
@@ -45,7 +45,7 @@ import { resolveEvalBackends } from "./eval-backends";
45
45
  import { GithubTool } from "./gh";
46
46
  import { GlobTool } from "./glob";
47
47
  import { GrepTool } from "./grep";
48
- import { HubTool } from "./hub";
48
+ import { HubTool, isIrcEnabled } from "./hub";
49
49
  import { InspectImageTool } from "./inspect-image";
50
50
  import { LearnTool } from "./learn";
51
51
  import { ManageSkillTool } from "./manage-skill";
@@ -183,17 +183,31 @@ export interface ToolSession {
183
183
  customToolPaths?: ToolPathWithSource[];
184
184
  /** Whether LSP integrations are enabled */
185
185
  enableLsp?: boolean;
186
+ /** Whether this invocation may expose IRC. `false` removes it even for subagents. */
187
+ enableIrc?: boolean;
188
+ /**
189
+ * Whether MCP capabilities may be forwarded to child sessions. `false`
190
+ * prohibits inherited-manager and process-global MCP fallback.
191
+ */
192
+ enableMCP?: boolean;
186
193
  /** Whether an edit-capable tool is available in this session (controls hashline output) */
187
194
  hasEditTool?: boolean;
188
195
  /** Event bus for tool/extension communication */
189
196
  eventBus?: EventBus;
190
- /** Output schema for structured completion (subagents) */
197
+ /** Output schema for structured completion (subagents). */
191
198
  outputSchema?: unknown;
199
+ /** Enforcement policy for {@link outputSchema}; defaults to legacy permissive behavior. */
200
+ outputSchemaMode?: StructuredSubagentSchemaMode;
192
201
  /** Whether to include the yield tool by default */
193
202
  requireYieldTool?: boolean;
194
203
  /** Session starts with a prewalk hand-off armed. Keeps `todo` in yield-gated
195
204
  * (subagent) registries: the prewalk plan nudge + todo gate need it. */
196
205
  prewalkArmed?: boolean;
206
+ /**
207
+ * Constrain the active set to the caller's explicit built-in names (plus a
208
+ * required yield tool). Suppresses automatic tool-set expansion.
209
+ */
210
+ restrictToolNames?: boolean;
197
211
  /** Task recursion depth (0 = top-level, 1 = first child, etc.) */
198
212
  taskDepth?: number;
199
213
  /** Get shared eval executor session ID. Subagents inherit this to share JS/Python/Ruby/Julia state. */
@@ -403,13 +417,18 @@ export type ToolName = BuiltinToolName;
403
417
  * Create tools from BUILTIN_TOOLS registry.
404
418
  */
405
419
  export async function createTools(session: ToolSession, toolNames?: string[]): Promise<Tool[]> {
420
+ const restrictToolNames = session.restrictToolNames === true;
406
421
  const includeYield = session.requireYieldTool === true;
407
422
  const enableLsp = session.enableLsp ?? true;
408
- let requestedTools = toolNames && toolNames.length > 0 ? normalizeToolNames(toolNames) : undefined;
423
+ const requestedTools = restrictToolNames
424
+ ? normalizeToolNames(toolNames ?? [])
425
+ : toolNames && toolNames.length > 0
426
+ ? normalizeToolNames(toolNames)
427
+ : undefined;
409
428
  const goalEnabled = session.settings.get("goal.enabled");
410
- const goalModeActive = goalEnabled && session.getGoalModeState?.()?.enabled === true;
429
+ const goalModeActive = !restrictToolNames && goalEnabled && session.getGoalModeState?.()?.enabled === true;
411
430
  if (goalModeActive && requestedTools && !requestedTools.includes("goal")) {
412
- requestedTools = [...requestedTools, "goal"];
431
+ requestedTools.push("goal");
413
432
  }
414
433
  const backends = resolveEvalBackends(session);
415
434
  const allowPython = backends.python;
@@ -466,8 +485,12 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
466
485
  // unreachable, in which case eval dispatches exclusively to the others.
467
486
  const allowEval = effectivePythonAllowed || allowJs || effectiveRubyAllowed || effectiveJuliaAllowed;
468
487
 
469
- // Auto-include AST counterparts when their text-based sibling is present
470
- if (requestedTools) {
488
+ // Auto-include AST counterparts when their text-based sibling is present.
489
+ // Restricted callers own the active list and must not have it widened.
490
+ if (requestedTools && !restrictToolNames) {
491
+ if (goalModeActive && !requestedTools.includes("goal")) {
492
+ requestedTools.push("goal");
493
+ }
471
494
  if (
472
495
  requestedTools.includes("grep") &&
473
496
  !requestedTools.includes("ast_grep") &&
@@ -522,6 +545,11 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
522
545
  if (name === "ask") return session.settings.get("ask.enabled");
523
546
  if (name === "browser") return session.settings.get("browser.enabled");
524
547
  if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
548
+ if (name === "hub") {
549
+ return (
550
+ !restrictToolNames && session.enableIrc !== false && isIrcEnabled(session.settings, session.taskDepth ?? 0)
551
+ );
552
+ }
525
553
  if (name === "retain" || name === "recall" || name === "reflect") {
526
554
  return ["hindsight", "mnemopi"].includes(session.settings.get("memory.backend") ?? "");
527
555
  }
@@ -569,10 +597,11 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
569
597
  );
570
598
  let tools = baseResults.filter((r): r is Tool => r !== null);
571
599
 
572
- // Always create the xd:// registry when enabled so SDK assembly can mount
573
- // discoverable custom/MCP tools later. Explicitly requested built-ins keep
574
- // their top-level presentation; default tool sets mount discoverable built-ins.
575
- const xdevEnabled = session.settings.get("tools.xdev");
600
+ // Ordinary sessions use xd:// for discoverable built-ins, custom tools, and
601
+ // MCP tools. Structured children must expose only their host-provided names,
602
+ // so never allocate a registry that later SDK assembly could populate.
603
+ // Explicitly requested built-ins retain their top-level presentation.
604
+ const xdevEnabled = !restrictToolNames && session.settings.get("tools.xdev");
576
605
  const mountBuiltinTools = requestedTools === undefined;
577
606
  if (xdevEnabled) {
578
607
  const mounted: Tool[] = [];
@@ -595,13 +624,17 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
595
624
  // (e.g. ast_edit) also resolve through a `write` to xd://resolve/reject. Retain
596
625
  // both whenever any device is mounted or a deferrable tool can stage one.
597
626
  const xdevMounted = (session.xdevRegistry?.size ?? 0) > 0;
598
- if ((tools.some(tool => tool.deferrable === true) || xdevMounted) && !tools.some(tool => tool.name === "write")) {
627
+ if (
628
+ !restrictToolNames &&
629
+ (tools.some(tool => tool.deferrable === true) || xdevMounted) &&
630
+ !tools.some(tool => tool.name === "write")
631
+ ) {
599
632
  const writeTool = await logger.time("createTools:write", BUILTIN_TOOLS.write, session);
600
633
  if (writeTool) {
601
634
  tools.push(wrapToolWithMetaNotice(writeTool));
602
635
  }
603
636
  }
604
- if (xdevMounted && !tools.some(tool => tool.name === "read")) {
637
+ if (!restrictToolNames && xdevMounted && !tools.some(tool => tool.name === "read")) {
605
638
  const readTool = await logger.time("createTools:read", BUILTIN_TOOLS.read, session);
606
639
  if (readTool) {
607
640
  tools.push(wrapToolWithMetaNotice(readTool));
@@ -35,6 +35,7 @@ const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
35
35
  agent: true,
36
36
  artifact: true,
37
37
  issue: true,
38
+ history: true,
38
39
  local: true,
39
40
  memory: true,
40
41
  omp: true,
package/src/tools/read.ts CHANGED
@@ -62,7 +62,7 @@ import {
62
62
  MAX_IMAGE_INPUT_BYTES,
63
63
  webpExclusionForModel,
64
64
  } from "../utils/image-loading";
65
- import { convertFileWithMarkit } from "../utils/markit";
65
+ import { CONVERTIBLE_EXTENSIONS, convertFileWithMarkit } from "../utils/markit";
66
66
  import { type ArchiveReader, formatArchiveEntryLines, openArchive, parseArchivePathCandidates } from "../utils/zip";
67
67
  import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree";
68
68
  import {
@@ -78,7 +78,7 @@ import {
78
78
  } from "./conflict-detect";
79
79
  import {
80
80
  executeReadUrl,
81
- loadReadUrlCacheEntry,
81
+ fetchReadUrl,
82
82
  parseReadUrlTarget,
83
83
  type ReadUrlToolDetails,
84
84
  renderReadUrlCall,
@@ -151,9 +151,6 @@ function getSummaryParseCache(session: object): LRUCache<string, SummaryResult |
151
151
  return cache;
152
152
  }
153
153
 
154
- // Document types converted to markdown via markit.
155
- const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]);
156
-
157
154
  const MAX_SUMMARY_BYTES = 2 * 1024 * 1024;
158
155
  const MAX_SUMMARY_LINES = 20_000;
159
156
  const MAX_ARTIFACT_RAW_INLINE_BYTES = DEFAULT_MAX_BYTES;
@@ -2167,15 +2164,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2167
2164
  const urlRaw = parsedUrlTarget.raw;
2168
2165
  const urlRanges = parsedUrlTarget.ranges;
2169
2166
  if (urlRanges !== undefined && urlRanges.length > 1) {
2170
- const cached = await loadReadUrlCacheEntry(
2171
- this.session,
2172
- { path: parsedUrlTarget.path, raw: urlRaw },
2173
- signal,
2174
- { ensureArtifact: true, preferCached: true },
2175
- );
2176
- return this.#buildInMemoryMultiRangeResult(cached.output, urlRanges, {
2177
- details: { ...cached.details },
2178
- sourceUrl: cached.details.finalUrl,
2167
+ const entry = await fetchReadUrl(this.session, { path: parsedUrlTarget.path, raw: urlRaw }, signal, {
2168
+ ensureArtifact: true,
2169
+ });
2170
+ return this.#buildInMemoryMultiRangeResult(entry.output, urlRanges, {
2171
+ details: { ...entry.details },
2172
+ sourceUrl: entry.details.finalUrl,
2179
2173
  entityLabel: "URL output",
2180
2174
  raw: urlRaw,
2181
2175
  immutable: true,
@@ -2184,18 +2178,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2184
2178
  const urlOffset = parsedUrlTarget.offset;
2185
2179
  const urlLimit = parsedUrlTarget.limit;
2186
2180
  if (urlOffset !== undefined || urlLimit !== undefined) {
2187
- const cached = await loadReadUrlCacheEntry(
2188
- this.session,
2189
- { path: parsedUrlTarget.path, raw: urlRaw },
2190
- signal,
2191
- {
2192
- ensureArtifact: true,
2193
- preferCached: true,
2194
- },
2195
- );
2196
- return this.#buildInMemoryTextResult(cached.output, urlOffset, urlLimit, {
2197
- details: { ...cached.details },
2198
- sourceUrl: cached.details.finalUrl,
2181
+ const entry = await fetchReadUrl(this.session, { path: parsedUrlTarget.path, raw: urlRaw }, signal, {
2182
+ ensureArtifact: true,
2183
+ });
2184
+ return this.#buildInMemoryTextResult(entry.output, urlOffset, urlLimit, {
2185
+ details: { ...entry.details },
2186
+ sourceUrl: entry.details.finalUrl,
2199
2187
  entityLabel: "URL output",
2200
2188
  raw: urlRaw,
2201
2189
  immutable: true,