@mono-agent/agent-runtime 0.19.0 → 0.20.0

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 (51) hide show
  1. package/MIGRATION.md +1 -1
  2. package/README.md +101 -4
  3. package/package.json +1 -1
  4. package/src/agent/sandbox-seam.js +16 -2
  5. package/src/agent/tools/bash.js +26 -4
  6. package/src/agent/tools/edit.js +72 -5
  7. package/src/agent/tools/exec.js +22 -4
  8. package/src/agent/tools/glob.js +65 -9
  9. package/src/agent/tools/grep.js +66 -11
  10. package/src/agent/tools/node-repl.js +5 -2
  11. package/src/agent/tools/pi-bridge.js +263 -48
  12. package/src/agent/tools/read.js +50 -10
  13. package/src/agent/tools/shared/path-resolver.js +67 -2
  14. package/src/agent/tools/shared/process-jobs.js +188 -0
  15. package/src/agent/tools/shared/process-runner.js +541 -30
  16. package/src/agent/tools/shared/protected-filesystem.js +150 -0
  17. package/src/agent/tools/web-search.js +63 -8
  18. package/src/agent/tools/write.js +52 -6
  19. package/src/ai/providers/acp.js +4 -0
  20. package/src/ai/providers/claude-cli.js +35 -2
  21. package/src/ai/providers/claude-sdk.js +12 -0
  22. package/src/ai/providers/codex-app.js +15 -2
  23. package/src/ai/providers/pi-native/stream-subscriber.js +29 -2
  24. package/src/ai/providers/pi-native/turn-runner.js +3 -0
  25. package/src/ai/providers/pi-native.js +7 -1
  26. package/src/ai/runtime/capabilities.js +2 -0
  27. package/src/ai/runtime/router.js +78 -6
  28. package/src/ai/streaming/codex-events.js +15 -0
  29. package/src/ai/streaming/opencode-events.js +5 -0
  30. package/src/ai/tool-lifecycle.js +347 -0
  31. package/src/ai/types.js +58 -0
  32. package/src/runtime.js +35 -21
  33. package/types/agent/sandbox-seam.d.ts +19 -6
  34. package/types/agent/tools/bash.d.ts +11 -26
  35. package/types/agent/tools/edit.d.ts +3 -2
  36. package/types/agent/tools/exec.d.ts +13 -26
  37. package/types/agent/tools/glob.d.ts +3 -2
  38. package/types/agent/tools/grep.d.ts +3 -2
  39. package/types/agent/tools/pi-bridge.d.ts +11 -4
  40. package/types/agent/tools/read.d.ts +3 -2
  41. package/types/agent/tools/shared/path-resolver.d.ts +8 -0
  42. package/types/agent/tools/shared/process-jobs.d.ts +64 -0
  43. package/types/agent/tools/shared/process-runner.d.ts +45 -3
  44. package/types/agent/tools/shared/protected-filesystem.d.ts +51 -0
  45. package/types/agent/tools/write.d.ts +3 -2
  46. package/types/ai/providers/pi-native/stream-subscriber.d.ts +2 -0
  47. package/types/ai/runtime/capabilities.d.ts +3 -0
  48. package/types/ai/streaming/codex-events.d.ts +1 -0
  49. package/types/ai/streaming/opencode-events.d.ts +1 -0
  50. package/types/ai/tool-lifecycle.d.ts +43 -0
  51. package/types/ai/types.d.ts +118 -0
@@ -0,0 +1,150 @@
1
+ // Protected-root filesystem operations must cross the native sandbox boundary.
2
+ // A host-side path check cannot close a parent-symlink swap between authorization
3
+ // and open(2); SRT evaluates the path at the actual child-process syscall.
4
+
5
+ // @ts-check
6
+
7
+ import { basename, isAbsolute, relative, resolve, sep } from "node:path";
8
+ import { readToolRuntime } from "./runtime-context.js";
9
+ import { runPreparedProcess } from "./process-runner.js";
10
+ import { resolveSandboxPolicy } from "./tool-context.js";
11
+
12
+ const PROTECTED_OPERATION_TIMEOUT_MS = 15_000;
13
+
14
+ /**
15
+ * @param {{command: string, args?: string[], cwd?: string, env?: Record<string, string|undefined>}} command
16
+ * @param {{sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, input?: string|Buffer, maxBufferBytes?: number}} [options]
17
+ * @returns {Promise<any|null>}
18
+ */
19
+ export async function runProtectedFilesystemCommand(command, {
20
+ sandboxPolicy,
21
+ sandboxEngine,
22
+ ctx,
23
+ input,
24
+ maxBufferBytes,
25
+ } = {}) {
26
+ const resolvedCtx = ctx ?? readToolRuntime();
27
+ const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
28
+ if (!hasProtectedRoots(policy)) {
29
+ return null;
30
+ }
31
+ const sandbox = resolvedCtx.sandbox;
32
+ let prepared;
33
+ try {
34
+ prepared = await sandbox.prepareCommand({
35
+ policy,
36
+ engine: sandboxEngine ?? resolvedCtx.sandboxEngine ?? undefined,
37
+ command,
38
+ });
39
+ return await runPreparedProcess(prepared, {
40
+ timeoutMs: PROTECTED_OPERATION_TIMEOUT_MS,
41
+ ...(input === undefined ? {} : { input }),
42
+ ...(maxBufferBytes === undefined ? {} : { maxBufferBytes }),
43
+ });
44
+ } finally {
45
+ await prepared?.cleanup?.();
46
+ }
47
+ }
48
+
49
+ /** @param {any} policy */
50
+ function hasProtectedRoots(policy) {
51
+ return policy !== undefined
52
+ && Array.isArray(policy.protectedRoots)
53
+ && policy.protectedRoots.length > 0;
54
+ }
55
+
56
+ /**
57
+ * Build a metadata-free operation plan rooted at a configured policy path.
58
+ * Search tools keep the model-controlled target as an argument; file helpers
59
+ * use the stable cwd with their absolute target. In both cases the host avoids
60
+ * target metadata and target-derived cwd resolution before SRT enforces policy.
61
+ *
62
+ * @param {string} target
63
+ * @param {{sandboxPolicy?: any, ctx?: any}} [options]
64
+ * @returns {{cwd: string, searchTarget: string}|null}
65
+ */
66
+ export function protectedFilesystemTargetPlan(target, { sandboxPolicy, ctx } = {}) {
67
+ const resolvedCtx = ctx ?? readToolRuntime();
68
+ const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
69
+ if (!hasProtectedRoots(policy)) return null;
70
+ const resolvedTarget = resolve(target);
71
+ const roots = [
72
+ policy.root,
73
+ ...(Array.isArray(policy.readableRoots) ? policy.readableRoots : []),
74
+ resolvedCtx.workspace,
75
+ resolvedCtx.repoRoot,
76
+ process.cwd(),
77
+ ];
78
+ const cwd = roots
79
+ .filter((root) => typeof root === "string" && root.length > 0)
80
+ .map((root) => resolve(root))
81
+ .find((root) => lexicallyContains(root, resolvedTarget))
82
+ ?? resolve(policy.root || resolvedCtx.workspace || resolvedCtx.repoRoot || process.cwd());
83
+ const rel = relative(cwd, resolvedTarget);
84
+ const searchTarget = rel === ""
85
+ ? "."
86
+ : (!rel.startsWith("..") && !isAbsolute(rel) ? rel : resolvedTarget);
87
+ return { cwd, searchTarget };
88
+ }
89
+
90
+ /** @param {string} searchTarget */
91
+ export function protectedDirectorySearchTarget(searchTarget) {
92
+ return searchTarget.endsWith(sep) ? searchTarget : `${searchTarget}${sep}`;
93
+ }
94
+
95
+ /**
96
+ * Scope a target-relative user glob to ripgrep's stable host cwd.
97
+ * @param {string} pattern
98
+ * @param {string} searchTarget
99
+ */
100
+ export function scopeProtectedSearchGlob(pattern, searchTarget) {
101
+ const normalizedTarget = normalizeSearchPath(searchTarget);
102
+ if (normalizedTarget === ".") return pattern;
103
+ const negated = pattern.startsWith("!");
104
+ const body = (negated ? pattern.slice(1) : pattern).replace(/^\.\//u, "").replace(/^\/+/u, "");
105
+ const prefix = normalizedTarget
106
+ .split("/")
107
+ .map((part) => part.replace(/[!\\*?\[\]{}]/gu, "\\$&"))
108
+ .join("/");
109
+ return `${negated ? "!" : ""}${prefix}/${body}`;
110
+ }
111
+
112
+ /**
113
+ * Restore the historical target-relative search output after ripgrep runs from
114
+ * the stable policy root.
115
+ * @param {string} line
116
+ * @param {string} searchTarget
117
+ */
118
+ export function normalizeProtectedSearchLine(line, searchTarget) {
119
+ const normalizedLine = normalizeSearchPath(line).replace(/^\.\//u, "");
120
+ const normalizedTarget = normalizeSearchPath(searchTarget).replace(/^\.\//u, "");
121
+ if (normalizedTarget === ".") return normalizedLine;
122
+ if (normalizedLine === normalizedTarget) return basename(normalizedTarget);
123
+ if (normalizedLine.startsWith(`${normalizedTarget}/`)) {
124
+ return normalizedLine.slice(normalizedTarget.length + 1);
125
+ }
126
+ if (normalizedLine.startsWith(`${normalizedTarget}:`)) {
127
+ return `${basename(normalizedTarget)}${normalizedLine.slice(normalizedTarget.length)}`;
128
+ }
129
+ return normalizedLine;
130
+ }
131
+
132
+ /** @param {any} result */
133
+ export function protectedCommandSucceeded(result) {
134
+ return result !== null
135
+ && result.code === 0
136
+ && result.signal === null
137
+ && result.spawnError === null
138
+ && result.timedOut === false
139
+ && result.bufferExceeded === false;
140
+ }
141
+
142
+ function normalizeSearchPath(path) {
143
+ const normalized = sep === "\\" ? String(path).replaceAll("\\", "/") : String(path);
144
+ return normalized.replace(/\/+$/u, "") || ".";
145
+ }
146
+
147
+ function lexicallyContains(root, target) {
148
+ const rel = relative(resolve(root), resolve(target));
149
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
150
+ }
@@ -35,7 +35,16 @@ const KEYLESS_DEFAULT_THROTTLE = {
35
35
  // behind it has to actually work.
36
36
  const KEYLESS_BACKENDS = ["duckduckgo", "startpage"];
37
37
  // Markers that identify an interstitial/bot-gate body served with a 2xx status.
38
- const CHALLENGE_BODY_RE = /anomaly|unusual traffic|captcha|are you a robot|challenge-(?:platform|form)/iu;
38
+ // The last two are Anubis, the proof-of-work gate Startpage now fronts its
39
+ // results with. It says none of the classic things — no captcha, no anomaly,
40
+ // just "Verifying your request..." — so without these it read as a clean 200
41
+ // that happened to parse to nothing, which is exactly the lie this guard exists
42
+ // to prevent.
43
+ const CHALLENGE_BODY_RE =
44
+ /anomaly|unusual traffic|captcha|are you a robot|challenge-(?:platform|form)|anubis[_-]?challenge|verifying your request/iu;
45
+ // Reasons SearXNG reports for an engine that is being throttled or gated rather
46
+ // than merely erroring, e.g. "CAPTCHA", "too many requests", "Suspended: CAPTCHA".
47
+ const SEARXNG_THROTTLE_REASON_RE = /captcha|too many requests|rate.?limit|suspend|blocked|denied/iu;
39
48
  // Statuses these engines use to say "you are sending too much", all of which
40
49
  // must put the backend into cooldown rather than be retried next search.
41
50
  const RATE_LIMIT_STATUSES = new Set([202, 403, 429]);
@@ -228,17 +237,22 @@ async function searchOneQuery(query, options) {
228
237
  // too. Reporting only the last one is what made a DuckDuckGo ban surface as
229
238
  // "startpage request failed: fetch failed" and sent diagnosis the wrong way.
230
239
  const failures = [];
240
+ // A genuinely empty 200 is a real answer, not a transport failure — but it is
241
+ // only worth returning once every backend has had its turn. Scoped to the whole
242
+ // chain, not just the keyless loop: an empty SearXNG answer used to short-
243
+ // circuit `auto` outright, so the fallbacks below could never rescue a query.
244
+ let emptySuccess = null;
231
245
  if (options.signal?.aborted) return abortedSearch(config.backend, failures);
232
246
  if (config.backend === "searxng" || (config.backend === "auto" && config.endpoint)) {
233
247
  const result = await searchSearxng(query, options);
234
- if (result.ok || config.backend === "searxng") return { ...result, failures };
235
- failures.push(result);
248
+ // Strict mode has nothing to fall through to, so its answer stands as-is.
249
+ if (config.backend === "searxng") return { ...result, failures };
250
+ if (result.ok && result.results.length > 0) return { ...result, failures };
251
+ if (result.ok) emptySuccess = result;
252
+ else failures.push(result);
236
253
  if (options.signal?.aborted) return abortedSearch(result.backend, failures);
237
254
  }
238
255
  if (config.backend === "keyless" || config.backend === "auto") {
239
- // A genuinely empty 200 is a real answer, not a transport failure — but it
240
- // is only worth returning once every backend has had its turn.
241
- let emptySuccess = null;
242
256
  for (const backend of KEYLESS_BACKENDS) {
243
257
  if (options.signal?.aborted) return abortedSearch(backend, failures);
244
258
  if (backendInCooldown(backend)) {
@@ -254,14 +268,14 @@ async function searchOneQuery(query, options) {
254
268
  const result = await KEYLESS_RUNNERS[backend](query, options);
255
269
  if (result.ok) {
256
270
  if (result.results.length > 0) return { ...result, failures };
257
- emptySuccess = result;
271
+ emptySuccess ??= result;
258
272
  continue;
259
273
  }
260
274
  // The cooldown is already open — rateLimited() sets it at detection.
261
275
  failures.push(result);
262
276
  }
263
- if (emptySuccess) return { ...emptySuccess, failures };
264
277
  }
278
+ if (emptySuccess) return { ...emptySuccess, failures };
265
279
  return {
266
280
  ...(failures[failures.length - 1] || {
267
281
  ok: false,
@@ -385,6 +399,33 @@ async function searchSearxng(query, options) {
385
399
  const results = Array.isArray(data?.results)
386
400
  ? data.results.flatMap((entry) => normalizedResult(entry, "searxng"))
387
401
  : [];
402
+ // An instance whose engines are all captcha'd or suspended still answers
403
+ // `200 {"results": []}`, and `unresponsive_engines` is the only thing that
404
+ // tells that apart from a query nothing matched. Reading `results` alone is
405
+ // what let a completely dead instance report "No results." on every query
406
+ // for weeks. Naming each engine and its reason is what makes the next one
407
+ // diagnosable from the tool output instead of from the container logs.
408
+ //
409
+ // Counted on the RAW array, not the normalized one: results that all fail
410
+ // canonicalization are an unusable answer from working engines, which is a
411
+ // different fault and must not be blamed on the engines that did fail.
412
+ const unresponsive = normalizeUnresponsiveEngines(data?.unresponsive_engines);
413
+ if (!Array.isArray(data?.results) || (data.results.length === 0 && unresponsive.length > 0)) {
414
+ if (unresponsive.length === 0) {
415
+ return { ok: false, backend: "searxng", message: "SearXNG returned no results array.", retryable: false };
416
+ }
417
+ // Deliberately not "every engine failed": SearXNG lists only the engines
418
+ // that failed, so a working engine that simply matched nothing is
419
+ // indistinguishable here from one that was never queried.
420
+ const detail = unresponsive.map((entry) => `${entry.name}: ${entry.reason}`).join("; ");
421
+ return {
422
+ ok: false,
423
+ backend: "searxng",
424
+ message: `SearXNG returned no results and ${unresponsive.length === 1 ? "1 engine" : `${unresponsive.length} engines`} failed (${detail})`,
425
+ retryable: true,
426
+ rateLimited: unresponsive.some((entry) => SEARXNG_THROTTLE_REASON_RE.test(entry.reason)),
427
+ };
428
+ }
388
429
  return { ok: true, backend: "searxng", results };
389
430
  } catch (error) {
390
431
  return fetchFailure("searxng", error);
@@ -573,6 +614,20 @@ export function parseStartpageResults(html) {
573
614
  return results;
574
615
  }
575
616
 
617
+ /**
618
+ * SearXNG reports each failed engine as a `[name, reason]` pair. Older builds
619
+ * and some forks send objects instead, so both shapes are accepted.
620
+ */
621
+ function normalizeUnresponsiveEngines(value) {
622
+ if (!Array.isArray(value)) return [];
623
+ return value.flatMap((entry) => {
624
+ const [name, reason] = Array.isArray(entry) ? entry : [entry?.name, entry?.error ?? entry?.reason];
625
+ const normalizedName = collapseWhitespace(name);
626
+ if (!normalizedName) return [];
627
+ return [{ name: normalizedName, reason: collapseWhitespace(reason) || "unknown error" }];
628
+ });
629
+ }
630
+
576
631
  function normalizedResult(entry, backend) {
577
632
  if (!entry || typeof entry !== "object") return [];
578
633
  const url = canonicalizeSearchUrl(entry.url);
@@ -1,18 +1,64 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { MAX_WRITE_BYTES } from "./shared/constants.js";
4
- import { isWritablePathAllowed, resolveToolPath } from "./shared/path-resolver.js";
4
+ import {
5
+ isWritablePathAllowed,
6
+ isWritablePathLexicallyAllowed,
7
+ resolveToolPath,
8
+ } from "./shared/path-resolver.js";
9
+ import {
10
+ protectedCommandSucceeded,
11
+ protectedFilesystemTargetPlan,
12
+ runProtectedFilesystemCommand,
13
+ } from "./shared/protected-filesystem.js";
14
+
15
+ const PROTECTED_WRITE_SOURCE = String.raw`
16
+ "use strict";
17
+ const { mkdirSync, writeFileSync } = require("node:fs");
18
+ const { dirname } = require("node:path");
19
+ const chunks = [];
20
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
21
+ process.stdin.on("end", () => {
22
+ const target = process.argv[1];
23
+ mkdirSync(dirname(target), { recursive: true });
24
+ writeFileSync(target, Buffer.concat(chunks));
25
+ });
26
+ `;
5
27
 
6
28
  /**
7
29
  * @param {{file_path: string, content?: string, workdir?: string}} params
8
- * @param {{sandboxPolicy?: any, ctx?: any}} [options]
30
+ * @param {{sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
9
31
  */
10
- export async function writeToolImpl({ file_path, content, workdir }, { sandboxPolicy, ctx } = {}) {
32
+ export async function writeToolImpl({ file_path, content, workdir }, { sandboxPolicy, sandboxEngine, ctx } = {}) {
11
33
  const target = resolveToolPath(file_path, workdir, ctx);
12
- if (!isWritablePathAllowed(target, workdir, { sandboxPolicy, ctx })) return `Error: Path not allowed: ${file_path}`;
34
+ const pathOptions = { sandboxPolicy, ctx };
35
+ const protectedTarget = protectedFilesystemTargetPlan(target, { sandboxPolicy, ctx });
36
+ const protectedExecution = protectedTarget !== null;
37
+ if (protectedExecution) {
38
+ if (!isWritablePathLexicallyAllowed(target, workdir, pathOptions)) {
39
+ return "Error: Protected filesystem write was denied.";
40
+ }
41
+ } else if (!isWritablePathAllowed(target, workdir, pathOptions)) {
42
+ return `Error: Path not allowed: ${file_path}`;
43
+ }
13
44
  const bytes = Buffer.byteLength(content || "", "utf8");
14
45
  if (bytes > MAX_WRITE_BYTES) return `Error: Content too large (${bytes} bytes)`;
15
- mkdirSync(dirname(target), { recursive: true });
16
- writeFileSync(target, content || "", "utf8");
46
+ try {
47
+ if (protectedExecution) {
48
+ const protectedResult = await runProtectedFilesystemCommand({
49
+ command: process.execPath,
50
+ args: ["--input-type=commonjs", "--eval", PROTECTED_WRITE_SOURCE, target],
51
+ cwd: protectedTarget.cwd,
52
+ }, { sandboxPolicy, sandboxEngine, ctx, input: content || "" });
53
+ if (!protectedCommandSucceeded(protectedResult)) {
54
+ return "Error: Protected filesystem write was denied.";
55
+ }
56
+ } else {
57
+ mkdirSync(dirname(target), { recursive: true });
58
+ writeFileSync(target, content || "", "utf8");
59
+ }
60
+ } catch {
61
+ return "Error: Protected filesystem write was denied.";
62
+ }
17
63
  return `Successfully wrote ${bytes} bytes to ${target}`;
18
64
  }
@@ -17,6 +17,7 @@ import {
17
17
  ownAcpSessionUpdateKind,
18
18
  sanitizeAcpHostValueWithStatus,
19
19
  } from "./acp-privacy.js";
20
+ import { toolLifecycleMetadata } from "../tool-lifecycle.js";
20
21
 
21
22
  /** @param {any} callback @param {any} event */
22
23
  function emit(callback, event) {
@@ -189,6 +190,9 @@ function normalizeUpdate(update, state) {
189
190
  tool_use_id: publicValue(ownValue(protocolBody, "toolCallId")),
190
191
  content: typeof output === "string" ? output : JSON.stringify(jsonSafe(output)),
191
192
  is_error: ownValue(protocolBody, "status") === "failed",
193
+ tool_lifecycle: toolLifecycleMetadata(ownValue(protocolBody, "status") === "failed"
194
+ ? { state: "error", failure_kind: "runtime_error", detail_code: "acp_tool_failed" }
195
+ : { state: "success" }),
192
196
  }],
193
197
  },
194
198
  });
@@ -26,6 +26,7 @@ import {
26
26
  } from "./claude-sandbox.js";
27
27
  import { resolveSandboxPolicy } from "../../agent/tools/shared/tool-context.js";
28
28
  import { createClaudeSubagentActivityNormalizer } from "./claude-subagent-activity.js";
29
+ import { toolLifecycleMetadata } from "../tool-lifecycle.js";
29
30
 
30
31
  const CODEX_CLI_SANDBOX_POLICY_UNSUPPORTED =
31
32
  "Direct Codex CLI cannot enforce mono-agent's native srt sandbox scopes. Remove the mono-agent sandbox policy or use a Pi runtime for exact readableRoots, writableRoots, denyWrite, and network rules.";
@@ -260,7 +261,8 @@ export function normalizeCliEvent(raw, context = {}) {
260
261
  if (raw.type === "assistant") {
261
262
  return thinkingBuffer ? thinkingBuffer.rehydrate(raw) : raw;
262
263
  }
263
- if (raw.type === "user" || raw.type === "result" || raw.type === "error") return raw;
264
+ if (raw.type === "user") return annotateClaudeCliToolResults(raw);
265
+ if (raw.type === "result" || raw.type === "error") return raw;
264
266
  if (raw.type === "message" && raw.message) return { type: "assistant", message: raw.message };
265
267
  if (raw.type === "item.completed" && raw.item?.type === "agent_message" && typeof raw.item.text === "string") {
266
268
  return { type: "assistant", message: { content: [{ type: "text", text: raw.item.text }] } };
@@ -276,11 +278,42 @@ export function normalizeCliEvent(raw, context = {}) {
276
278
  return { type: "assistant", message: { content: [{ type: "tool_use", id: raw.id, name: raw.name, input: raw.input || raw.arguments }] } };
277
279
  }
278
280
  if (raw.type === "tool_result") {
279
- return { type: "user", message: { content: [{ type: "tool_result", tool_use_id: raw.id || raw.tool_use_id, content: raw.output || raw.result || "" }] } };
281
+ const isError = raw.is_error === true;
282
+ return {
283
+ type: "user",
284
+ message: {
285
+ content: [{
286
+ type: "tool_result",
287
+ tool_use_id: raw.id || raw.tool_use_id,
288
+ content: raw.output || raw.result || "",
289
+ is_error: isError,
290
+ tool_lifecycle: toolLifecycleMetadata(isError
291
+ ? { state: "error", failure_kind: "runtime_error", detail_code: "claude_cli_tool_error" }
292
+ : { state: "success" }),
293
+ }],
294
+ },
295
+ };
280
296
  }
281
297
  return { type: "cli_event", raw };
282
298
  }
283
299
 
300
+ /** Preserve Claude CLI's only trustworthy terminal distinction: tool_result is_error. */
301
+ function annotateClaudeCliToolResults(raw) {
302
+ if (!raw?.message || !Array.isArray(raw.message.content)) return raw;
303
+ let changed = false;
304
+ const content = raw.message.content.map((block) => {
305
+ if (!block || block.type !== "tool_result") return block;
306
+ changed = true;
307
+ return {
308
+ ...block,
309
+ tool_lifecycle: toolLifecycleMetadata(block.is_error === true
310
+ ? { state: "error", failure_kind: "runtime_error", detail_code: "claude_cli_tool_error" }
311
+ : { state: "success" }),
312
+ };
313
+ });
314
+ return changed ? { ...raw, message: { ...raw.message, content } } : raw;
315
+ }
316
+
284
317
  function textFromEvent(raw) {
285
318
  if (typeof raw?.text === "string") return raw.text;
286
319
  if (typeof raw?.item?.text === "string") return raw.item.text;
@@ -19,6 +19,7 @@ import {
19
19
  claudeSandboxPolicyProblem,
20
20
  } from "./claude-sandbox.js";
21
21
  import { createClaudeSubagentActivityNormalizer } from "./claude-subagent-activity.js";
22
+ import { toolLifecycleMetadata } from "../tool-lifecycle.js";
22
23
 
23
24
  const CLAUDE_EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
24
25
  const CLAUDE_SETTING_SOURCES = new Set(["user", "project", "local"]);
@@ -383,6 +384,16 @@ function toolResultText(block) {
383
384
  try { return JSON.stringify(content); } catch { return String(content); }
384
385
  }
385
386
 
387
+ function annotateClaudeToolLifecycles(event) {
388
+ if (event?.type !== "user" || !Array.isArray(event.message?.content)) return;
389
+ for (const block of event.message.content) {
390
+ if (!block || block.type !== "tool_result") continue;
391
+ block.tool_lifecycle = toolLifecycleMetadata(block.is_error === true
392
+ ? { state: "error", failure_kind: "runtime_error", detail_code: "claude_sdk_tool_error" }
393
+ : { state: "success" });
394
+ }
395
+ }
396
+
386
397
  function structuredOutputRejectionFromEvent(event) {
387
398
  if (event?.type !== "user" || !Array.isArray(event.message?.content)) return null;
388
399
  for (const block of event.message.content) {
@@ -666,6 +677,7 @@ export async function generateClaudeResponse(systemPrompt, options) {
666
677
 
667
678
  function emitEvent(event) {
668
679
  if (!event) return;
680
+ annotateClaudeToolLifecycles(event);
669
681
  capturedEvents.push(event);
670
682
  onEvent(event);
671
683
  }
@@ -15,6 +15,7 @@ import {
15
15
  isAllowAllToolPolicy,
16
16
  TOOL_POLICY_ALLOW_ALL_ONLY,
17
17
  } from "../runtime/tool-policy.js";
18
+ import { toolLifecycleMetadata } from "../tool-lifecycle.js";
18
19
 
19
20
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
20
21
  const DEFAULT_THREAD_START_ATTEMPTS = 2;
@@ -535,6 +536,9 @@ function delay(ms, signal) {
535
536
  });
536
537
  }
537
538
 
539
+ // `thread/start.sandbox` selects only the thread-level filesystem class; it
540
+ // does not own network. `sandboxPolicyForRun()` supplies authoritative per-turn
541
+ // networkAccess, per the app-server 0.147.0 / gpt-5.6-sol retained-thread matrix.
538
542
  function sandboxForRun(options) {
539
543
  if (options.codexNoToolsProbe === true) return "read-only";
540
544
  if (options.permissionMode === "bypassPermissions") return "danger-full-access";
@@ -553,11 +557,14 @@ function approvalPolicyForRun(options) {
553
557
  function sandboxPolicyForRun(options) {
554
558
  if (options.codexNoToolsProbe === true) return { type: "readOnly", networkAccess: false };
555
559
  if (options.permissionMode === "bypassPermissions") return { type: "dangerFullAccess" };
556
- if (options.permissionMode === "plan") return { type: "readOnly", networkAccess: false };
560
+ // App-server 0.147.0 / gpt-5.6-sol retained-thread matrix proved per-turn
561
+ // networkAccess authoritative in both directions.
562
+ const networkAccess = options.codexSandboxNetworkAccess === true;
563
+ if (options.permissionMode === "plan") return { type: "readOnly", networkAccess };
557
564
  return {
558
565
  type: "workspaceWrite",
559
566
  writableRoots: [options.cwd || process.cwd()],
560
- networkAccess: false,
567
+ networkAccess,
561
568
  excludeTmpdirEnvVar: false,
562
569
  excludeSlashTmp: false,
563
570
  };
@@ -1064,6 +1071,9 @@ function mapThreadItem(method, item) {
1064
1071
  ...(item.error ? { error: item.error } : {}),
1065
1072
  },
1066
1073
  is_error: item.status === "failed" || Boolean(item.error),
1074
+ tool_lifecycle: toolLifecycleMetadata(item.status === "failed" || Boolean(item.error)
1075
+ ? { state: "error", failure_kind: "runtime_error", detail_code: "codex_collab_failed" }
1076
+ : { state: "success" }),
1067
1077
  }],
1068
1078
  },
1069
1079
  };
@@ -1090,6 +1100,9 @@ function mapThreadItem(method, item) {
1090
1100
  tool_use_id: item.id,
1091
1101
  content: item.contentItems || item.result || item.error || "",
1092
1102
  is_error: item.status === "failed" || item.success === false || Boolean(item.error),
1103
+ tool_lifecycle: toolLifecycleMetadata(item.status === "failed" || item.success === false || Boolean(item.error)
1104
+ ? { state: "error", failure_kind: "runtime_error", detail_code: "codex_dynamic_tool_failed" }
1105
+ : { state: "success" }),
1093
1106
  }],
1094
1107
  },
1095
1108
  };
@@ -14,6 +14,7 @@ import {
14
14
  streamContentKey,
15
15
  } from "../pi-events.js";
16
16
  import { contextUsageFromAssistantMessage } from "./result-builder.js";
17
+ import { classifyPiToolResult, toolLifecycleMetadata } from "../../tool-lifecycle.js";
17
18
 
18
19
  function toolResultFileChange(result) {
19
20
  const fileChange = result?.details?.file_change;
@@ -66,6 +67,7 @@ function toolResultOutcome(result) {
66
67
  * @property {Set<unknown>} textDeltaIndexes
67
68
  * @property {Set<unknown>} thinkingDeltaIndexes
68
69
  * @property {Map<string, number>} toolStartTimes
70
+ * @property {Map<string, any>} toolApprovals
69
71
  * @property {number} turnCount
70
72
  * @property {number} toolResultsSeen
71
73
  * @property {string|null} lastToolName
@@ -128,7 +130,14 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
128
130
  const input = eventToolArgs(event.toolName, event.args, { cwd: options.cwd, toolLimits });
129
131
  onEvent({
130
132
  type: "assistant",
131
- message: { content: [{ type: "tool_use", id: event.toolCallId, name: event.toolName, input }] },
133
+ message: {
134
+ content: [{
135
+ type: "tool_use",
136
+ id: event.toolCallId,
137
+ name: event.toolName,
138
+ input,
139
+ }],
140
+ },
132
141
  });
133
142
  } else if (event.type === "tool_execution_update") {
134
143
  const input = eventToolArgs(event.toolName, event.args, { cwd: options.cwd, toolLimits });
@@ -143,6 +152,12 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
143
152
  const resultContent = toolResultContent(event.result);
144
153
  const fileChange = toolResultFileChange(event.result);
145
154
  const outcome = toolResultOutcome(event.result);
155
+ const classification = classifyPiToolResult({
156
+ result: event.result,
157
+ isError: !!event.isError,
158
+ aborted: options.abortSignal?.aborted === true,
159
+ approval: runState.toolApprovals.get(event.toolCallId),
160
+ });
146
161
  if (!event.isError) runState.toolResultsSeen += 1;
147
162
  const startedAt = runState.toolStartTimes.get(event.toolCallId);
148
163
  if (startedAt !== undefined) {
@@ -154,8 +169,15 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
154
169
  execution_ms: Date.now() - startedAt,
155
170
  is_error: !!event.isError,
156
171
  ...(outcome || {}),
172
+ tool_lifecycle: toolLifecycleMetadata({
173
+ state: classification.state,
174
+ failure_kind: classification.failureKind,
175
+ detail_code: classification.detailCode,
176
+ }),
157
177
  });
158
178
  }
179
+ runState.toolApprovals.delete(event.toolCallId);
180
+ const rawResult = compactToolRawResult(jsonSerializable(event.result, resultContent), resultContent);
159
181
  onEvent({
160
182
  type: "user",
161
183
  message: {
@@ -163,9 +185,14 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
163
185
  type: "tool_result",
164
186
  tool_use_id: event.toolCallId,
165
187
  content: resultContent,
166
- raw_result: compactToolRawResult(jsonSerializable(event.result, resultContent), resultContent),
188
+ raw_result: rawResult,
167
189
  ...(fileChange === null ? {} : { file_change: fileChange }),
168
190
  is_error: !!event.isError,
191
+ tool_lifecycle: toolLifecycleMetadata({
192
+ state: classification.state,
193
+ failure_kind: classification.failureKind,
194
+ detail_code: classification.detailCode,
195
+ }),
169
196
  }],
170
197
  },
171
198
  });
@@ -120,6 +120,7 @@ export async function buildTurnTools(runState, {
120
120
  approvalModel: runtime.model?.id || runtime.model?.name || resolved.model,
121
121
  nodeReplController,
122
122
  webController,
123
+ processJobsController: options.processJobs,
123
124
  toolExecutionMode,
124
125
  subagents: options.subagents,
125
126
  // The child inherits the parent's route and workspace unless its profile
@@ -166,6 +167,8 @@ export async function buildTurnTools(runState, {
166
167
  sandboxPolicy: options.sandboxPolicy,
167
168
  sandboxEngine,
168
169
  ctx: runCtx,
170
+ mcpApps: options.mcpApps,
171
+ runId: runCtx?.runId,
169
172
  }));
170
173
  // Surface MCP init/list failures BOTH to the live event stream and to runtimeWarnings, so a
171
174
  // failed server (e.g. an stdio adapter-send child that closed on startup) lands in the run
@@ -276,6 +276,7 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
276
276
  textDeltaIndexes: new Set(),
277
277
  thinkingDeltaIndexes: new Set(),
278
278
  toolStartTimes: new Map(),
279
+ toolApprovals: new Map(),
279
280
  turnCount: 0,
280
281
  toolResultsSeen: 0,
281
282
  lastToolName: null,
@@ -345,7 +346,12 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
345
346
  let structuredOutputFinalizationRetryFailed = false;
346
347
  const piTransport = resolvePiTransport(options.piTransport);
347
348
 
348
- const onEvent = (event) => emitCaptured(events, options.onEvent, event);
349
+ const onEvent = (event) => {
350
+ if (event?.type === "tool_approval_denied" && typeof event.toolUseId === "string") {
351
+ runState.toolApprovals.set(event.toolUseId, event);
352
+ }
353
+ emitCaptured(events, options.onEvent, event);
354
+ };
349
355
  const approvalRiskTiers = {
350
356
  ...(options.toolRiskTiers || {}),
351
357
  ...(
@@ -9,6 +9,7 @@ export const COMMON_CAPABILITIES = {
9
9
  supports_session_resume: false,
10
10
  native_runtime_config: null,
11
11
  supports_mcp: true,
12
+ supports_mcp_apps: false,
12
13
  supports_skills: true,
13
14
  supports_builtin_tools: true,
14
15
  supports_live_input: true,
@@ -36,6 +37,7 @@ export const RUNTIME_CAPABILITIES = {
36
37
  // tool, so advertise no support rather than letting callers expect it.
37
38
  supports_native_subagents: false,
38
39
  supports_request_tool_environment: true,
40
+ supports_mcp_apps: true,
39
41
  },
40
42
  codex: {
41
43
  runtime: "cli",