@mono-agent/agent-runtime 0.3.0 → 0.4.1

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 (123) hide show
  1. package/ARCHITECTURE.md +75 -9
  2. package/MIGRATION.md +293 -0
  3. package/README.md +46 -20
  4. package/package.json +104 -10
  5. package/src/agent/allowlists.js +3 -0
  6. package/src/agent/approval.js +3 -0
  7. package/src/agent/compaction.js +231 -654
  8. package/src/agent/index.js +1 -1
  9. package/src/agent/prompt/skill-index.js +6 -0
  10. package/src/agent/sandbox-seam.js +227 -0
  11. package/src/agent/tool-bloat.js +8 -2
  12. package/src/agent/tools/bash.js +16 -8
  13. package/src/agent/tools/edit.js +7 -3
  14. package/src/agent/tools/glob.js +11 -5
  15. package/src/agent/tools/grep.js +11 -5
  16. package/src/agent/tools/pi-bridge.js +272 -54
  17. package/src/agent/tools/read.js +28 -3
  18. package/src/agent/tools/shared/output-truncation.js +8 -3
  19. package/src/agent/tools/shared/path-resolver.js +24 -18
  20. package/src/agent/tools/shared/ripgrep.js +33 -6
  21. package/src/agent/tools/shared/runtime-context.js +60 -50
  22. package/src/agent/tools/shared/tool-context.js +157 -0
  23. package/src/agent/tools/web-fetch.js +65 -18
  24. package/src/agent/tools/web-search.js +11 -4
  25. package/src/agent/tools/write.js +7 -3
  26. package/src/agent/transcript.js +16 -1
  27. package/src/ai/cost.js +128 -3
  28. package/src/ai/failure.js +96 -4
  29. package/src/ai/index.js +1 -0
  30. package/src/ai/live-input-prompt.js +11 -1
  31. package/src/ai/providers/claude-cli.js +6 -2
  32. package/src/ai/providers/claude-sdk.js +55 -12
  33. package/src/ai/providers/codex-app.js +36 -23
  34. package/src/ai/providers/opencode-app.js +2 -2
  35. package/src/ai/providers/opencode-discovery.js +2 -2
  36. package/src/ai/providers/pi-errors.js +65 -0
  37. package/src/ai/providers/pi-events.js +5 -0
  38. package/src/ai/providers/pi-models.js +21 -4
  39. package/src/ai/providers/pi-native/compaction-driver.js +394 -0
  40. package/src/ai/providers/pi-native/result-builder.js +312 -0
  41. package/src/ai/providers/pi-native/session-lifecycle.js +438 -0
  42. package/src/ai/providers/pi-native/stream-subscriber.js +148 -0
  43. package/src/ai/providers/pi-native/structured-output.js +130 -0
  44. package/src/ai/providers/pi-native/turn-runner.js +278 -0
  45. package/src/ai/providers/pi-native.js +754 -0
  46. package/src/ai/runtime/capabilities.js +3 -0
  47. package/src/ai/runtime/model-refs.js +30 -0
  48. package/src/ai/runtime/registry.js +33 -2
  49. package/src/ai/runtime/router.js +119 -19
  50. package/src/ai/runtime/session-liveness.js +110 -0
  51. package/src/ai/runtime/sessions.js +19 -2
  52. package/src/ai/types.js +252 -20
  53. package/src/index.js +1 -1
  54. package/src/pi-auth.js +147 -16
  55. package/src/runtime-brand.js +21 -1
  56. package/src/runtime.js +75 -10
  57. package/types/agent/allowlists.d.ts +25 -0
  58. package/types/agent/approval.d.ts +30 -0
  59. package/types/agent/compaction.d.ts +97 -0
  60. package/types/agent/index.d.ts +5 -0
  61. package/types/agent/prompt/skill-index.d.ts +19 -0
  62. package/types/agent/sandbox-seam.d.ts +148 -0
  63. package/types/agent/tool-bloat.d.ts +22 -0
  64. package/types/agent/tools/bash.d.ts +16 -0
  65. package/types/agent/tools/edit.d.ts +14 -0
  66. package/types/agent/tools/glob.d.ts +16 -0
  67. package/types/agent/tools/grep.d.ts +22 -0
  68. package/types/agent/tools/index.d.ts +10 -0
  69. package/types/agent/tools/pi-bridge.d.ts +144 -0
  70. package/types/agent/tools/read.d.ts +19 -0
  71. package/types/agent/tools/shared/constants.d.ts +13 -0
  72. package/types/agent/tools/shared/dedup.d.ts +8 -0
  73. package/types/agent/tools/shared/output-truncation.d.ts +22 -0
  74. package/types/agent/tools/shared/path-resolver.d.ts +6 -0
  75. package/types/agent/tools/shared/ripgrep.d.ts +38 -0
  76. package/types/agent/tools/shared/runtime-context.d.ts +27 -0
  77. package/types/agent/tools/shared/tool-context.d.ts +48 -0
  78. package/types/agent/tools/web-fetch.d.ts +13 -0
  79. package/types/agent/tools/web-search.d.ts +11 -0
  80. package/types/agent/tools/write.d.ts +12 -0
  81. package/types/agent/transcript.d.ts +45 -0
  82. package/types/ai/backend.d.ts +41 -0
  83. package/types/ai/cost.d.ts +96 -0
  84. package/types/ai/failure.d.ts +117 -0
  85. package/types/ai/file-change-stats.d.ts +75 -0
  86. package/types/ai/index.d.ts +7 -0
  87. package/types/ai/live-input-prompt.d.ts +6 -0
  88. package/types/ai/observer.d.ts +68 -0
  89. package/types/ai/providers/claude-cli.d.ts +211 -0
  90. package/types/ai/providers/claude-sdk.d.ts +66 -0
  91. package/types/ai/providers/claude-subagents.d.ts +2 -0
  92. package/types/ai/providers/codex-app.d.ts +151 -0
  93. package/types/ai/providers/opencode-app.d.ts +95 -0
  94. package/types/ai/providers/opencode-discovery.d.ts +4 -0
  95. package/types/ai/providers/pi-errors.d.ts +3 -0
  96. package/types/ai/providers/pi-events.d.ts +24 -0
  97. package/types/ai/providers/pi-messages.d.ts +5 -0
  98. package/types/ai/providers/pi-models.d.ts +57 -0
  99. package/types/ai/providers/pi-native/compaction-driver.d.ts +86 -0
  100. package/types/ai/providers/pi-native/result-builder.d.ts +168 -0
  101. package/types/ai/providers/pi-native/session-lifecycle.d.ts +62 -0
  102. package/types/ai/providers/pi-native/stream-subscriber.d.ts +50 -0
  103. package/types/ai/providers/pi-native/structured-output.d.ts +69 -0
  104. package/types/ai/providers/pi-native/turn-runner.d.ts +60 -0
  105. package/types/ai/providers/pi-native.d.ts +18 -0
  106. package/types/ai/registry.d.ts +1 -0
  107. package/types/ai/runtime/capabilities-used.d.ts +21 -0
  108. package/types/ai/runtime/capabilities.d.ts +33 -0
  109. package/types/ai/runtime/context-windows.d.ts +8 -0
  110. package/types/ai/runtime/fast-mode.d.ts +2 -0
  111. package/types/ai/runtime/model-refs.d.ts +35 -0
  112. package/types/ai/runtime/registry.d.ts +38 -0
  113. package/types/ai/runtime/router.d.ts +56 -0
  114. package/types/ai/runtime/session-liveness.d.ts +55 -0
  115. package/types/ai/runtime/sessions.d.ts +38 -0
  116. package/types/ai/streaming/codex-events.d.ts +30 -0
  117. package/types/ai/streaming/opencode-events.d.ts +41 -0
  118. package/types/ai/types.d.ts +702 -0
  119. package/types/index.d.ts +7 -0
  120. package/types/pi-auth.d.ts +28 -0
  121. package/types/runtime-brand.d.ts +30 -0
  122. package/types/runtime.d.ts +10 -0
  123. package/src/ai/providers/pi-sdk.js +0 -1310
@@ -3,7 +3,7 @@ import { Client as McpClient } from "@modelcontextprotocol/sdk/client/index.js";
3
3
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
4
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
5
5
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6
- import { prepareSandboxedCommand } from "@mono-agent/sandbox";
6
+ import { passthroughSandbox } from "../sandbox-seam.js";
7
7
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
8
8
  import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
9
9
  import {
@@ -28,7 +28,8 @@ import { formatSkillBodyWithPathNote } from "../prompt/skill-index.js";
28
28
  import { MAX_TOOL_RESULT_BYTES, summarisePayload, wrapToolsWithBloatGuard } from "../tool-bloat.js";
29
29
  import { wrapToolsWithApprovalGate } from "../approval.js";
30
30
  import { isInsidePath } from "./shared/path-resolver.js";
31
- import { readRuntimeBrand, readToolRuntime, resolveSandboxPolicy } from "./shared/runtime-context.js";
31
+ import { readToolRuntime } from "./shared/runtime-context.js";
32
+ import { resolveSandboxPolicy } from "./shared/tool-context.js";
32
33
 
33
34
  function textResult(text, details = {}) {
34
35
  return {
@@ -37,7 +38,21 @@ function textResult(text, details = {}) {
37
38
  };
38
39
  }
39
40
 
41
+ function imageResult(data, mimeType, details = {}) {
42
+ return {
43
+ content: [{ type: "image", data: String(data ?? ""), mimeType: mimeType || "image/png" }],
44
+ details,
45
+ };
46
+ }
47
+
48
+ function isImageToolResult(raw) {
49
+ return Boolean(raw) && typeof raw === "object" && raw.kind === "image" && typeof raw.data === "string";
50
+ }
51
+
40
52
  const MCP_TEXT_RESULT_LIMIT = 12_000;
53
+ // Fallback hard cap per MCP call when limits.mcpCallMaxTotalTimeoutMs is not
54
+ // provided; mirrors DEFAULT_MCP_CALL_MAX_TOTAL_TIMEOUT_MS in agent/compaction.js.
55
+ const DEFAULT_MCP_CALL_MAX_TOTAL_TIMEOUT_MS = 2_700_000;
41
56
  const MCP_RAW_DETAIL_LIMIT = 4_000;
42
57
  const MCP_IMAGE_INLINE_MAX_BYTES = 250_000;
43
58
  const DEFAULT_BASH_TIMEOUT_MS = 120_000;
@@ -75,28 +90,34 @@ function artifactFilename(filename, outputDir) {
75
90
  return target;
76
91
  }
77
92
 
78
- export function normalizeMcpToolParams(_serverName, toolName, params, { qaOutputDir } = {}) {
93
+ /**
94
+ * @param {any} _serverName
95
+ * @param {any} toolName
96
+ * @param {any} params
97
+ * @param {{qaOutputDir?: any, ctx?: any}} [options]
98
+ */
99
+ export function normalizeMcpToolParams(_serverName, toolName, params, { qaOutputDir, ctx } = {}) {
79
100
  if (!params || typeof params !== "object" || Array.isArray(params)) return params;
80
101
  if (!PLAYWRIGHT_FILENAME_TOOLS.has(toolName) || !params.filename || isAbsolute(String(params.filename))) return params;
81
- const dir = qaOutputDir ?? readToolRuntime().qaOutputDir;
102
+ const dir = qaOutputDir ?? (ctx ?? readToolRuntime()).qaOutputDir;
82
103
  return {
83
104
  ...params,
84
105
  filename: artifactFilename(params.filename, dir),
85
106
  };
86
107
  }
87
108
 
88
- function normalizeWorkdir(value, cwd) {
89
- const base = resolve(cwd || readToolRuntime().workspace || process.cwd());
109
+ function normalizeWorkdir(value, cwd, ctx) {
110
+ const base = resolve(cwd || (ctx ?? readToolRuntime()).workspace || process.cwd());
90
111
  const resolved = value ? resolve(absolutizePath(value, base)) : base;
91
112
  return isInsidePath(base, resolved) ? resolved : base;
92
113
  }
93
114
 
94
- function withAbsolutePaths(name, params, cwd) {
115
+ function withAbsolutePaths(name, params, cwd, ctx) {
95
116
  const next = { ...(params || {}) };
96
117
  if (["Read", "Write", "Edit"].includes(name)) next.file_path = absolutizePath(next.file_path, cwd);
97
118
  if (["Glob", "Grep"].includes(name)) next.path = absolutizePath(next.path, cwd);
98
119
  if (["Read", "Write", "Edit", "Glob", "Grep", "Bash"].includes(name)) {
99
- next.workdir = normalizeWorkdir(next.workdir, cwd);
120
+ next.workdir = normalizeWorkdir(next.workdir, cwd, ctx);
100
121
  }
101
122
  return next;
102
123
  }
@@ -144,6 +165,10 @@ function compactRawMcpResult(out) {
144
165
  };
145
166
  }
146
167
 
168
+ /**
169
+ * @param {any} change
170
+ * @param {{status?: any, before?: any, after?: any, error?: any}} [options]
171
+ */
147
172
  function fileEditPayload(change, { status, before, after, error } = {}) {
148
173
  const lineStats = statsForCompletedChange(change, before, after);
149
174
  const completedChange = lineStats ? { ...change, line_stats: lineStats } : change;
@@ -183,8 +208,13 @@ function withToolLimits(name, params, limits = {}) {
183
208
  return next;
184
209
  }
185
210
 
186
- export function normalizePiBuiltinToolParams(name, params, { cwd, toolLimits } = {}) {
187
- return withToolLimits(name, withAbsolutePaths(name, params, cwd), toolLimits);
211
+ /**
212
+ * @param {any} name
213
+ * @param {any} params
214
+ * @param {{cwd?: any, toolLimits?: any, ctx?: any}} [options]
215
+ */
216
+ export function normalizePiBuiltinToolParams(name, params, { cwd, toolLimits, ctx } = {}) {
217
+ return withToolLimits(name, withAbsolutePaths(name, params, cwd, ctx), toolLimits);
188
218
  }
189
219
 
190
220
  function integerSchema() {
@@ -211,7 +241,15 @@ function isReadOnlyShellCommand(command) {
211
241
  ].some((pattern) => pattern.test(text));
212
242
  }
213
243
 
214
- function createBuiltinTool(name, label, description, parameters, execute, { cwd, onEvent, toolLimits, toolPolicy, sandboxPolicy, sandboxEngine } = {}) {
244
+ /**
245
+ * @param {any} name
246
+ * @param {any} label
247
+ * @param {any} description
248
+ * @param {any} parameters
249
+ * @param {any} execute
250
+ * @param {{cwd?: any, onEvent?: (event: any) => void, toolLimits?: any, toolPolicy?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
251
+ */
252
+ function createBuiltinTool(name, label, description, parameters, execute, { cwd, onEvent, toolLimits, toolPolicy, sandboxPolicy, sandboxEngine, ctx } = {}) {
215
253
  return {
216
254
  name,
217
255
  label,
@@ -220,7 +258,7 @@ function createBuiltinTool(name, label, description, parameters, execute, { cwd,
220
258
  executionMode: name === "Write" || name === "Edit" || name === "Bash" ? "sequential" : undefined,
221
259
  async execute(toolCallId, params, signal) {
222
260
  if (signal?.aborted) throw new Error("tool execution aborted");
223
- const normalized = normalizePiBuiltinToolParams(name, params, { cwd, toolLimits });
261
+ const normalized = normalizePiBuiltinToolParams(name, params, { cwd, toolLimits, ctx });
224
262
  if (name === "Bash" && toolPolicy?.bashReadOnly && !isReadOnlyShellCommand(normalized.command)) {
225
263
  throw new Error("Error: Planning shell policy allows only read-only inspection commands.");
226
264
  }
@@ -242,7 +280,13 @@ function createBuiltinTool(name, label, description, parameters, execute, { cwd,
242
280
  }));
243
281
  }
244
282
 
245
- const raw = await execute(normalized, { signal, sandboxPolicy, sandboxEngine });
283
+ const raw = await execute(normalized, { signal, sandboxPolicy, sandboxEngine, ctx });
284
+ // Image reads (e.g. Read on a .png) come back as a structured image
285
+ // result so vision models see pixels; emit an image content block and let
286
+ // the shared bloat guard cap oversize payloads.
287
+ if (isImageToolResult(raw)) {
288
+ return imageResult(raw.data, raw.mimeType, { tool: name, params: normalized });
289
+ }
246
290
  const text = toolText(raw);
247
291
  if (isFileEdit && editState) {
248
292
  const failed = isErrorText(text);
@@ -264,24 +308,81 @@ function createBuiltinTool(name, label, description, parameters, execute, { cwd,
264
308
  };
265
309
  }
266
310
 
267
- function readSkillTool(skillNames = [], dataDir) {
268
- const safe = skillNames.filter((name) => /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name));
269
- if (!safe.length || !dataDir) return null;
311
+ /**
312
+ * Progressive skill disclosure: exposes a `read_skill` tool so the agent can pull
313
+ * a named skill's FULL body on demand (skills are otherwise injected index-only).
314
+ *
315
+ * Two input shapes are accepted, matching what hosts pass:
316
+ * - Minimal / legacy (mono-agent's agent-harness): bare `skillNames` + a shared
317
+ * `skillsRoot` (the directory that directly contains `<name>/SKILL.md`), or the
318
+ * back-compat `dataDir` (skills under `<dataDir>/skills`). This path is UNCHANGED.
319
+ * - pi's neutral `Skill` shape (`{name, description, content, filePath, ...}`,
320
+ * what worklab passes): each skill carries an absolute `filePath`. When NO shared
321
+ * `skillsRoot`/`dataDir` is configured, read_skill derives each skill's root from
322
+ * its own `filePath` — pi has no lazy-body equivalent, so the body is still read
323
+ * from disk on demand rather than injected up front.
324
+ *
325
+ * The path-traversal guard (resolved file must stay under the shared root) is
326
+ * preserved on the shared-root path; on the filePath path the name→file binding is
327
+ * fixed at build time and the model can only pick an enum value, so there is no
328
+ * name-driven traversal surface.
329
+ */
330
+ /**
331
+ * @param {any[]} [skillNames]
332
+ * @param {{skillsRoot?: any, dataDir?: any, skills?: any[]}} [options]
333
+ */
334
+ function readSkillTool(skillNames = [], { skillsRoot, dataDir, skills = [] } = {}) {
335
+ const sharedRoot = skillsRoot
336
+ ? resolve(skillsRoot)
337
+ : (dataDir ? resolve(dataDir, "skills") : null);
338
+ const isSafeName = (name) => typeof name === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name);
339
+
340
+ // name -> {path, assetsPath, skillsRoot} derived from the pi Skill filePath.
341
+ // filePath is the skill file itself (nested `<root>/<name>/SKILL.md` or a flat
342
+ // `<root>/<name>.md`): assetsPath is its directory; the note-only skills root is
343
+ // one level up for the nested layout, else the directory itself.
344
+ const filePathEntries = new Map();
345
+ for (const skill of Array.isArray(skills) ? skills : []) {
346
+ if (!skill || typeof skill !== "object") continue;
347
+ if (!isSafeName(skill.name) || typeof skill.filePath !== "string" || !skill.filePath) continue;
348
+ if (filePathEntries.has(skill.name)) continue;
349
+ const path = resolve(skill.filePath);
350
+ const assetsPath = dirname(path);
351
+ const root = basename(path) === "SKILL.md" ? dirname(assetsPath) : assetsPath;
352
+ filePathEntries.set(skill.name, { path, assetsPath, skillsRoot: root });
353
+ }
354
+
355
+ // A shared root (minimal/legacy form) drives the tool exactly as before;
356
+ // filePath is consulted ONLY when it is absent — "derive root from
357
+ // skill.filePath when skillsRoot is absent".
358
+ const enumNames = sharedRoot
359
+ ? skillNames.filter(isSafeName)
360
+ : [...filePathEntries.keys()];
361
+ if (!enumNames.length) return null;
362
+
270
363
  return {
271
364
  name: "read_skill",
272
365
  label: "Read Skill",
273
366
  description: "Load the full instructions for a named skill.",
274
- parameters: objectSchema({ name: { type: "string", enum: safe } }, ["name"]),
367
+ parameters: objectSchema({ name: { type: "string", enum: enumNames } }, ["name"]),
275
368
  async execute(_toolCallId, { name }) {
276
- const path = resolve(dataDir, "skills", name, "SKILL.md");
277
- const root = resolve(dataDir, "skills");
278
- if (!path.startsWith(root + "/")) throw new Error(`invalid skill path: ${name}`);
279
- if (!existsSync(path)) throw new Error(`SKILL.md not found for ${name}`);
369
+ if (sharedRoot) {
370
+ const path = resolve(sharedRoot, name, "SKILL.md");
371
+ if (!path.startsWith(sharedRoot + "/")) throw new Error(`invalid skill path: ${name}`);
372
+ if (!existsSync(path)) throw new Error(`SKILL.md not found for ${name}`);
373
+ return textResult(formatSkillBodyWithPathNote({
374
+ body: stripFrontmatter(readFileSync(path, "utf8")),
375
+ assetsPath: resolve(sharedRoot, name),
376
+ skillsRoot: sharedRoot,
377
+ }), { skill: name, path });
378
+ }
379
+ const entry = filePathEntries.get(name);
380
+ if (!entry || !existsSync(entry.path)) throw new Error(`SKILL.md not found for ${name}`);
280
381
  return textResult(formatSkillBodyWithPathNote({
281
- body: stripFrontmatter(readFileSync(path, "utf8")),
282
- assetsPath: resolve(root, name),
283
- skillsRoot: root,
284
- }), { skill: name, path });
382
+ body: stripFrontmatter(readFileSync(entry.path, "utf8")),
383
+ assetsPath: entry.assetsPath,
384
+ skillsRoot: entry.skillsRoot,
385
+ }), { skill: name, path: entry.path });
285
386
  },
286
387
  };
287
388
  }
@@ -305,8 +406,14 @@ export function createStructuredOutputTool(outputSchema, onStructuredOutput) {
305
406
  };
306
407
  }
307
408
 
409
+ /**
410
+ * @param {any} allowedTools
411
+ * @param {{skillNames?: any[], skills?: any[], skillsRoot?: any, dataDir?: any, cwd?: any, onEvent?: (event: any) => void, toolLimits?: any, persistArtifact?: any, onTruncate?: any, toolPayloadMaxBytes?: number, imageInlineMaxBytes?: any, toolPolicy?: any, sandboxPolicy?: any, sandboxEngine?: any, approvalManager?: any, approvalModel?: any, ctx?: any}} [options]
412
+ */
308
413
  export function getPiBuiltinTools(allowedTools, {
309
414
  skillNames = [],
415
+ skills = [],
416
+ skillsRoot,
310
417
  dataDir,
311
418
  cwd,
312
419
  onEvent,
@@ -314,11 +421,13 @@ export function getPiBuiltinTools(allowedTools, {
314
421
  persistArtifact = null,
315
422
  onTruncate = null,
316
423
  toolPayloadMaxBytes = MAX_TOOL_RESULT_BYTES,
424
+ imageInlineMaxBytes = toolPayloadMaxBytes,
317
425
  toolPolicy = null,
318
426
  sandboxPolicy = null,
319
427
  sandboxEngine = null,
320
428
  approvalManager = null,
321
429
  approvalModel = null,
430
+ ctx = null,
322
431
  } = {}) {
323
432
  const textLimitSchema = integerSchema();
324
433
  const bashLimitSchema = integerSchema();
@@ -326,9 +435,11 @@ export function getPiBuiltinTools(allowedTools, {
326
435
  type: "integer",
327
436
  description: "Timeout in milliseconds. Use 30000 for 30 seconds; small values like 30 are treated as seconds for compatibility.",
328
437
  };
329
- const toolContext = { cwd, onEvent, toolLimits, toolPolicy, sandboxPolicy, sandboxEngine };
438
+ // Per-tool closure config (cwd/event sink/limits/policy) plus the per-instance
439
+ // ToolContext `ctx` that the tool impls and shared helpers read from.
440
+ const toolContext = { cwd, onEvent, toolLimits, toolPolicy, sandboxPolicy, sandboxEngine, ctx };
330
441
  const all = {
331
- Read: createBuiltinTool("Read", "Read", "Read a local file with line numbers.", objectSchema({
442
+ Read: createBuiltinTool("Read", "Read", "Read a local file. Text files return line-numbered content; image files (PNG, JPEG, GIF, WebP, BMP) are returned as a viewable image you can see directly — use this to look at image attachments.", objectSchema({
332
443
  file_path: { type: "string" },
333
444
  offset: { type: "integer" },
334
445
  start_line: { type: "integer" },
@@ -386,7 +497,7 @@ export function getPiBuiltinTools(allowedTools, {
386
497
  };
387
498
  const names = Array.isArray(allowedTools) ? allowedTools : Object.keys(all);
388
499
  const tools = names.map((name) => all[name]).filter(Boolean);
389
- const skillTool = readSkillTool(skillNames, dataDir);
500
+ const skillTool = readSkillTool(skillNames, { skillsRoot, dataDir, skills });
390
501
  if (skillTool) tools.push(skillTool);
391
502
  const gated = approvalManager
392
503
  ? wrapToolsWithApprovalGate(tools, approvalManager, { model: approvalModel })
@@ -394,6 +505,7 @@ export function getPiBuiltinTools(allowedTools, {
394
505
  return wrapToolsWithBloatGuard(gated, {
395
506
  persistArtifact,
396
507
  maxBytes: toolPayloadMaxBytes,
508
+ imageMaxBytes: imageInlineMaxBytes,
397
509
  onTruncate,
398
510
  });
399
511
  }
@@ -405,9 +517,11 @@ export function resolveMcpStdioCwd(cfg = {}, cwd = null) {
405
517
  return cwd || process.cwd();
406
518
  }
407
519
 
408
- export async function prepareMcpStdioCommand(cfg = {}, { cwd = null, sandboxPolicy = null, sandboxEngine = null } = {}) {
409
- return prepareSandboxedCommand({
410
- policy: resolveSandboxPolicy(sandboxPolicy),
520
+ export async function prepareMcpStdioCommand(cfg = {}, { cwd = null, sandboxPolicy = null, sandboxEngine = null, ctx = null } = {}) {
521
+ const resolvedCtx = ctx ?? readToolRuntime();
522
+ const sandbox = resolvedCtx.sandbox ?? passthroughSandbox;
523
+ return sandbox.prepareCommand({
524
+ policy: resolveSandboxPolicy(resolvedCtx, sandboxPolicy),
411
525
  engine: sandboxEngine ?? undefined,
412
526
  command: {
413
527
  command: cfg.command,
@@ -418,8 +532,13 @@ export async function prepareMcpStdioCommand(cfg = {}, { cwd = null, sandboxPoli
418
532
  });
419
533
  }
420
534
 
421
- async function connectMcpClient(name, cfg, { cwd, sandboxPolicy, sandboxEngine } = {}) {
422
- const brand = readRuntimeBrand();
535
+ /**
536
+ * @param {any} name
537
+ * @param {any} cfg
538
+ * @param {{cwd?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
539
+ */
540
+ async function connectMcpClient(name, cfg, { cwd, sandboxPolicy, sandboxEngine, ctx } = {}) {
541
+ const brand = (ctx ?? readToolRuntime()).runtimeBrand;
423
542
  const client = new McpClient(
424
543
  { name: `${brand.mcpClientName}/${name}`, version: brand.mcpClientVersion },
425
544
  { capabilities: {} },
@@ -429,25 +548,28 @@ async function connectMcpClient(name, cfg, { cwd, sandboxPolicy, sandboxEngine }
429
548
  transport = new StreamableHTTPClientTransport(new URL(cfg.url), { requestInit: { headers: cfg.headers || {} } });
430
549
  } else if (cfg.type === "sse") {
431
550
  transport = new SSEClientTransport(new URL(cfg.url), {
432
- eventSourceInit: { headers: cfg.headers || {} },
551
+ // SSE EventSourceInit's typed shape omits `headers`, but the transport
552
+ // forwards them to the underlying EventSource — keep the header pass-through.
553
+ eventSourceInit: /** @type {any} */ ({ headers: cfg.headers || {} }),
433
554
  requestInit: { headers: cfg.headers || {} },
434
555
  });
435
556
  } else {
436
- const prepared = await prepareMcpStdioCommand(cfg, { cwd, sandboxPolicy, sandboxEngine });
557
+ const prepared = await prepareMcpStdioCommand(cfg, { cwd, sandboxPolicy, sandboxEngine, ctx });
437
558
  transport = new StdioClientTransport({
438
559
  command: prepared.command,
439
560
  args: prepared.args || [],
440
561
  cwd: prepared.cwd,
441
562
  env: { ...process.env, ...(prepared.env || {}) },
442
563
  });
443
- transport.__monoSandboxCleanup = prepared.cleanup;
564
+ // Monkey-patched cleanup handle: not part of the MCP transport's typed shape.
565
+ /** @type {any} */ (transport).__monoSandboxCleanup = prepared.cleanup;
444
566
  }
445
567
  try {
446
568
  await client.connect(transport);
447
569
  return { name, client, transport };
448
570
  } catch (error) {
449
571
  try { await transport?.close?.(); } catch { /* best-effort */ }
450
- try { await transport?.__monoSandboxCleanup?.(); } catch { /* best-effort */ }
572
+ try { await /** @type {any} */ (transport)?.__monoSandboxCleanup?.(); } catch { /* best-effort */ }
451
573
  throw error;
452
574
  }
453
575
  }
@@ -515,16 +637,29 @@ function mcpToolName(serverName, toolName, reservedNames) {
515
637
  return `mcp__${serverName}__${toolName}`;
516
638
  }
517
639
 
518
- function withTimeout(promise, timeoutMs, signal, label) {
640
+ // Inactivity wall clock around an MCP call. `registerReset` (optional) hands the
641
+ // caller a rearm function so tool progress notifications can keep a legitimately
642
+ // long call alive; the SDK's maxTotalTimeout stays the hard cap.
643
+ function withTimeout(promise, timeoutMs, signal, label, registerReset) {
519
644
  if (signal?.aborted) return Promise.reject(new Error("tool execution aborted"));
520
645
  const ms = Number(timeoutMs) || 120000;
521
646
  let timeout;
522
647
  const timer = new Promise((_, reject) => {
523
- timeout = setTimeout(() => reject(new Error(`${label || "MCP tool"} timed out after ${ms}ms`)), ms);
648
+ const arm = () => setTimeout(() => reject(new Error(`${label || "MCP tool"} timed out after ${ms}ms`)), ms);
649
+ timeout = arm();
650
+ registerReset?.(() => {
651
+ clearTimeout(timeout);
652
+ timeout = arm();
653
+ });
524
654
  });
525
655
  return Promise.race([promise, timer]).finally(() => clearTimeout(timeout));
526
656
  }
527
657
 
658
+ /**
659
+ * @param {any} mcpConfig
660
+ * @param {Set<any>} [reservedNames]
661
+ * @param {{limits?: any, cwd?: any, persistArtifact?: any, qaOutputDir?: any, onTruncate?: any, toolPayloadMaxBytes?: number, sandboxPolicy?: any, sandboxEngine?: any, onToolProgress?: any, ctx?: any}} [options]
662
+ */
528
663
  export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
529
664
  limits = {},
530
665
  cwd = null,
@@ -534,14 +669,19 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
534
669
  toolPayloadMaxBytes = MAX_TOOL_RESULT_BYTES,
535
670
  sandboxPolicy = null,
536
671
  sandboxEngine = null,
672
+ onToolProgress = null,
673
+ ctx = null,
537
674
  } = {}) {
538
675
  const clients = [];
539
676
  const tools = [];
540
677
  const entries = Object.entries(mcpConfig || {});
541
- const settled = await Promise.allSettled(entries.map(([name, cfg]) => connectMcpClient(name, cfg, { cwd, sandboxPolicy, sandboxEngine })));
678
+ const settled = await Promise.allSettled(entries.map(([name, cfg]) => connectMcpClient(name, cfg, { cwd, sandboxPolicy, sandboxEngine, ctx })));
542
679
  const warnings = [];
543
680
  const seen = new Set(reservedNames);
544
681
 
682
+ // Phase 1: collect connected clients in entry order (so tool registration is
683
+ // deterministic) and record connect failures.
684
+ const connectedList = [];
545
685
  for (const [index, result] of settled.entries()) {
546
686
  const serverName = entries[index]?.[0];
547
687
  if (result.status !== "fulfilled") {
@@ -553,18 +693,28 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
553
693
  });
554
694
  continue;
555
695
  }
696
+ clients.push(result.value);
697
+ connectedList.push({ serverName, connected: result.value });
698
+ }
556
699
 
557
- const connected = result.value;
558
- clients.push(connected);
559
- let listed;
700
+ // Phase 2: list tools from every connected server CONCURRENTLY (previously
701
+ // serial, adding ~Nx the slowest listTools to turn startup, painful over a
702
+ // SOCKS proxy). Results are awaited in entry order to keep registration stable.
703
+ const listings = await Promise.all(connectedList.map(async ({ serverName, connected }) => {
560
704
  try {
561
- listed = await connected.client.listTools();
705
+ return { serverName, connected, listed: await connected.client.listTools() };
562
706
  } catch (err) {
707
+ return { serverName, connected, error: err };
708
+ }
709
+ }));
710
+
711
+ for (const { serverName, connected, listed, error } of listings) {
712
+ if (error) {
563
713
  warnings.push({
564
714
  type: "runtime_warning",
565
715
  warning_kind: "mcp_list_tools_failed",
566
716
  server: serverName,
567
- message: err?.message || String(err),
717
+ message: error?.message || String(error),
568
718
  });
569
719
  continue;
570
720
  }
@@ -577,18 +727,67 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
577
727
  name,
578
728
  label: sourceTool.title || sourceTool.name,
579
729
  description: sourceTool.description || `${serverName}:${sourceTool.name}`,
580
- parameters: sourceTool.inputSchema || sourceTool.input_schema || objectSchema({}),
730
+ parameters: sourceTool.inputSchema || /** @type {any} */ (sourceTool).input_schema || objectSchema({}),
581
731
  async execute(toolCallId, params, signal) {
582
732
  if (signal?.aborted) throw new Error("tool execution aborted");
583
733
  const textLimit = limits.mcpTextLimitChars || MCP_TEXT_RESULT_LIMIT;
584
734
  const imageInlineMaxBytes = limits.imageInlineMaxBytes ?? MCP_IMAGE_INLINE_MAX_BYTES;
585
- const normalizedParams = normalizeMcpToolParams(serverName, sourceTool.name, params || {}, { qaOutputDir });
735
+ const normalizedParams = normalizeMcpToolParams(serverName, sourceTool.name, params || {}, { qaOutputDir, ctx });
736
+ // Measure the MCP round-trip so observability can separate slow MCP
737
+ // servers (e.g. context-example over a SOCKS proxy) from model latency.
738
+ const mcpCallStartMs = Date.now();
739
+ // The MCP SDK's per-request timeout defaults to 60s (DEFAULT_REQUEST_TIMEOUT_MSEC),
740
+ // which would fire -32001 well before the outer withTimeout wall-clock cap — fatal for
741
+ // in-process tools that run a whole agent turn (e.g. notify_conversation delivery).
742
+ // Pass it explicitly so the SDK request timeout matches our cap instead of pre-empting it.
743
+ const mcpCallTimeoutMs = limits.mcpCallTimeoutMs || 120000;
744
+ // Inactivity vs total: mcpCallTimeoutMs is reset by every progress
745
+ // notification (keep-alive for long tools like transcription or an
746
+ // ask-the-user wait); mcpCallMaxTotalTimeoutMs is the unresettable cap.
747
+ const mcpCallMaxTotalTimeoutMs = Math.max(
748
+ limits.mcpCallMaxTotalTimeoutMs || DEFAULT_MCP_CALL_MAX_TOTAL_TIMEOUT_MS,
749
+ mcpCallTimeoutMs,
750
+ );
751
+ // The SDK only attaches a progressToken (and thus honors
752
+ // resetTimeoutOnProgress) when an onprogress callback is present, so one
753
+ // is always attached: it rearms the outer wall clock and optionally
754
+ // surfaces the notification to the host.
755
+ let resetInactivityTimeout = null;
756
+ const onprogress = (progress) => {
757
+ resetInactivityTimeout?.();
758
+ onToolProgress?.({
759
+ type: "tool_progress",
760
+ server: serverName,
761
+ tool: sourceTool.name,
762
+ toolCallId,
763
+ ...(progress?.progress === undefined ? {} : { progress: progress.progress }),
764
+ ...(progress?.total === undefined ? {} : { total: progress.total }),
765
+ ...(progress?.message === undefined ? {} : { message: progress.message }),
766
+ });
767
+ };
586
768
  const out = await withTimeout(
587
- connected.client.callTool({ name: sourceTool.name, arguments: normalizedParams || {} }),
588
- limits.mcpCallTimeoutMs || 120000,
769
+ connected.client.callTool(
770
+ { name: sourceTool.name, arguments: normalizedParams || {} },
771
+ undefined,
772
+ // Forward the abort signal too, so a cancelled/timed-out call also cancels the
773
+ // in-flight MCP request on the wire (otherwise the SDK keeps awaiting until its own
774
+ // timeout, and an in-process loopback turn could post late after the bridge rejected).
775
+ {
776
+ timeout: mcpCallTimeoutMs,
777
+ resetTimeoutOnProgress: true,
778
+ maxTotalTimeout: mcpCallMaxTotalTimeoutMs,
779
+ signal,
780
+ onprogress,
781
+ },
782
+ ),
783
+ mcpCallTimeoutMs,
589
784
  signal,
590
785
  `${serverName}:${sourceTool.name}`,
786
+ (reset) => {
787
+ resetInactivityTimeout = reset;
788
+ },
591
789
  );
790
+ const mcpCallDurationMs = Date.now() - mcpCallStartMs;
592
791
  const imageTruncations = [];
593
792
  return {
594
793
  content: coerceMcpContent(out, {
@@ -605,6 +804,7 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
605
804
  details: {
606
805
  server: serverName,
607
806
  tool: sourceTool.name,
807
+ mcp_call_duration_ms: mcpCallDurationMs,
608
808
  result_truncated: mcpContentWasTruncated(out, { textLimit, imageInlineMaxBytes }),
609
809
  raw: compactRawMcpResult(out),
610
810
  ...(imageTruncations.length ? {
@@ -629,10 +829,28 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
629
829
  };
630
830
  }
631
831
 
632
- export async function closePiMcpClients(clients) {
633
- for (const { client, transport } of clients || []) {
634
- try { await client.close?.(); } catch { /* best-effort */ }
635
- try { await transport.close?.(); } catch { /* best-effort */ }
636
- try { await transport.__monoSandboxCleanup?.(); } catch { /* best-effort */ }
832
+ async function closeWithTimeout(close, timeoutMs) {
833
+ if (typeof close !== "function") return;
834
+ const result = close();
835
+ if (!(result && typeof result.then === "function")) return;
836
+ let timer;
837
+ try {
838
+ await Promise.race([
839
+ result,
840
+ new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); }),
841
+ ]);
842
+ } finally {
843
+ if (timer) clearTimeout(timer);
637
844
  }
638
845
  }
846
+
847
+ export async function closePiMcpClients(clients, { timeoutMs = 5000 } = {}) {
848
+ // Close the client first (stop accepting messages) then the transport (tear
849
+ // down I/O), each bounded by a timeout so a hung stdio pipe cannot stall
850
+ // shutdown — a common source of "Connection closed" churn on reconnect.
851
+ await Promise.all((clients || []).map(async ({ client, transport }) => {
852
+ try { await closeWithTimeout(client?.close?.bind(client), timeoutMs); } catch { /* best-effort */ }
853
+ try { await closeWithTimeout(transport?.close?.bind(transport), timeoutMs); } catch { /* best-effort */ }
854
+ try { await transport?.__monoSandboxCleanup?.(); } catch { /* best-effort */ }
855
+ }));
856
+ }
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { extname } from "node:path";
2
3
  import {
3
4
  DEFAULT_MAX_READ_CHARS,
4
5
  DEFAULT_READ_LINES,
@@ -8,10 +9,33 @@ import { boundedInt, rememberRead, trimLine } from "./shared/dedup.js";
8
9
  import { capChars } from "./shared/output-truncation.js";
9
10
  import { isPathAllowed, resolveToolPath } from "./shared/path-resolver.js";
10
11
 
11
- export async function readToolImpl({ file_path, offset = 0, start_line, limit, max_output_chars, workdir }, { sandboxPolicy } = {}) {
12
- const target = resolveToolPath(file_path, workdir);
13
- if (!isPathAllowed(target, workdir, { sandboxPolicy })) return `Error: Path not allowed: ${file_path}`;
12
+ // Raster image formats a vision model can consume directly. SVG is intentionally
13
+ // excluded it is XML text, so it stays on the line-numbered text path.
14
+ const IMAGE_MIME_BY_EXT = {
15
+ ".png": "image/png",
16
+ ".jpg": "image/jpeg",
17
+ ".jpeg": "image/jpeg",
18
+ ".gif": "image/gif",
19
+ ".webp": "image/webp",
20
+ ".bmp": "image/bmp",
21
+ };
22
+
23
+ /**
24
+ * @param {{file_path: string, offset?: number, start_line?: number, limit?: number, max_output_chars?: number, workdir?: string}} params
25
+ * @param {{sandboxPolicy?: any, ctx?: any}} [options]
26
+ */
27
+ export async function readToolImpl({ file_path, offset = 0, start_line, limit, max_output_chars, workdir }, { sandboxPolicy, ctx } = {}) {
28
+ const target = resolveToolPath(file_path, workdir, ctx);
29
+ if (!isPathAllowed(target, workdir, { sandboxPolicy, ctx })) return `Error: Path not allowed: ${file_path}`;
14
30
  if (!existsSync(target)) return `Error: File not found: ${file_path}`;
31
+ // Image files are returned as an image result so vision models see pixels
32
+ // rather than the raw bytes decoded (and garbled) as utf8 text. The builtin
33
+ // tool wrapper turns this into an image content block; oversize images are
34
+ // capped by the shared tool-result bloat guard.
35
+ const imageMime = IMAGE_MIME_BY_EXT[extname(target).toLowerCase()];
36
+ if (imageMime !== undefined) {
37
+ return { kind: "image", data: readFileSync(target).toString("base64"), mimeType: imageMime };
38
+ }
15
39
  const content = readFileSync(target, "utf8");
16
40
  let lines = content.split("\n");
17
41
  const total = lines.length;
@@ -35,5 +59,6 @@ export async function readToolImpl({ file_path, offset = 0, start_line, limit, m
35
59
  label: "Read",
36
60
  maxChars: Number(max_output_chars) || DEFAULT_MAX_READ_CHARS,
37
61
  hint: "Use Read with offset/start_line and limit for the specific range you need.",
62
+ ctx,
38
63
  });
39
64
  }
@@ -9,8 +9,8 @@ function sanitizeName(value) {
9
9
  return String(value || "tool").replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "tool";
10
10
  }
11
11
 
12
- export function writeToolArtifact(label, text) {
13
- const { toolArtifactDir, runId } = readToolRuntime();
12
+ export function writeToolArtifact(label, text, ctx) {
13
+ const { toolArtifactDir, runId } = ctx ?? readToolRuntime();
14
14
  if (!toolArtifactDir) return null;
15
15
  try {
16
16
  const safeRunId = sanitizeName(runId || "manual");
@@ -33,16 +33,21 @@ export function truncationSuffix({ label, shown, total, artifact, hint }) {
33
33
  ].filter(Boolean).join("\n");
34
34
  }
35
35
 
36
+ /**
37
+ * @param {string} text
38
+ * @param {{label?: string, maxChars?: number, strategy?: string, hint?: string, ctx?: any}} [options]
39
+ */
36
40
  export function capChars(text, {
37
41
  label = "tool",
38
42
  maxChars = DEFAULT_MAX_TOOL_OUTPUT_CHARS,
39
43
  strategy = "head",
40
44
  hint,
45
+ ctx,
41
46
  } = {}) {
42
47
  const value = String(text || "");
43
48
  const limit = boundedInt(maxChars, DEFAULT_MAX_TOOL_OUTPUT_CHARS, { min: 200 });
44
49
  if (value.length <= limit) return value;
45
- const artifact = writeToolArtifact(label, value);
50
+ const artifact = writeToolArtifact(label, value, ctx);
46
51
  const suffix = truncationSuffix({ label, shown: limit, total: value.length, artifact, hint });
47
52
  const budget = Math.max(0, limit - suffix.length);
48
53
  if (strategy === "head_tail" && budget > 200) {