@mono-agent/agent-runtime 0.20.11 → 0.21.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 (148) hide show
  1. package/ARCHITECTURE.md +50 -11
  2. package/MIGRATION.md +288 -26
  3. package/README.md +352 -477
  4. package/package.json +13 -44
  5. package/src/agent/tool-bloat.js +145 -9
  6. package/src/agent/tools/agent-tool.js +108 -9
  7. package/src/agent/tools/bash.js +11 -26
  8. package/src/agent/tools/codex-subscription-search.js +123 -29
  9. package/src/agent/tools/exec.js +10 -2
  10. package/src/agent/tools/index.js +7 -0
  11. package/src/agent/tools/monitor.js +149 -0
  12. package/src/agent/tools/pi-bridge.js +123 -19
  13. package/src/agent/tools/shared/bash-environment.js +31 -0
  14. package/src/agent/tools/shared/monitors.js +293 -0
  15. package/src/agent/tools/shared/path-resolver.js +25 -6
  16. package/src/agent/tools/shared/process-jobs.js +6 -1
  17. package/src/agent/tools/shared/process-runner.js +26 -6
  18. package/src/agent/tools/shared/tool-context.js +8 -0
  19. package/src/agent/tools/web-access-interstitial.js +70 -0
  20. package/src/agent/tools/web-browser-render.js +83 -58
  21. package/src/agent/tools/web-controller.js +112 -21
  22. package/src/agent/tools/web-document-extractor.js +379 -0
  23. package/src/agent/tools/web-fetch.js +271 -243
  24. package/src/agent/tools/web-request.js +65 -0
  25. package/src/agent/tools/web-search-output.js +165 -0
  26. package/src/agent/tools/web-search-state.js +75 -0
  27. package/src/agent/tools/web-search.js +532 -71
  28. package/src/ai/cost.js +13 -68
  29. package/src/ai/failure.js +3 -3
  30. package/src/ai/index.js +5 -17
  31. package/src/ai/observer.js +8 -0
  32. package/src/ai/pi-interop.js +221 -1
  33. package/src/ai/pi-oauth-compat.js +1 -1
  34. package/src/ai/provider-check.js +131 -0
  35. package/src/ai/providers/codex/app-server-client.js +592 -0
  36. package/src/ai/providers/pi-models.js +18 -10
  37. package/src/ai/providers/pi-native/compaction-driver.js +94 -42
  38. package/src/ai/providers/pi-native/compaction-summary.js +140 -0
  39. package/src/ai/providers/pi-native/harness-adapter.js +376 -0
  40. package/src/ai/providers/pi-native/prompt-cache-diagnostics.js +103 -0
  41. package/src/ai/providers/pi-native/provider-attribution.js +102 -0
  42. package/src/ai/providers/pi-native/result-builder.js +38 -14
  43. package/src/ai/providers/pi-native/session-lifecycle.js +253 -55
  44. package/src/ai/providers/pi-native/stream-subscriber.js +52 -6
  45. package/src/ai/providers/pi-native/terminal-recovery.js +40 -0
  46. package/src/ai/providers/pi-native/turn-runner.js +279 -28
  47. package/src/ai/providers/pi-native.js +206 -61
  48. package/src/ai/runtime/capabilities.js +11 -56
  49. package/src/ai/runtime/live-input-events.js +250 -54
  50. package/src/ai/runtime/model-refs.js +118 -153
  51. package/src/ai/runtime/registry.js +22 -56
  52. package/src/ai/runtime/router.js +76 -417
  53. package/src/ai/runtime/session-liveness.js +3 -4
  54. package/src/ai/runtime/sessions.js +4 -5
  55. package/src/ai/runtime/tool-policy.js +0 -2
  56. package/src/ai/tool-lifecycle.js +32 -18
  57. package/src/ai/types.js +37 -112
  58. package/src/index.js +0 -6
  59. package/src/runtime.js +29 -16
  60. package/types/agent/tool-bloat.d.ts +1 -1
  61. package/types/agent/tools/agent-tool.d.ts +4 -2
  62. package/types/agent/tools/bash.d.ts +5 -3
  63. package/types/agent/tools/codex-subscription-search.d.ts +7 -3
  64. package/types/agent/tools/exec.d.ts +5 -3
  65. package/types/agent/tools/index.d.ts +1 -0
  66. package/types/agent/tools/monitor.d.ts +47 -0
  67. package/types/agent/tools/pi-bridge.d.ts +7 -4
  68. package/types/agent/tools/shared/bash-environment.d.ts +4 -0
  69. package/types/agent/tools/shared/monitors.d.ts +98 -0
  70. package/types/agent/tools/shared/process-jobs.d.ts +5 -1
  71. package/types/agent/tools/shared/process-runner.d.ts +14 -4
  72. package/types/agent/tools/shared/tool-context.d.ts +2 -0
  73. package/types/agent/tools/web-access-interstitial.d.ts +23 -0
  74. package/types/agent/tools/web-browser-render.d.ts +4 -1
  75. package/types/agent/tools/web-controller.d.ts +4 -2
  76. package/types/agent/tools/web-document-extractor.d.ts +27 -0
  77. package/types/agent/tools/web-fetch.d.ts +19 -24
  78. package/types/agent/tools/web-request.d.ts +20 -0
  79. package/types/agent/tools/web-search-output.d.ts +31 -0
  80. package/types/agent/tools/web-search-state.d.ts +21 -0
  81. package/types/agent/tools/web-search.d.ts +10 -45
  82. package/types/ai/cost.d.ts +1 -2
  83. package/types/ai/index.d.ts +2 -4
  84. package/types/ai/observer.d.ts +6 -0
  85. package/types/ai/pi-interop.d.ts +81 -0
  86. package/types/ai/provider-check.d.ts +53 -0
  87. package/types/ai/providers/codex/app-server-client.d.ts +37 -0
  88. package/types/ai/providers/pi-native/compaction-driver.d.ts +2 -1
  89. package/types/ai/providers/pi-native/compaction-summary.d.ts +19 -0
  90. package/types/ai/providers/pi-native/harness-adapter.d.ts +58 -0
  91. package/types/ai/providers/pi-native/prompt-cache-diagnostics.d.ts +3 -0
  92. package/types/ai/providers/pi-native/provider-attribution.d.ts +26 -0
  93. package/types/ai/providers/pi-native/result-builder.d.ts +14 -4
  94. package/types/ai/providers/pi-native/session-lifecycle.d.ts +25 -6
  95. package/types/ai/providers/pi-native/stream-subscriber.d.ts +2 -2
  96. package/types/ai/providers/pi-native/terminal-recovery.d.ts +2 -0
  97. package/types/ai/providers/pi-native/turn-runner.d.ts +68 -10
  98. package/types/ai/providers/pi-native.d.ts +21 -4
  99. package/types/ai/runtime/capabilities.d.ts +21 -70
  100. package/types/ai/runtime/live-input-events.d.ts +32 -8
  101. package/types/ai/runtime/model-refs.d.ts +0 -24
  102. package/types/ai/runtime/router.d.ts +3 -10
  103. package/types/ai/runtime/tool-policy.d.ts +0 -2
  104. package/types/ai/tool-lifecycle.d.ts +4 -3
  105. package/types/ai/types.d.ts +162 -256
  106. package/types/index.d.ts +0 -1
  107. package/src/ai/providers/acp-client.js +0 -1149
  108. package/src/ai/providers/acp-privacy.js +0 -124
  109. package/src/ai/providers/acp-public.js +0 -21
  110. package/src/ai/providers/acp-session-tokens.js +0 -282
  111. package/src/ai/providers/acp-transport.js +0 -356
  112. package/src/ai/providers/acp.js +0 -543
  113. package/src/ai/providers/claude-cli.js +0 -883
  114. package/src/ai/providers/claude-sandbox.js +0 -71
  115. package/src/ai/providers/claude-sdk-discovery-worker.js +0 -53
  116. package/src/ai/providers/claude-sdk-discovery.js +0 -352
  117. package/src/ai/providers/claude-sdk.js +0 -1127
  118. package/src/ai/providers/claude-subagent-activity.js +0 -719
  119. package/src/ai/providers/claude-subagents.js +0 -88
  120. package/src/ai/providers/codex-app.js +0 -2946
  121. package/src/ai/providers/opencode-app.js +0 -1109
  122. package/src/ai/providers/opencode-discovery.js +0 -39
  123. package/src/ai/providers/opencode-server.js +0 -508
  124. package/src/ai/runtime/context-windows.js +0 -46
  125. package/src/ai/runtime/fast-mode.js +0 -8
  126. package/src/ai/streaming/codex-events.js +0 -146
  127. package/src/ai/streaming/opencode-events.js +0 -59
  128. package/types/ai/providers/acp-client.d.ts +0 -227
  129. package/types/ai/providers/acp-privacy.d.ts +0 -25
  130. package/types/ai/providers/acp-public.d.ts +0 -7
  131. package/types/ai/providers/acp-session-tokens.d.ts +0 -41
  132. package/types/ai/providers/acp-transport.d.ts +0 -45
  133. package/types/ai/providers/acp.d.ts +0 -93
  134. package/types/ai/providers/claude-cli.d.ts +0 -305
  135. package/types/ai/providers/claude-sandbox.d.ts +0 -79
  136. package/types/ai/providers/claude-sdk-discovery-worker.d.ts +0 -1
  137. package/types/ai/providers/claude-sdk-discovery.d.ts +0 -97
  138. package/types/ai/providers/claude-sdk.d.ts +0 -138
  139. package/types/ai/providers/claude-subagent-activity.d.ts +0 -53
  140. package/types/ai/providers/claude-subagents.d.ts +0 -18
  141. package/types/ai/providers/codex-app.d.ts +0 -151
  142. package/types/ai/providers/opencode-app.d.ts +0 -96
  143. package/types/ai/providers/opencode-discovery.d.ts +0 -4
  144. package/types/ai/providers/opencode-server.d.ts +0 -20
  145. package/types/ai/runtime/context-windows.d.ts +0 -9
  146. package/types/ai/runtime/fast-mode.d.ts +0 -2
  147. package/types/ai/streaming/codex-events.d.ts +0 -40
  148. package/types/ai/streaming/opencode-events.d.ts +0 -42
@@ -4,12 +4,14 @@ import { mkdtemp, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
 
7
- import { createCodexAppServerClient } from "../../ai/providers/codex-app.js";
7
+ import { createCodexAppServerClient } from "../../ai/providers/codex/app-server-client.js";
8
+ import { boundWebSearchSnippet, sliceWellFormedCodePoints, toWellFormedText } from "./web-search-output.js";
8
9
 
9
10
  export const DEFAULT_CODEX_SEARCH_MODEL = "gpt-5.6-luna";
10
11
 
11
12
  const REQUEST_TIMEOUT_MS = 30_000;
12
- const IDLE_CLOSE_MS = 1_000;
13
+ const IDLE_CLOSE_MS = 30_000;
14
+ let quotaSnapshot;
13
15
  const MAX_MODEL_PAGES = 10;
14
16
  const MAX_RESULTS = 100;
15
17
  const SEARCH_ONLY_INSTRUCTIONS = [
@@ -59,16 +61,23 @@ export async function inspectCodexSubscriptionSearch(options = {}) {
59
61
  * turns or cross-wire app-server notifications between requests.
60
62
  *
61
63
  * @param {string} query
62
- * @param {{model?: string, signal?: AbortSignal, clientFactory?: typeof createCodexAppServerClient}} [options]
64
+ * @param {{model?: string, signal?: AbortSignal, clientFactory?: typeof createCodexAppServerClient, coordinator?: any, language?: string, timeRange?: string, claimRequest?: () => void}} [options]
63
65
  */
64
66
  export function searchCodexSubscription(query, options = {}) {
65
- return enqueue(async () => {
67
+ let executing = false;
68
+ const pending = enqueue(async () => {
69
+ executing = true;
66
70
  if (options.signal?.aborted) return abortedResult();
67
71
  const model = normalizeModel(options.model);
68
72
  let current;
69
73
  try {
70
- current = await getBroker(model, options.clientFactory);
71
- const result = await runSearch(current, query, model, options.signal);
74
+ current = await getBroker(model, options.clientFactory, options.signal);
75
+ options.signal?.throwIfAborted();
76
+ await checkQuota(current, options.coordinator, options.signal);
77
+ options.signal?.throwIfAborted();
78
+ const quotaBefore = quotaSnapshot?.checkedAt;
79
+ const result = await runSearch(current, query, model, options.signal, options);
80
+ if (quotaSnapshot && quotaSnapshot.checkedAt !== quotaBefore) await options.coordinator?.writeQuota(quotaSnapshot.value);
72
81
  scheduleIdleClose();
73
82
  return result;
74
83
  } catch (error) {
@@ -78,9 +87,70 @@ export function searchCodexSubscription(query, options = {}) {
78
87
  backend: "codex",
79
88
  message: `Codex subscription search unavailable: ${publicReason(error)}`,
80
89
  retryable: isRetryable(error),
90
+ code: options.signal?.aborted ? (options.signal.reason?.code === "deadline_exceeded" ? "deadline_exceeded" : "aborted") : error?.code,
91
+ quotaSkipped: ["quota_reserved", "quota_unavailable"].includes(error?.code),
92
+ retryAfterMs: error?.retryAfterMs,
81
93
  };
82
94
  }
83
95
  });
96
+ return abortable(pending, options.signal, () => executing);
97
+ }
98
+
99
+ function abortable(pending, signal, executing) {
100
+ if (!signal) return pending;
101
+ return new Promise((resolve, reject) => {
102
+ const aborted = () => { if (!executing()) reject(signal.reason || Object.assign(new Error("WebSearch was aborted."), { name: "AbortError" })); };
103
+ if (signal.aborted) aborted();
104
+ else signal.addEventListener("abort", aborted, { once: true });
105
+ pending.then(resolve, reject).finally(() => signal.removeEventListener("abort", aborted));
106
+ });
107
+ }
108
+
109
+ async function checkQuota(current, coordinator, signal) {
110
+ const shared = coordinator ? await coordinator.readQuota() : quotaSnapshot;
111
+ let snapshot = shared;
112
+ if (!snapshot || Date.now() - snapshot.checkedAt > 60_000 || snapshot.checkedAt > Date.now()) {
113
+ let response;
114
+ try { response = await searchRequest(current.client, "account/rateLimits/read", {}, { timeoutMs: 5000 }, signal); }
115
+ catch { throw Object.assign(new Error("Codex quota information is unavailable."), { code: "quota_unavailable" }); }
116
+ snapshot = { checkedAt: Date.now(), value: quotaValue(response) };
117
+ quotaSnapshot = snapshot;
118
+ await coordinator?.writeQuota(snapshot.value);
119
+ }
120
+ quotaSnapshot = snapshot;
121
+ const windows = snapshot.value?.windows;
122
+ if (!Array.isArray(windows) || windows.length === 0 || !windows.every((w) =>
123
+ Number.isFinite(w.usedPercent) && w.usedPercent >= 0 && w.usedPercent <= 100
124
+ && Number.isSafeInteger(w.resetsAt) && w.resetsAt * 1000 > Date.now())) {
125
+ throw Object.assign(new Error("Codex quota information is unavailable."), { code: "quota_unavailable" });
126
+ }
127
+ const reserved = windows.filter((w) => w.usedPercent >= 90);
128
+ if (reserved.length) throw Object.assign(new Error("Codex search preserves the remaining subscription allowance."), {
129
+ code: "quota_reserved", retryAfterMs: Math.max(...reserved.map((w) => w.resetsAt * 1000 - Date.now())),
130
+ });
131
+ }
132
+
133
+ function quotaValue(response) {
134
+ const bucket = response?.rateLimitsByLimitId?.codex ?? response?.rateLimits;
135
+ return { windows: [bucket?.primary, bucket?.secondary].filter(Boolean).map((w) => ({ usedPercent: w.usedPercent, resetsAt: w.resetsAt })) };
136
+ }
137
+
138
+ // Close the transport before releasing admission on abort, including startup
139
+ // and thread/start. A queued or late request cannot launch a new search turn.
140
+ async function searchRequest(client, method, params, options, signal) {
141
+ signal?.throwIfAborted();
142
+ if (!signal) return await client.request(method, params, options);
143
+ let abort;
144
+ const cancelled = new Promise((_, reject) => {
145
+ abort = () => {
146
+ Promise.resolve(client.close()).then(
147
+ () => reject(signal.reason), () => reject(signal.reason),
148
+ );
149
+ };
150
+ signal.addEventListener("abort", abort, { once: true });
151
+ });
152
+ try { return await Promise.race([client.request(method, params, options), cancelled]); }
153
+ finally { signal.removeEventListener("abort", abort); }
84
154
  }
85
155
 
86
156
  function enqueue(task) {
@@ -89,14 +159,16 @@ function enqueue(task) {
89
159
  return result;
90
160
  }
91
161
 
92
- async function getBroker(model, clientFactory) {
162
+ async function getBroker(model, clientFactory, signal) {
93
163
  clearIdleTimer();
94
164
  if (broker?.models.has(model)) return broker;
95
165
  if (broker && !broker.models.has(model)) await closeBroker();
96
166
  if (!brokerOpening) {
97
167
  brokerOpening = (async () => {
98
- const owned = await openBroker(clientFactory);
99
- const ready = await inspectClient(owned.client, model);
168
+ const owned = await openBroker(clientFactory, signal);
169
+ let ready;
170
+ try { ready = await inspectClient(owned.client, model, signal); }
171
+ catch (error) { await closeOwnedBroker(owned); throw error; }
100
172
  if (!ready.ok) {
101
173
  await closeOwnedBroker(owned);
102
174
  throw new Error(ready.reason);
@@ -109,7 +181,7 @@ async function getBroker(model, clientFactory) {
109
181
  return await brokerOpening;
110
182
  }
111
183
 
112
- async function openBroker(clientFactory = createCodexAppServerClient) {
184
+ async function openBroker(clientFactory = createCodexAppServerClient, signal) {
113
185
  const directory = await mkdtemp(join(tmpdir(), "mono-agent-codex-search-"));
114
186
  /** @type {{handler: (message: any) => void}} */
115
187
  const target = { handler: () => {} };
@@ -117,16 +189,19 @@ async function openBroker(clientFactory = createCodexAppServerClient) {
117
189
  try {
118
190
  client = clientFactory({
119
191
  cwd: directory,
120
- onNotification: (message) => target.handler(message),
192
+ onNotification: (message) => {
193
+ if (message?.method === "account/rateLimits/updated") quotaSnapshot = { checkedAt: Date.now(), value: quotaValue(message.params) };
194
+ target.handler(message);
195
+ },
121
196
  onServerRequest: (message) => {
122
197
  target.handler(message);
123
198
  throw new Error("Codex subscription search rejected an unexpected server request.");
124
199
  },
125
200
  });
126
- await client.request("initialize", {
201
+ await searchRequest(client, "initialize", {
127
202
  clientInfo: { name: "mono-agent-web-search", title: "mono-agent WebSearch", version: "0" },
128
203
  capabilities: { experimentalApi: true },
129
- }, { timeoutMs: REQUEST_TIMEOUT_MS });
204
+ }, { timeoutMs: REQUEST_TIMEOUT_MS }, signal);
130
205
  return { client, directory, models: new Set(), target };
131
206
  } catch (error) {
132
207
  await Promise.resolve(client?.close?.()).catch(() => {});
@@ -135,8 +210,8 @@ async function openBroker(clientFactory = createCodexAppServerClient) {
135
210
  }
136
211
  }
137
212
 
138
- async function inspectClient(client, model) {
139
- const account = await client.request("account/read", { refreshToken: false }, { timeoutMs: REQUEST_TIMEOUT_MS });
213
+ async function inspectClient(client, model, signal) {
214
+ const account = await searchRequest(client, "account/read", { refreshToken: false }, { timeoutMs: REQUEST_TIMEOUT_MS }, signal);
140
215
  if (account?.account?.type !== "chatgpt") {
141
216
  return {
142
217
  ok: false,
@@ -146,10 +221,10 @@ async function inspectClient(client, model) {
146
221
  models: new Set(),
147
222
  };
148
223
  }
149
- const capabilities = await client.request(
224
+ const capabilities = await searchRequest(client,
150
225
  "modelProvider/capabilities/read",
151
226
  {},
152
- { timeoutMs: REQUEST_TIMEOUT_MS },
227
+ { timeoutMs: REQUEST_TIMEOUT_MS }, signal,
153
228
  );
154
229
  if (capabilities?.webSearch !== true) {
155
230
  return {
@@ -160,7 +235,7 @@ async function inspectClient(client, model) {
160
235
  models: new Set(),
161
236
  };
162
237
  }
163
- const models = await readModels(client);
238
+ const models = await readModels(client, signal);
164
239
  if (!models.has(model)) {
165
240
  return {
166
241
  ok: false,
@@ -179,15 +254,15 @@ async function inspectClient(client, model) {
179
254
  };
180
255
  }
181
256
 
182
- async function readModels(client) {
257
+ async function readModels(client, signal) {
183
258
  const models = new Set();
184
259
  let cursor = null;
185
260
  for (let page = 0; page < MAX_MODEL_PAGES; page += 1) {
186
- const response = await client.request("model/list", {
261
+ const response = await searchRequest(client, "model/list", {
187
262
  includeHidden: false,
188
263
  limit: 100,
189
264
  ...(cursor === null ? {} : { cursor }),
190
- }, { timeoutMs: REQUEST_TIMEOUT_MS });
265
+ }, { timeoutMs: REQUEST_TIMEOUT_MS }, signal);
191
266
  if (!Array.isArray(response?.data)) throw new Error("Codex returned an invalid model catalog.");
192
267
  for (const row of response.data) {
193
268
  if (typeof row?.id === "string" && row.id.trim()) models.add(row.id.trim());
@@ -198,7 +273,8 @@ async function readModels(client) {
198
273
  throw new Error("Codex model catalog exceeded the pagination bound.");
199
274
  }
200
275
 
201
- async function runSearch(current, query, model, signal) {
276
+ async function runSearch(current, query, model, signal, preferences = {}) {
277
+ signal?.throwIfAborted();
202
278
  const state = /** @type {any} */ ({
203
279
  threadId: "",
204
280
  turnId: "",
@@ -221,7 +297,7 @@ async function runSearch(current, query, model, signal) {
221
297
  };
222
298
  signal?.addEventListener?.("abort", onAbort, { once: true });
223
299
  try {
224
- const thread = await current.client.request("thread/start", {
300
+ const thread = await searchRequest(current.client, "thread/start", {
225
301
  model,
226
302
  modelProvider: "openai",
227
303
  allowProviderModelFallback: false,
@@ -234,17 +310,21 @@ async function runSearch(current, query, model, signal) {
234
310
  project_doc_max_bytes: 0,
235
311
  mcp_servers: {},
236
312
  },
237
- developerInstructions: SEARCH_ONLY_INSTRUCTIONS,
313
+ developerInstructions: SEARCH_ONLY_INSTRUCTIONS + (
314
+ preferences.language || preferences.timeRange
315
+ ? ` Search preferences (keep query text unchanged): language=${JSON.stringify(preferences.language || "default")}; time range=${JSON.stringify(preferences.timeRange || "any")}. Use supported search filters; do not invent dates.` : ""
316
+ ),
238
317
  ephemeral: true,
239
318
  sessionStartSource: "startup",
240
319
  environments: [],
241
320
  dynamicTools: [],
242
321
  selectedCapabilityRoots: [],
243
322
  experimentalRawEvents: false,
244
- }, { timeoutMs: REQUEST_TIMEOUT_MS });
323
+ }, { timeoutMs: REQUEST_TIMEOUT_MS }, signal);
245
324
  state.threadId = thread?.thread?.id || "";
246
325
  if (!state.threadId) throw new Error("Codex did not return a search thread id.");
247
- const turn = await current.client.request("turn/start", {
326
+ preferences.claimRequest?.();
327
+ const turn = await searchRequest(current.client, "turn/start", {
248
328
  threadId: state.threadId,
249
329
  input: [{ type: "text", text: String(query), text_elements: [] }],
250
330
  cwd: current.directory,
@@ -255,7 +335,7 @@ async function runSearch(current, query, model, signal) {
255
335
  effort: "low",
256
336
  summary: "none",
257
337
  environments: [],
258
- }, { timeoutMs: REQUEST_TIMEOUT_MS });
338
+ }, { timeoutMs: REQUEST_TIMEOUT_MS }, signal);
259
339
  state.turnId = turn?.turn?.id || state.turnId;
260
340
  if (state.violation && state.turnId) {
261
341
  await current.client.request("turn/interrupt", {
@@ -276,6 +356,9 @@ async function runSearch(current, query, model, signal) {
276
356
  if (actualQuery !== String(query)) {
277
357
  throw new Error("Codex changed the exact web search query.");
278
358
  }
359
+ if (!Array.isArray(item.results) || item.results.some((row) => !row || typeof row.url !== "string" || !validResultUrl(row.url))) {
360
+ throw new Error("Codex returned malformed structured search results.");
361
+ }
279
362
  const results = normalizeResults(item.results);
280
363
  return {
281
364
  ok: true,
@@ -329,15 +412,24 @@ function handleNotification(message, state, client) {
329
412
  }
330
413
  }
331
414
 
415
+ function validResultUrl(value) {
416
+ try {
417
+ const url = new URL(value);
418
+ return ["http:", "https:"].includes(url.protocol) && Boolean(url.hostname) && !url.username && !url.password;
419
+ } catch { return false; }
420
+ }
421
+
332
422
  function normalizeResults(rows) {
333
423
  if (!Array.isArray(rows)) return [];
334
424
  const results = [];
335
425
  for (const row of rows) {
336
426
  if (!row || typeof row !== "object" || typeof row.url !== "string") continue;
427
+ const snippet = boundWebSearchSnippet(row.snippet);
337
428
  results.push({
338
429
  title: boundedText(row.title, 500),
339
430
  url: row.url,
340
- snippet: boundedText(row.snippet, 4_000),
431
+ snippet: snippet.text,
432
+ snippetTruncated: snippet.truncated,
341
433
  provenance: boundedText(row.domain || row.ref_id || row.type, 300),
342
434
  backend: "codex",
343
435
  });
@@ -391,7 +483,8 @@ function normalizeModel(value) {
391
483
  }
392
484
 
393
485
  function boundedText(value, max) {
394
- return typeof value === "string" ? value.replace(/\s+/gu, " ").trim().slice(0, max) : "";
486
+ const text = typeof value === "string" ? toWellFormedText(value).replace(/\s+/gu, " ").trim() : "";
487
+ return sliceWellFormedCodePoints(text, max);
395
488
  }
396
489
 
397
490
  function safeReason(error) {
@@ -430,6 +523,7 @@ function abortedResult() {
430
523
 
431
524
  /** Test hook for process-shared broker state. */
432
525
  export async function __resetCodexSubscriptionSearchForTests() {
526
+ quotaSnapshot = undefined;
433
527
  await enqueue(async () => { await closeBroker(); });
434
528
  brokerOpening = null;
435
529
  }
@@ -25,7 +25,7 @@ const MAX_EXEC_ARGS = 256;
25
25
  /** @typedef {import("./shared/process-jobs.js").ProcessJobsController} ProcessJobsController */
26
26
 
27
27
  /**
28
- * @param {{executable: string, args?: string[], workdir?: string, description?: string, timeout_ms?: number, max_output_chars?: number, background?: boolean}} params
28
+ * @param {{executable: string, args?: string[], workdir?: string, description?: string, timeout_ms?: number, max_output_chars?: number, background?: boolean, wake_on_completion?: boolean}} params
29
29
  * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, processJobsController?: ProcessJobsController}} [options]
30
30
  */
31
31
  export async function execToolImpl(params, options = {}) {
@@ -35,7 +35,7 @@ export async function execToolImpl(params, options = {}) {
35
35
  /**
36
36
  * Execute an argv vector directly, without shell parsing.
37
37
  *
38
- * @param {{executable: string, args?: string[], workdir?: string, description?: string, timeout_ms?: number, max_output_chars?: number, background?: boolean}} params
38
+ * @param {{executable: string, args?: string[], workdir?: string, description?: string, timeout_ms?: number, max_output_chars?: number, background?: boolean, wake_on_completion?: boolean}} params
39
39
  * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, processJobsController?: ProcessJobsController}} [options]
40
40
  */
41
41
  export async function execToolRun(
@@ -47,6 +47,7 @@ export async function execToolRun(
47
47
  timeout_ms,
48
48
  max_output_chars,
49
49
  background,
50
+ wake_on_completion,
50
51
  },
51
52
  {
52
53
  signal,
@@ -57,6 +58,12 @@ export async function execToolRun(
57
58
  } = {},
58
59
  ) {
59
60
  const startedAt = Date.now();
61
+ if (wake_on_completion !== undefined && (background !== true || typeof wake_on_completion !== "boolean")) {
62
+ return failed("Error: wake_on_completion requires background=true and a boolean value.", "process_job_invalid", startedAt);
63
+ }
64
+ if (background === true && !processJobsController) {
65
+ return failed("Error: Background process jobs are unavailable for this request.", "background_unsupported", startedAt);
66
+ }
60
67
  const executableProblem = validateExecutable(executable);
61
68
  if (executableProblem) return failed(executableProblem, "invalid_executable", startedAt);
62
69
  const argsProblem = validateArgs(args);
@@ -103,6 +110,7 @@ export async function execToolRun(
103
110
  prepared,
104
111
  summary: `Exec command (${args.length} argument${args.length === 1 ? "" : "s"}; values redacted)`,
105
112
  description,
113
+ wakeOnCompletion: wake_on_completion,
106
114
  // Re-derived from the raw param: `timeoutMs` carries the foreground
107
115
  // ceiling, and a background job is bounded by processJobs instead.
108
116
  timeoutMs: timeout_ms === undefined ? undefined : normalizeBackgroundTimeoutMs(timeout_ms),
@@ -18,6 +18,13 @@ export {
18
18
  normalizeProcessTimeoutMs,
19
19
  } from "./bash.js";
20
20
  export { execToolImpl, execToolRun } from "./exec.js";
21
+ export {
22
+ DEFAULT_MONITOR_TIMEOUT_MS,
23
+ MIN_MONITOR_TIMEOUT_MS,
24
+ monitorStopToolRun,
25
+ monitorToolRun,
26
+ normalizeMonitorTimeoutMs,
27
+ } from "./monitor.js";
21
28
  export { webFetchToolImpl, performWebFetch } from "./web-fetch.js";
22
29
  export { webSearchToolImpl, performWebSearch } from "./web-search.js";
23
30
  export {
@@ -0,0 +1,149 @@
1
+ // @ts-check
2
+
3
+ import { existsSync } from "node:fs";
4
+ import { passthroughSandbox } from "../sandbox-seam.js";
5
+ import {
6
+ isPathAllowed,
7
+ isWorkdirAllowed,
8
+ workspaceRoot,
9
+ } from "./shared/path-resolver.js";
10
+ import { handOffMonitor, handOffMonitorStop } from "./shared/monitors.js";
11
+ import { readToolRuntime } from "./shared/runtime-context.js";
12
+ import { requestToolProcessEnvironment, resolveSandboxPolicy } from "./shared/tool-context.js";
13
+ import { cleanBashEnvironment } from "./shared/bash-environment.js";
14
+
15
+ /** Claude-Code-compatible defaults: 5 minutes, never below one second. */
16
+ export const DEFAULT_MONITOR_TIMEOUT_MS = 300_000;
17
+ export const MIN_MONITOR_TIMEOUT_MS = 1_000;
18
+
19
+ /**
20
+ * Normalize `timeout_ms` for a monitor. Unlike Bash there is no
21
+ * seconds-vs-milliseconds heuristic — this field is new, so it is exact
22
+ * milliseconds only. `undefined`/invalid falls back to the documented default;
23
+ * the host's own ceiling clamps the result on its side and reports what it
24
+ * actually granted.
25
+ */
26
+ export function normalizeMonitorTimeoutMs(value, fallback = DEFAULT_MONITOR_TIMEOUT_MS) {
27
+ const number = Number(value);
28
+ if (!Number.isFinite(number) || number <= 0) return fallback;
29
+ return Math.max(MIN_MONITOR_TIMEOUT_MS, Math.floor(number));
30
+ }
31
+
32
+ /**
33
+ * Structured Monitor execution used by the Pi bridge.
34
+ *
35
+ * Command preparation is deliberately byte-identical to Bash: the same
36
+ * `/bin/bash --noprofile --norc -c` shape, the same workdir rules, the same
37
+ * cleaned startup environment, and the same sandbox `prepareCommand` seam. A
38
+ * monitor must never be a way to run a command Bash could not.
39
+ *
40
+ * @param {{command?: string, description?: string, timeout_ms?: number, persistent?: boolean, workdir?: string, wake_on?: "batch"|"exit", dedupe?: "none"|"batch", min_wake_interval_ms?: number}} params
41
+ * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, monitorsController?: import("./shared/monitors.js").MonitorsController}} [options]
42
+ */
43
+ export async function monitorToolRun(
44
+ { command, description, timeout_ms, persistent, workdir, wake_on = "batch", dedupe = "none", min_wake_interval_ms = 0 },
45
+ { sandboxPolicy, sandboxEngine, ctx, monitorsController } = {},
46
+ ) {
47
+ const startedAt = Date.now();
48
+ if (!monitorsController) {
49
+ return failed("Error: Monitors are unsupported for this tool call.", "monitor_unsupported", startedAt);
50
+ }
51
+ if (typeof command !== "string") {
52
+ return failed("Error: Monitor command must be a string.", "monitor_invalid", startedAt);
53
+ }
54
+ if (command.includes("\0")) {
55
+ return failed("Error: Monitor command must not contain NUL characters.", "monitor_invalid", startedAt);
56
+ }
57
+ if (typeof description !== "string" || description.trim().length === 0) {
58
+ return failed("Error: Monitor description is required.", "monitor_invalid", startedAt);
59
+ }
60
+ if (!["batch", "exit"].includes(wake_on)
61
+ || !["none", "batch"].includes(dedupe)
62
+ || !Number.isSafeInteger(min_wake_interval_ms) || min_wake_interval_ms < 0
63
+ || (wake_on === "exit" && (dedupe !== "none" || min_wake_interval_ms !== 0))) {
64
+ return failed("Error: Invalid Monitor wake policy; exit-only requires dedupe none and interval 0.", "monitor_invalid", startedAt);
65
+ }
66
+ const resolvedCtx = ctx ?? readToolRuntime();
67
+ const sandbox = resolvedCtx.sandbox ?? passthroughSandbox;
68
+ const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
69
+ const pathOptions = { sandboxPolicy: policy, ctx: resolvedCtx };
70
+ if (workdir && !isWorkdirAllowed(workdir, pathOptions)) {
71
+ return failed(`Error: Working directory not allowed: ${workdir}`, "workdir_denied", startedAt);
72
+ }
73
+ const cwd = workspaceRoot(workdir, resolvedCtx);
74
+ if (!isPathAllowed(cwd, workdir, pathOptions)) {
75
+ return failed(`Error: Working directory not allowed: ${cwd}`, "workdir_denied", startedAt);
76
+ }
77
+ if (!existsSync(cwd)) {
78
+ return failed(`Error: Working directory not found: ${cwd}`, "workdir_not_found", startedAt);
79
+ }
80
+
81
+ let prepared;
82
+ try {
83
+ prepared = await sandbox.prepareCommand({
84
+ policy,
85
+ engine: sandboxEngine ?? resolvedCtx.sandboxEngine ?? undefined,
86
+ command: {
87
+ command: "/bin/bash",
88
+ args: ["--noprofile", "--norc", "-c", command],
89
+ cwd,
90
+ env: requestToolProcessEnvironment(resolvedCtx, cleanBashEnvironment()),
91
+ },
92
+ });
93
+ } catch (error) {
94
+ return failed(`Error: ${error?.message || String(error)}`, "sandbox_prepare_failed", startedAt);
95
+ }
96
+
97
+ const isPersistent = persistent === true;
98
+ return await handOffMonitor({
99
+ controller: monitorsController,
100
+ prepared,
101
+ summary: `Monitor command (${command.length} characters; content redacted)`,
102
+ description,
103
+ // A persistent monitor has no timed deadline; sending one anyway would let
104
+ // an ignored field look honoured in the durable record.
105
+ ...(isPersistent ? {} : { timeoutMs: normalizeMonitorTimeoutMs(timeout_ms) }),
106
+ persistent: isPersistent,
107
+ wakeOn: wake_on,
108
+ dedupe,
109
+ minWakeIntervalMs: min_wake_interval_ms,
110
+ startedAt,
111
+ failed,
112
+ });
113
+ }
114
+
115
+ /**
116
+ * @param {{monitor_id?: string}} params
117
+ * @param {{monitorsController?: import("./shared/monitors.js").MonitorsController}} [options]
118
+ */
119
+ export async function monitorStopToolRun({ monitor_id }, { monitorsController } = {}) {
120
+ const startedAt = Date.now();
121
+ if (!monitorsController) {
122
+ return failed("Error: Monitors are unsupported for this tool call.", "monitor_unsupported", startedAt);
123
+ }
124
+ return await handOffMonitorStop({
125
+ controller: monitorsController,
126
+ monitorId: monitor_id,
127
+ startedAt,
128
+ failed,
129
+ });
130
+ }
131
+
132
+ function failed(text, code, startedAt) {
133
+ return {
134
+ text,
135
+ outcome: {
136
+ status: "error",
137
+ code,
138
+ retryable: false,
139
+ attempts: 1,
140
+ durationMs: Date.now() - startedAt,
141
+ bytes: 0,
142
+ truncated: false,
143
+ exitCode: null,
144
+ signal: null,
145
+ timedOut: false,
146
+ },
147
+ error: true,
148
+ };
149
+ }