@ordewell/core 0.9.0 → 0.9.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.
package/dist/index.js CHANGED
@@ -59,7 +59,9 @@ __export(index_exports, {
59
59
  DEFAULT_MAX_PARALLEL: () => DEFAULT_MAX_PARALLEL,
60
60
  DEFAULT_PLANNER_ALLOWLIST: () => DEFAULT_PLANNER_ALLOWLIST,
61
61
  DEFAULT_RUNNERS: () => DEFAULT_RUNNERS,
62
+ DEFAULT_WEB_READ_BUDGET: () => DEFAULT_WEB_READ_BUDGET,
62
63
  DENY_ALL: () => DENY_ALL,
64
+ DuckDuckGoSearchProvider: () => DuckDuckGoSearchProvider,
63
65
  EMPTY_CONVERSATION: () => EMPTY_CONVERSATION,
64
66
  EMPTY_HOLD: () => EMPTY_HOLD,
65
67
  EMPTY_TASK_LOG: () => EMPTY_TASK_LOG,
@@ -71,6 +73,7 @@ __export(index_exports, {
71
73
  GREP_PCRE_PROBE_ARGS: () => GREP_PCRE_PROBE_ARGS,
72
74
  GeminiService: () => GeminiService,
73
75
  HARNESS_PLANNER_REASON: () => HARNESS_PLANNER_REASON,
76
+ HttpWebFetcher: () => HttpWebFetcher,
74
77
  JSON_REPAIR_INSTRUCTION: () => JSON_REPAIR_INSTRUCTION,
75
78
  LineBuffer: () => LineBuffer,
76
79
  ModelCatalog: () => ModelCatalog,
@@ -137,6 +140,7 @@ __export(index_exports, {
137
140
  TaskOrchestrator: () => TaskOrchestrator,
138
141
  VerdictEngine: () => VerdictEngine,
139
142
  WINDOWS_MAX_COMMAND_LINE: () => WINDOWS_MAX_COMMAND_LINE,
143
+ WebResponseCache: () => WebResponseCache,
140
144
  WorkspaceNotAProjectError: () => WorkspaceNotAProjectError,
141
145
  WorkspaceNotFoundError: () => WorkspaceNotFoundError,
142
146
  abbreviateHome: () => abbreviateHome,
@@ -278,6 +282,7 @@ __export(index_exports, {
278
282
  loadSessionPlanState: () => loadSessionPlanState,
279
283
  loadState: () => loadState,
280
284
  loadedSkillTokens: () => loadedSkillTokens,
285
+ looksLikeChallenge: () => looksLikeChallenge,
281
286
  looksLikePlanAttempt: () => looksLikePlanAttempt,
282
287
  mapAgentTool: () => mapAgentTool,
283
288
  markAction: () => markAction,
@@ -310,6 +315,7 @@ __export(index_exports, {
310
315
  parseMaxParallel: () => parseMaxParallel,
311
316
  parsePartialPlan: () => parsePartialPlan,
312
317
  parsePlanJson: () => parsePlanJson,
318
+ parseSearchHits: () => parseSearchHits,
313
319
  parseTaskOpsJson: () => parseTaskOpsJson,
314
320
  parseTaskQueryJson: () => parseTaskQueryJson,
315
321
  partedPromptUsage: () => partedPromptUsage,
@@ -370,6 +376,8 @@ __export(index_exports, {
370
376
  sessionDataDir: () => sessionDataDir,
371
377
  sessionRuntimeSettings: () => sessionRuntimeSettings,
372
378
  sharedMcpServer: () => sharedMcpServer,
379
+ sharedSearchProvider: () => sharedSearchProvider,
380
+ sharedWebResponseCache: () => sharedWebResponseCache,
373
381
  skillNames: () => skillNames,
374
382
  skillTokens: () => skillTokens,
375
383
  stateExists: () => stateExists,
@@ -953,7 +961,7 @@ function toTaskLogEvent(event) {
953
961
  }
954
962
  }
955
963
  function taskLogForSurface(events) {
956
- return events.map((event) => event.type === "task_skills" ? { type: "task_skills", skills: event.skills.map(({ name, source, path: path27 }) => ({ name, source, path: path27 })) } : event);
964
+ return events.map((event) => event.type === "task_skills" ? { type: "task_skills", skills: event.skills.map(({ name, source, path: path32 }) => ({ name, source, path: path32 })) } : event);
957
965
  }
958
966
  function coalesceTaskLog(events) {
959
967
  const out = [];
@@ -1208,6 +1216,7 @@ function lex(command, nested2, dialect) {
1208
1216
  let followable = true;
1209
1217
  let bareSegment = false;
1210
1218
  let tokens = [];
1219
+ let wordEnds = [];
1211
1220
  let expandable = false;
1212
1221
  let globbed = false;
1213
1222
  let current = "";
@@ -1244,7 +1253,10 @@ function lex(command, nested2, dialect) {
1244
1253
  started = false;
1245
1254
  return;
1246
1255
  }
1247
- if (started) tokens.push(current);
1256
+ if (started) {
1257
+ tokens.push(current);
1258
+ wordEnds.push(i);
1259
+ }
1248
1260
  current = "";
1249
1261
  started = false;
1250
1262
  };
@@ -1273,12 +1285,13 @@ function lex(command, nested2, dialect) {
1273
1285
  followable = false;
1274
1286
  bareSegment = true;
1275
1287
  }
1276
- segments.push({ ...seg, expandable, globbed, stdinRedirected, inputs, joinedBy });
1288
+ segments.push({ ...seg, expandable, globbed, stdinRedirected, inputs, joinedBy, wordEnds });
1277
1289
  } else if (!last || segments.length > 0 || joinedBy !== void 0) {
1278
1290
  followable = false;
1279
1291
  bareSegment = true;
1280
1292
  }
1281
1293
  tokens = [];
1294
+ wordEnds = [];
1282
1295
  inputs = [];
1283
1296
  expandable = false;
1284
1297
  globbed = false;
@@ -1302,6 +1315,10 @@ function lex(command, nested2, dialect) {
1302
1315
  }
1303
1316
  if (c === dialect.escape && (quote === "" || dialect.escapeInQuotes)) {
1304
1317
  const next = command[i + 1];
1318
+ if (dialect.escape === "\\" && next === "\n") {
1319
+ i += 2;
1320
+ continue;
1321
+ }
1305
1322
  if (redirectTarget === "heredoc") hereDocQuoted = true;
1306
1323
  if (next !== void 0 && !(quote === "" && nameDelimiter(next))) {
1307
1324
  current += next;
@@ -1543,7 +1560,7 @@ function lexAll(command, dialect, cdpathSet = false) {
1543
1560
  if (top.followable && !top.grouped && queue.length === 0 && !cdpathSet) followCd(all.segments);
1544
1561
  for (let depth = 0; depth < 32 && queue.length > 0; depth++) {
1545
1562
  const inner = lex(queue.shift(), queue, dialect);
1546
- all.segments.push(...inner.segments.filter((s) => s.binary));
1563
+ all.segments.push(...inner.segments.filter((s) => s.binary).map((s) => ({ ...s, nested: true })));
1547
1564
  all.unsafeRedirect ??= inner.unsafeRedirect;
1548
1565
  all.processSubstitution ||= inner.processSubstitution;
1549
1566
  all.unbalanced ||= inner.unbalanced;
@@ -3718,7 +3735,7 @@ function exclusionWord(pattern) {
3718
3735
  return { matches: (word) => re.test(word), wildcard: pattern.includes("*") };
3719
3736
  }
3720
3737
  function isScopePattern(entry) {
3721
- return /^(\/|~|https?:\/\/|mcp__|[A-Za-z]:[\\/])/.test(entry);
3738
+ return /^(\/|~|https?:\/\/|web_search:|mcp__|mcp-server:|[A-Za-z]:[\\/])/.test(entry);
3722
3739
  }
3723
3740
  function parseAllowlist(entries) {
3724
3741
  const list = { commands: [], commandExclusions: [], tools: [], toolExclusions: [], scopes: [], scopeExclusions: [] };
@@ -3752,6 +3769,7 @@ function ruleCovers(rule, command) {
3752
3769
  return rule.words.every((word, i) => word.matches(lead[i]) && !(word.wildcard && MUTATING_VERB.test(lead[i])));
3753
3770
  }
3754
3771
  function exclusionCovers(rule, command) {
3772
+ if (/^!\* \*https?:\/\/\*$/.test(rule.text) && /^(curl|wget)$/.test(command.binary) && command.args.length === 1 && /^https?:\/\/\S+\/\*$/.test(command.args[0])) return false;
3755
3773
  if (rule.binary !== "*" && rule.binary !== command.binary) return false;
3756
3774
  let next = 0;
3757
3775
  for (const arg of command.args) {
@@ -3767,8 +3785,14 @@ function toolAllowed(list, tool2) {
3767
3785
  if (list.toolExclusions.some((re) => re.test(tool2))) return false;
3768
3786
  return list.tools.some((re) => re.test(tool2));
3769
3787
  }
3788
+ function scopeExcluded(list, scope) {
3789
+ const origin = /^(?:curl|wget) (https?:\/\/\S+\/\*)$/.exec(scope)?.[1];
3790
+ return list.scopeExclusions.some((re) => re.test(scope) || origin !== void 0 && re.test(origin));
3791
+ }
3770
3792
  function scopeAllowed(list, scope) {
3771
- if (list.scopeExclusions.some((re) => re.test(scope))) return false;
3793
+ if (scopeExcluded(list, scope)) return false;
3794
+ const web = /^(curl|wget) (https?:\/\/\S+\/\*)$/.exec(scope);
3795
+ if (web) return commandAllowedBy(list, { binary: web[1], args: [web[2]], scopeWords: 1 }) !== void 0;
3772
3796
  return list.scopes.some((re) => re.test(scope)) || list.commands.some((rule) => rule.text === scope);
3773
3797
  }
3774
3798
  function withArgument(binary, verbs) {
@@ -4036,6 +4060,405 @@ var DEFAULT_PLANNER_ALLOWLIST = [
4036
4060
  ...["*secret*", "*token*", "*password*", "*credential*", "*key*", "*url*", "*fetch*", "*http*"].map((w) => `!mcp:${w}`)
4037
4061
  ];
4038
4062
 
4063
+ // src/services/webCommands.ts
4064
+ var import_node_net2 = require("node:net");
4065
+
4066
+ // src/services/egressGuard.ts
4067
+ var import_promises = require("node:dns/promises");
4068
+ var import_node_net = require("node:net");
4069
+ var resolveAll = async (host) => (await (0, import_promises.lookup)(host, { all: true, verbatim: true })).map((a) => a.address);
4070
+ var ALLOWED_PORTS = /* @__PURE__ */ new Set(["", "80", "443"]);
4071
+ var LOCAL_NAMES = /* @__PURE__ */ new Set(["localhost", "ip6-localhost", "ip6-loopback"]);
4072
+ function ipv4ToInt(ip) {
4073
+ return ip.split(".").reduce((acc, part) => acc * 256 + Number(part), 0);
4074
+ }
4075
+ var BLOCKED_V4 = [
4076
+ ["0.0.0.0", 8, '"this network"'],
4077
+ ["10.0.0.0", 8, "private"],
4078
+ ["100.64.0.0", 10, "shared (CGNAT)"],
4079
+ ["127.0.0.0", 8, "loopback"],
4080
+ ["169.254.0.0", 16, "link-local"],
4081
+ ["172.16.0.0", 12, "private"],
4082
+ ["192.168.0.0", 16, "private"],
4083
+ ["198.18.0.0", 15, "benchmarking"],
4084
+ ["224.0.0.0", 4, "multicast"],
4085
+ // Includes the 255.255.255.255 limited broadcast.
4086
+ ["240.0.0.0", 4, "reserved"]
4087
+ ].map(([base, bits, label]) => [ipv4ToInt(base), bits, label]);
4088
+ function blockedV4Reason(ip) {
4089
+ const n = ipv4ToInt(ip);
4090
+ for (const [base, bits, label] of BLOCKED_V4) {
4091
+ const size = 2 ** (32 - bits);
4092
+ if (n >= base && n < base + size) return `${label} address ${ip}`;
4093
+ }
4094
+ return void 0;
4095
+ }
4096
+ function ipv6Bytes(ip) {
4097
+ let text2 = ip;
4098
+ const tail = [];
4099
+ const dotted = /(\d+\.\d+\.\d+\.\d+)$/.exec(text2);
4100
+ if (dotted) {
4101
+ tail.push(...dotted[1].split(".").map(Number));
4102
+ text2 = text2.slice(0, -dotted[1].length) + "0:0";
4103
+ }
4104
+ const [head, rest] = text2.includes("::") ? text2.split("::") : [text2, void 0];
4105
+ const left = head ? head.split(":") : [];
4106
+ const right = rest ? rest.split(":") : [];
4107
+ const groups = rest === void 0 ? left : [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
4108
+ const bytes = groups.flatMap((g) => {
4109
+ const v = parseInt(g, 16);
4110
+ return [v >> 8, v & 255];
4111
+ });
4112
+ if (tail.length) bytes.splice(12, 4, ...tail);
4113
+ return bytes;
4114
+ }
4115
+ function inPrefix(bytes, prefix, bits) {
4116
+ for (let i = 0; i < bits; i += 8) {
4117
+ const mask = 255 << 8 - Math.min(8, bits - i) & 255;
4118
+ if ((bytes[i / 8] & mask) !== ((prefix[i / 8] ?? 0) & mask)) return false;
4119
+ }
4120
+ return true;
4121
+ }
4122
+ var BLOCKED_V6 = [
4123
+ [[], 127, "unspecified/loopback"],
4124
+ [[252], 7, "unique-local"],
4125
+ [[254, 128], 10, "link-local"],
4126
+ [[254, 192], 10, "site-local"],
4127
+ [[255], 8, "multicast"]
4128
+ ];
4129
+ var MAPPED_V4 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255];
4130
+ var NAT64_V4 = [0, 100, 255, 155];
4131
+ function blockedV6Reason(ip) {
4132
+ const bytes = ipv6Bytes(ip);
4133
+ const v4 = bytes.slice(12).join(".");
4134
+ if (inPrefix(bytes, MAPPED_V4, 96) || inPrefix(bytes, NAT64_V4, 96)) return blockedV4Reason(v4);
4135
+ for (const [prefix, bits, label] of BLOCKED_V6) {
4136
+ if (inPrefix(bytes, prefix, bits)) return `${label} address ${ip}`;
4137
+ }
4138
+ return void 0;
4139
+ }
4140
+ function blockedAddressReason(ip) {
4141
+ if (typeof ip !== "string") return "unparseable address";
4142
+ const bare = ip.replace(/%.*$/, "");
4143
+ switch ((0, import_node_net.isIP)(bare)) {
4144
+ case 4:
4145
+ return blockedV4Reason(bare);
4146
+ case 6:
4147
+ return blockedV6Reason(bare.toLowerCase());
4148
+ default:
4149
+ return "unparseable address";
4150
+ }
4151
+ }
4152
+ function stripBrackets(host) {
4153
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
4154
+ }
4155
+ function vetHostUnresolved(host) {
4156
+ const bare = stripBrackets(host);
4157
+ if ((0, import_node_net.isIP)(bare)) {
4158
+ const reason = blockedAddressReason(bare);
4159
+ return reason ? { ok: false, reason } : { ok: true, addresses: [bare] };
4160
+ }
4161
+ if (LOCAL_NAMES.has(bare.toLowerCase())) return { ok: false, reason: `local host name ${bare}` };
4162
+ return void 0;
4163
+ }
4164
+ async function vetHost(host, resolve9) {
4165
+ const settled = vetHostUnresolved(host);
4166
+ if (settled) return settled;
4167
+ const bare = stripBrackets(host);
4168
+ let addresses;
4169
+ try {
4170
+ addresses = await resolve9(bare);
4171
+ } catch {
4172
+ return { ok: false, reason: `${bare} did not resolve` };
4173
+ }
4174
+ if (addresses.length === 0) return { ok: false, reason: `${bare} did not resolve` };
4175
+ for (const address of addresses) {
4176
+ const reason = blockedAddressReason(address);
4177
+ if (reason) return { ok: false, reason: `${bare} resolves to a ${reason}` };
4178
+ }
4179
+ return { ok: true, addresses };
4180
+ }
4181
+ async function vetEgress(host, port, resolve9 = resolveAll) {
4182
+ return vetEgressUnresolved(host, port) ?? vetHost(host, resolve9);
4183
+ }
4184
+ function vetEgressUnresolved(host, port) {
4185
+ if (!ALLOWED_PORTS.has(port)) return { ok: false, reason: `port ${port} is not 80 or 443` };
4186
+ return vetHostUnresolved(host);
4187
+ }
4188
+ function guardedLookup(resolve9 = resolveAll) {
4189
+ return (hostname, options, callback) => {
4190
+ void vetHost(hostname, resolve9).then((check2) => {
4191
+ if (!check2.ok) {
4192
+ callback(Object.assign(new Error(`blocked host: ${check2.reason}`), { code: "EBLOCKEDHOST" }), []);
4193
+ return;
4194
+ }
4195
+ const family = options.family === "IPv4" ? 4 : options.family === "IPv6" ? 6 : options.family;
4196
+ const addresses = check2.addresses.map((address) => ({ address, family: (0, import_node_net.isIP)(address) })).filter((a) => !family || a.family === family);
4197
+ if (addresses.length === 0) {
4198
+ callback(Object.assign(new Error(`getaddrinfo ENOTFOUND ${hostname}`), { code: "ENOTFOUND" }), []);
4199
+ } else if (options.all) {
4200
+ callback(null, addresses);
4201
+ } else {
4202
+ callback(null, addresses[0].address, addresses[0].family);
4203
+ }
4204
+ });
4205
+ };
4206
+ }
4207
+
4208
+ // src/services/webCommands.ts
4209
+ var READ_REFUSAL_HINT = "Fetch the page with your web fetch tool instead.";
4210
+ var EFFECT_REFUSAL_HINT = "This sends data or changes something, so the planner should not do it itself: add it to the plan as an ops task (and, if it writes files, a change task).";
4211
+ var CURL_EFFECT = /* @__PURE__ */ new Set([
4212
+ "d",
4213
+ "F",
4214
+ "T",
4215
+ "O",
4216
+ "D",
4217
+ "c",
4218
+ "J",
4219
+ "--data",
4220
+ "--data-raw",
4221
+ "--data-binary",
4222
+ "--data-ascii",
4223
+ "--data-urlencode",
4224
+ "--form",
4225
+ "--form-string",
4226
+ "--json",
4227
+ "--upload-file",
4228
+ "--remote-name",
4229
+ "--remote-name-all",
4230
+ "--dump-header",
4231
+ "--cookie-jar",
4232
+ "--create-dirs",
4233
+ "--output-dir",
4234
+ "--remote-header-name"
4235
+ ]);
4236
+ var WGET_EFFECT = /* @__PURE__ */ new Set([
4237
+ "P",
4238
+ "r",
4239
+ "m",
4240
+ "N",
4241
+ "c",
4242
+ "a",
4243
+ "o",
4244
+ "--post-data",
4245
+ "--post-file",
4246
+ "--body-data",
4247
+ "--body-file",
4248
+ "--method",
4249
+ "--output-file",
4250
+ "--append-output",
4251
+ "--directory-prefix",
4252
+ "--recursive",
4253
+ "--mirror",
4254
+ "--timestamping",
4255
+ "--continue",
4256
+ "--save-headers",
4257
+ "--content-disposition",
4258
+ "--input-file",
4259
+ "--warc-file",
4260
+ "--save-cookies"
4261
+ ]);
4262
+ var CURL_SWITCHES = /* @__PURE__ */ new Set([
4263
+ "s",
4264
+ "S",
4265
+ "f",
4266
+ "i",
4267
+ "I",
4268
+ "v",
4269
+ "L",
4270
+ "q",
4271
+ "G",
4272
+ "--silent",
4273
+ "--show-error",
4274
+ "--fail",
4275
+ "--include",
4276
+ "--head",
4277
+ "--verbose",
4278
+ "--location",
4279
+ "--disable",
4280
+ "--get",
4281
+ "--compressed",
4282
+ "--version"
4283
+ ]);
4284
+ var CURL_VALUES = /* @__PURE__ */ new Set(["A", "m", "o", "w", "--user-agent", "--max-time", "--connect-timeout", "--output", "--write-out", "--noproxy"]);
4285
+ var CURL_SENSITIVE = /* @__PURE__ */ new Set([
4286
+ "d",
4287
+ "F",
4288
+ "u",
4289
+ "H",
4290
+ "b",
4291
+ "e",
4292
+ "X",
4293
+ "--data",
4294
+ "--data-raw",
4295
+ "--data-binary",
4296
+ "--data-ascii",
4297
+ "--data-urlencode",
4298
+ "--form",
4299
+ "--form-string",
4300
+ "--user",
4301
+ "--header",
4302
+ "--cookie",
4303
+ "--referer",
4304
+ "--request"
4305
+ ]);
4306
+ var WGET_SWITCHES = /* @__PURE__ */ new Set(["q", "S", "v", "--quiet", "--server-response", "--verbose", "--no-verbose", "--spider", "--no-config", "--no-hsts", "--no-proxy", "--no-netrc", "--version"]);
4307
+ var WGET_VALUES = /* @__PURE__ */ new Set(["O", "U", "T", "--output-document", "--user-agent", "--timeout", "--connect-timeout", "--read-timeout", "--max-redirect"]);
4308
+ function webCommand(seg, trustedResolves) {
4309
+ const binary = seg.binary.toLowerCase().replace(/\.exe$/, "");
4310
+ if (binary === "wget2") return { urls: [], reason: `wget2 can write persistent databases. ${READ_REFUSAL_HINT}` };
4311
+ if (binary !== "curl" && binary !== "wget") return void 0;
4312
+ if (seg.binaryPath !== void 0) return { urls: [], reason: `Call ${binary} by name so the research shell resolves it from PATH. ${READ_REFUSAL_HINT}` };
4313
+ const urls = [];
4314
+ const outboundTexts = [];
4315
+ const switches = binary === "curl" ? CURL_SWITCHES : WGET_SWITCHES;
4316
+ const values = binary === "curl" ? CURL_VALUES : WGET_VALUES;
4317
+ let stdout = false;
4318
+ let spider = false;
4319
+ let perInvocation = false;
4320
+ let redirects = binary === "wget";
4321
+ let getQuery = false;
4322
+ let queryData = false;
4323
+ let effect = false;
4324
+ const effectFlags = binary === "curl" ? CURL_EFFECT : WGET_EFFECT;
4325
+ const refused3 = (flag, sends = false) => sends ? { urls, effect: true, reason: `"${binary} ${flag}" is not a read-only web command. ${EFFECT_REFUSAL_HINT}` } : { urls, reason: `"${binary} ${flag}" is not known to be a read-only web command. ${READ_REFUSAL_HINT}` };
4326
+ if (seg.expandable || seg.globbed) return refused3("with shell expansions or globbing");
4327
+ for (let i = 0; i < seg.args.length; i++) {
4328
+ const arg = seg.args[i];
4329
+ if (arg === "-") return refused3(arg);
4330
+ if (!arg.startsWith("-")) {
4331
+ let url;
4332
+ try {
4333
+ url = new URL(arg);
4334
+ } catch {
4335
+ return refused3(arg);
4336
+ }
4337
+ if (!/^https?:\/\//i.test(arg) || /[\\\s{}]/.test(arg)) return refused3(arg);
4338
+ const host = url.hostname.replace(/^\[|\]$/g, "");
4339
+ const authority = /^https?:\/\/([^/?#]*)/i.exec(arg)?.[1] ?? "";
4340
+ const rawHost = authority.slice(authority.lastIndexOf("@") + 1).replace(/:\d*$/, "");
4341
+ if (binary === "curl" && !/^[A-Za-z0-9.-]+$/.test(rawHost) && !(/^\[[0-9A-Fa-f:.]+\]$/.test(rawHost) && (0, import_node_net2.isIP)(host) === 6)) return refused3("with a non-literal ASCII hostname");
4342
+ const withoutHost = arg.replace(url.hostname, "");
4343
+ if (/[[\]]/.test(withoutHost)) return refused3(arg);
4344
+ if ((0, import_node_net2.isIP)(host)) {
4345
+ const reason = blockedAddressReason(host);
4346
+ if (reason) return { urls, reason: `Blocked host: ${reason}. ${READ_REFUSAL_HINT}` };
4347
+ }
4348
+ if (url.username || url.password) perInvocation = true;
4349
+ urls.push(url);
4350
+ outboundTexts.push(arg);
4351
+ continue;
4352
+ }
4353
+ const long = arg.startsWith("--");
4354
+ const flags = long ? [arg.split("=")[0]] : [...arg.slice(1)];
4355
+ for (let j = 0; j < flags.length; j++) {
4356
+ const flag = flags[j];
4357
+ if (binary === "curl" && (flag === "n" || flag === "--netrc" || flag === "--netrc-optional")) {
4358
+ if (arg.includes("=")) return refused3(arg);
4359
+ perInvocation = true;
4360
+ continue;
4361
+ }
4362
+ if (switches.has(flag)) {
4363
+ if (long && arg.includes("=")) return refused3(arg);
4364
+ if (flag === "--spider") spider = true;
4365
+ if (flag === "L" || flag === "--location") redirects = true;
4366
+ if (flag === "G" || flag === "--get") getQuery = true;
4367
+ continue;
4368
+ }
4369
+ const sensitive = binary === "curl" && CURL_SENSITIVE.has(flag);
4370
+ const sends = effectFlags.has(flag) && !sensitive;
4371
+ if (sends && !(binary === "wget" && (flag === "O" || flag === "--output-document"))) return refused3(arg, true);
4372
+ const trustedResolve = binary === "curl" && flag === "--resolve" && trustedResolves !== void 0;
4373
+ if (!values.has(flag) && !sensitive && !trustedResolve) return refused3(arg);
4374
+ const attached2 = long ? arg.includes("=") ? arg.slice(flag.length + 1) : void 0 : flags.slice(j + 1).join("") || void 0;
4375
+ const value = attached2 ?? seg.args[++i];
4376
+ if (value === void 0) return refused3(arg);
4377
+ outboundTexts.push(value);
4378
+ if (trustedResolve && !trustedResolves?.has(value)) return refused3(arg);
4379
+ if (flag === "--noproxy" && value !== "*") return refused3(arg);
4380
+ if (binary === "wget" && flag === "--max-redirect") redirects = value !== "0";
4381
+ if (sensitive) {
4382
+ if (flag === "--data-urlencode" && !value.includes("@")) queryData = true;
4383
+ else if (!((flag === "X" || flag === "--request") && (value === "GET" || value === "HEAD"))) {
4384
+ perInvocation = true;
4385
+ effect = true;
4386
+ }
4387
+ }
4388
+ if (binary === "wget" && (flag === "O" || flag === "--output-document")) {
4389
+ if (value !== "-") return refused3(arg, true);
4390
+ stdout = true;
4391
+ }
4392
+ if ((flag === "o" || flag === "--output") && value !== "/dev/null") return refused3(arg, binary === "curl");
4393
+ if ((flag === "w" || flag === "--write-out") && (value.startsWith("@") || value.includes("%output{"))) return refused3(arg);
4394
+ break;
4395
+ }
4396
+ }
4397
+ if (urls.length === 0) return refused3("without a literal HTTP(S) URL");
4398
+ if (binary === "wget" && !stdout && !spider) return refused3("without stdout or --spider");
4399
+ return { urls, outboundTexts, ...effect ? { effect } : {}, ...perInvocation || redirects || queryData && !getQuery ? { perInvocation: true } : {} };
4400
+ }
4401
+ function disableWebCommandStartup(command, dialect) {
4402
+ const syntax = dialectFor(dialect);
4403
+ const edits = [];
4404
+ for (const original of lex(command, [], syntax).segments) {
4405
+ const { seg } = unwrap(original, syntax);
4406
+ const binary = seg.binary.toLowerCase().replace(/\.exe$/, "");
4407
+ if (binary !== "curl" && binary !== "wget") continue;
4408
+ const ends = original.wordEnds ?? [];
4409
+ const end = ends[ends.length - seg.args.length - 1];
4410
+ const noProxy = syntax.escape === "^" ? '"*"' : "'*'";
4411
+ if (end !== void 0) edits.push({ end, flags: binary === "curl" ? ` -q --noproxy ${noProxy}` : " --no-config --no-hsts --no-proxy --no-netrc" });
4412
+ }
4413
+ for (const { end, flags } of edits.sort((a, b) => b.end - a.end)) {
4414
+ command = command.slice(0, end) + flags + command.slice(end);
4415
+ }
4416
+ return command;
4417
+ }
4418
+ function pinWebCommandAddresses(command, addresses, dialect) {
4419
+ const syntax = dialectFor(dialect);
4420
+ const resolves2 = /* @__PURE__ */ new Set();
4421
+ const edits = [];
4422
+ for (const original of lex(command, [], syntax).segments) {
4423
+ const { seg } = unwrap(original, syntax);
4424
+ if (seg.binary.toLowerCase().replace(/\.exe$/, "") !== "curl") continue;
4425
+ const web = webCommand(seg);
4426
+ if (!web || web.reason) continue;
4427
+ const flags = [...new Set(web.urls.map((url) => url.origin))].flatMap((origin) => {
4428
+ const url = new URL(origin);
4429
+ if ((0, import_node_net2.isIP)(url.hostname.replace(/^\[|\]$/g, ""))) return [];
4430
+ const address = addresses.get(origin);
4431
+ if (!address) throw new Error("Missing vetted curl address");
4432
+ const value = `${url.hostname}:${url.port || (url.protocol === "https:" ? "443" : "80")}:${(0, import_node_net2.isIP)(address) === 6 ? `[${address}]` : address}`;
4433
+ resolves2.add(value);
4434
+ return [` --resolve ${syntax.escape === "^" ? `"${value}"` : `'${value}'`}`];
4435
+ }).join("");
4436
+ const ends = original.wordEnds ?? [];
4437
+ const end = ends[ends.length - seg.args.length - (seg.args[0] === "-q" ? 0 : 1)];
4438
+ if (end !== void 0 && flags) edits.push({ end, flags });
4439
+ }
4440
+ for (const { end, flags } of edits.sort((a, b) => b.end - a.end)) {
4441
+ command = command.slice(0, end) + flags + command.slice(end);
4442
+ }
4443
+ return { command, resolves: resolves2 };
4444
+ }
4445
+ function wgetVersionCommands(command, dialect) {
4446
+ const syntax = dialectFor(dialect);
4447
+ const { segments } = lexAll(command, syntax, !!process.env.CDPATH);
4448
+ const quote = (word) => syntax.escape === "^" ? `"${word}"` : `'${word.replace(/'/g, "'\\''")}'`;
4449
+ return [...new Set(segments.flatMap((original) => {
4450
+ const { seg } = unwrap(original, syntax);
4451
+ if (seg.binary.toLowerCase().replace(/\.exe$/, "") !== "wget") return [];
4452
+ if (seg.binaryPath !== void 0) return [void 0];
4453
+ const executable = seg.binary;
4454
+ if (original.cwd === void 0 && segments.some((s) => /^(cd|pushd|popd)$/i.test(s.binary))) return [void 0];
4455
+ if (syntax.escape === "^" && [...original.cwd ?? [], executable].some((word) => /["%&|<>^()!]/.test(word))) return [void 0];
4456
+ const navigation = (original.cwd ?? []).map((target) => `cd ${quote(target)} && `).join("");
4457
+ const env = syntax.escape === "^" ? 'set "XDG_DATA_HOME=/dev/null" && set "XDG_CONFIG_HOME=/dev/null" && set "LOCALAPPDATA=NUL" && ' : "XDG_DATA_HOME=/dev/null XDG_CONFIG_HOME=/dev/null LOCALAPPDATA=NUL ";
4458
+ return [`${navigation}${env}${quote(executable)} --no-config --version`];
4459
+ }))];
4460
+ }
4461
+
4039
4462
  // src/services/commandPolicy.ts
4040
4463
  var AUTO_COMMANDS = [
4041
4464
  "ls",
@@ -4389,7 +4812,7 @@ function pathRefs(command, opts = {}) {
4389
4812
  return lexAll(command.trim(), dialect, opts.cdpathSet).segments.flatMap((seg) => {
4390
4813
  const patterns = patternArgs(seg);
4391
4814
  const cwd = seg.cwd ?? [];
4392
- return [...seg.args.filter((_, i) => !patterns.has(i)), ...seg.inputs].flatMap(pathIn).map((path27) => ({ path: path27, cwd }));
4815
+ return [...seg.args.filter((_, i) => !patterns.has(i)), ...seg.inputs].flatMap(pathIn).map((path32) => ({ path: path32, cwd }));
4393
4816
  });
4394
4817
  }
4395
4818
  function pathIn(a) {
@@ -4488,7 +4911,9 @@ function isAuto(seg, opts) {
4488
4911
  const sub = subcommandOf(seg);
4489
4912
  return sub !== void 0 && GIT_READONLY_SUBCOMMANDS.includes(sub);
4490
4913
  }
4491
- function refusalFor(seg) {
4914
+ function refusalFor(seg, trustedResolves) {
4915
+ const web = webCommand(seg, trustedResolves);
4916
+ if (web?.reason) return web.reason;
4492
4917
  if (seg.computedBinary !== void 0) {
4493
4918
  return `"${seg.computedBinary}" is a command name the shell computes as it runs, so this classifier cannot tell what would run. Name the program directly.`;
4494
4919
  }
@@ -4648,9 +5073,25 @@ function classifyCommand(command, opts = {}) {
4648
5073
  }
4649
5074
  if (segments.length === 0) return { tier: "refuse", scope: "", reason: "No command found." };
4650
5075
  const unwrapped = segments.map((seg) => unwrap(seg, dialect));
5076
+ const nestedWeb = segments.flatMap((seg, i) => seg.nested ? [webCommand(unwrapped[i].seg, opts.trustedWebResolves)] : []).find((web) => web);
5077
+ if (nestedWeb) {
5078
+ return {
5079
+ tier: "refuse",
5080
+ scope: "",
5081
+ reason: `A curl or wget command inside a substitution cannot have its startup files disabled safely. ${nestedWeb.effect ? EFFECT_REFUSAL_HINT : `Run it directly, or ${READ_REFUSAL_HINT.charAt(0).toLowerCase()}${READ_REFUSAL_HINT.slice(1)}`}`
5082
+ };
5083
+ }
4651
5084
  for (const { seg, wrappers, runner, reason: wrapperReason } of unwrapped) {
4652
5085
  if (wrapperReason) return { tier: "refuse", scope: "", reason: wrapperReason };
4653
- const reason = refusalFor({ ...seg, binary: seg.binary.toLowerCase() });
5086
+ const web = webCommand(seg, opts.trustedWebResolves);
5087
+ if (web && (opts.autonomous || opts.approvalMode === "deny")) {
5088
+ return { tier: "refuse", scope: "", reason: `curl and wget are refused in Full and allowlist mode. ${web.effect ? EFFECT_REFUSAL_HINT : READ_REFUSAL_HINT}` };
5089
+ }
5090
+ const allow = opts.allow;
5091
+ if (web && allow && web.urls.some((url) => scopeExcluded(allow, `${seg.binary.toLowerCase().replace(/\.exe$/, "")} ${url.origin}/*`))) {
5092
+ return { tier: "refuse", scope: "", reason: `The web origin is excluded by the planner allowlist. ${READ_REFUSAL_HINT}` };
5093
+ }
5094
+ const reason = refusalFor({ ...seg, binary: seg.binary.toLowerCase() }, opts.trustedWebResolves);
4654
5095
  if (reason) return { tier: "refuse", scope: "", reason: withWrapperNote(reason, wrappers) };
4655
5096
  if (runner && !XARGS_TARGETS.includes(seg.binary)) {
4656
5097
  return {
@@ -4660,22 +5101,49 @@ function classifyCommand(command, opts = {}) {
4660
5101
  };
4661
5102
  }
4662
5103
  }
5104
+ if (unwrapped.some(({ seg }) => seg.binary.toLowerCase().replace(/\.exe$/, "") === "wget") && wgetVersionCommands(command, opts.dialect).includes(void 0)) {
5105
+ return { tier: "refuse", scope: "", reason: `The wget executable cannot be checked safely in this working directory. ${READ_REFUSAL_HINT}` };
5106
+ }
4663
5107
  const nonAuto = unwrapped.filter((u) => u.runner !== void 0 || u.wrappers.some((w) => w !== w.toLowerCase()) || !isAuto(u.seg, opts));
4664
5108
  if (nonAuto.length === 0) return { tier: "auto", scope: "" };
4665
- if (nonAuto.some((u) => {
5109
+ const lineScoped = nonAuto.some((u) => {
4666
5110
  const name = u.seg.binary.toLowerCase();
4667
5111
  return name === "cd" || name !== "command" && SHELL_STATE_COMMANDS.has(name);
4668
- })) {
5112
+ });
5113
+ const webUrls = nonAuto.flatMap((u) => webCommand(u.seg, opts.trustedWebResolves)?.urls ?? []);
5114
+ const webOutboundTexts = nonAuto.flatMap((u) => webCommand(u.seg, opts.trustedWebResolves)?.outboundTexts ?? []);
5115
+ if (lineScoped && webUrls.length === 0) {
4669
5116
  return { tier: "ask", scope: trimmed };
4670
5117
  }
4671
- const scope = [...new Set(nonAuto.map((u) => u.runner ? `${u.runner} ${scopeFor(u.seg)}` : scopeFor(u.seg)))].sort().join(" + ");
4672
- const allowedBy = opts.allow ? allowlistRule(nonAuto, opts.allow) : void 0;
4673
- return { tier: "ask", scope, ...allowedBy ? { allowedBy } : {} };
5118
+ const scopes = [...new Set(nonAuto.flatMap((u) => {
5119
+ const web = webCommand(u.seg, opts.trustedWebResolves);
5120
+ return web ? web.urls.map((url) => `${u.seg.binary.toLowerCase().replace(/\.exe$/, "")} ${url.origin}/*`) : [lineScoped ? trimmed : u.runner ? `${u.runner} ${scopeFor(u.seg)}` : scopeFor(u.seg)];
5121
+ }))].sort();
5122
+ const scope = scopes.join(" + ");
5123
+ const perInvocation = nonAuto.some((u) => webCommand(u.seg, opts.trustedWebResolves)?.perInvocation);
5124
+ const allowedBy = opts.allow && !perInvocation && !lineScoped ? allowlistRule(nonAuto, opts.allow, opts.trustedWebResolves) : void 0;
5125
+ return {
5126
+ tier: "ask",
5127
+ scope,
5128
+ ...webUrls.length ? { webUrls, webOutboundTexts } : {},
5129
+ ...scopes.length > 1 && webUrls.length ? { scopes } : {},
5130
+ ...perInvocation ? { perInvocation } : {},
5131
+ ...allowedBy ? { allowedBy } : {}
5132
+ };
4674
5133
  }
4675
- function allowlistRule(parts, allow) {
5134
+ function allowlistRule(parts, allow, trustedResolves) {
4676
5135
  const rules = [];
4677
5136
  for (const { seg, runner } of parts) {
4678
5137
  if (runner || seg.expandable || seg.globbed || seg.binaryPath !== void 0 || seg.assignments.length > 0) return void 0;
5138
+ const web = webCommand(seg, trustedResolves);
5139
+ if (web) {
5140
+ for (const url of web.urls) {
5141
+ const rule2 = commandAllowedBy(allow, { binary: seg.binary, args: [`${url.origin}/*`], scopeWords: 1 });
5142
+ if (!rule2) return void 0;
5143
+ rules.push(rule2);
5144
+ }
5145
+ continue;
5146
+ }
4679
5147
  const rule = commandAllowedBy(allow, { binary: seg.binary, args: seg.args, scopeWords: scopeFor(seg).split(" ").length - 1 });
4680
5148
  if (!rule) return void 0;
4681
5149
  rules.push(rule);
@@ -4846,9 +5314,62 @@ function referencePattern(symbol) {
4846
5314
  return `\\b${escapeRegex(symbol)}\\b`;
4847
5315
  }
4848
5316
 
5317
+ // src/services/outboundSecrets.ts
5318
+ var SENSITIVE_ENV_NAME = /(?:^|_)(?:KEY|API_KEY|TOKEN|SECRET|PASSWORD|CREDENTIALS?)(?:_|$)/i;
5319
+ var MIN_SECRET_LENGTH = 8;
5320
+ var MAX_DECODE_ROUNDS = 3;
5321
+ var TOKEN_SHAPES = [
5322
+ /\bgh[pousr]_[A-Za-z0-9]{36,}/,
5323
+ /\bgithub_pat_[A-Za-z0-9_]{22,}/,
5324
+ /\bAIza[A-Za-z0-9_-]{30,}/,
5325
+ /\bglpat-[A-Za-z0-9_-]{20,}/,
5326
+ /\b[sr]k_live_[A-Za-z0-9]{16,}/,
5327
+ /\b(?:hf|npm)_[A-Za-z0-9]{20,}/,
5328
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/,
5329
+ /\bxox[abpr]-[A-Za-z0-9-]{10,}/,
5330
+ /\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{10,}/,
5331
+ /-----BEGIN [A-Z0-9 ]+-----/
5332
+ ];
5333
+ var SK_KEY = /\bsk-[A-Za-z0-9_-]{20,}/g;
5334
+ function looksRandom(tail) {
5335
+ return /[a-z]/.test(tail) && /[A-Z]/.test(tail) && /\d/.test(tail);
5336
+ }
5337
+ function percentDecode(text2) {
5338
+ return text2.replace(/(?:%[0-9a-f]{2})+/gi, (run) => {
5339
+ try {
5340
+ return decodeURIComponent(run);
5341
+ } catch {
5342
+ return run;
5343
+ }
5344
+ });
5345
+ }
5346
+ function forms(text2) {
5347
+ const seen = [text2, text2.replace(/\+/g, " ")];
5348
+ let current = text2;
5349
+ for (let round = 0; round < MAX_DECODE_ROUNDS; round++) {
5350
+ const next = percentDecode(current);
5351
+ if (next === current) break;
5352
+ seen.push(next, next.replace(/\+/g, " "));
5353
+ current = next;
5354
+ }
5355
+ return seen;
5356
+ }
5357
+ function sensitiveEnvValues(env) {
5358
+ return Object.entries(env).filter(([name, value]) => SENSITIVE_ENV_NAME.test(name) && !/_(?:URL|URI|PATH|FILE|DIR)$/i.test(name) && value !== void 0).map(([, value]) => (value ?? "").trim());
5359
+ }
5360
+ function carriesSecret(texts, held) {
5361
+ const secrets = [...held].map((s) => s.trim()).filter((s) => s.length >= MIN_SECRET_LENGTH);
5362
+ return texts.flatMap(forms).some((text2) => secrets.some((secret) => text2.includes(secret)) || TOKEN_SHAPES.some((shape) => shape.test(text2)) || [...text2.matchAll(SK_KEY)].some((m) => looksRandom(m[0].slice(3))));
5363
+ }
5364
+
4849
5365
  // src/services/BaseFileSystem.ts
4850
5366
  var BaseFileSystem = class {
5367
+ constructor(resolveWebHost = resolveAll) {
5368
+ this.resolveWebHost = resolveWebHost;
5369
+ }
5370
+ resolveWebHost;
4851
5371
  approval = DENY_ALL;
5372
+ outboundSecrets = () => [];
4852
5373
  /**
4853
5374
  * The interpreter `execBashImpl` will hand the command to. Owned here rather
4854
5375
  * than per-adapter because {@link classifyCommand} has to be told the same
@@ -4860,6 +5381,9 @@ var BaseFileSystem = class {
4860
5381
  setApproval(approval) {
4861
5382
  this.approval = approval;
4862
5383
  }
5384
+ setOutboundSecrets(secrets) {
5385
+ this.outboundSecrets = secrets;
5386
+ }
4863
5387
  /**
4864
5388
  * Resolve `p` and confirm the planner may touch it. In-workspace paths pass
4865
5389
  * silently; anything else needs one approval, remembered per containing
@@ -4996,19 +5520,34 @@ ${refs.output.trim()}`);
4996
5520
  * directory the next command names.
4997
5521
  */
4998
5522
  async bash(command, signal) {
5523
+ command = disableWebCommandStartup(command, this.researchShell.dialect);
4999
5524
  const allow = this.approval.allowlist?.();
5000
- const { tier, scope, reason, allowedBy } = classifyCommand(command, { ...this.policyOptions(), ...allow ? { allow } : {} });
5525
+ const { tier, scope, reason, allowedBy, perInvocation, scopes: commandScopes, webUrls, webOutboundTexts } = classifyCommand(command, {
5526
+ ...this.policyOptions(),
5527
+ ...this.approval.commandPolicy?.(),
5528
+ ...allow ? { allow } : {}
5529
+ });
5001
5530
  if (tier === "refuse") {
5002
5531
  return { success: false, output: `Command refused: ${reason}`, truncated: false };
5003
5532
  }
5533
+ if (webUrls?.length && carriesSecret([...webOutboundTexts ?? [], ...webUrls.map((url) => url.href)], [...this.outboundSecrets(), ...sensitiveEnvValues(process.env)])) {
5534
+ return { success: false, output: "Command refused: a web command contains what looks like a secret. Fetch the page with your web fetch tool, without credentials.", truncated: false };
5535
+ }
5536
+ const vettedAddresses = /* @__PURE__ */ new Map();
5537
+ for (const url of webUrls ?? []) {
5538
+ const check2 = await vetEgress(url.hostname, url.port, this.resolveWebHost);
5539
+ if (!check2.ok) return { success: false, output: `Command refused: Blocked host: ${check2.reason}. ${READ_REFUSAL_HINT}`, truncated: false };
5540
+ vettedAddresses.set(url.origin, check2.addresses[0]);
5541
+ }
5004
5542
  const outside = this.outsidePaths(command);
5005
5543
  const asks = tier === "ask";
5006
5544
  if (asks || outside.length > 0) {
5007
5545
  const root = this.getWorkspaceRoot();
5008
- const scopes = [...asks ? [scope] : [], ...outside.map((o) => o.scope)];
5546
+ const scopes = [...asks ? commandScopes ?? [scope] : [], ...outside.map((o) => o.scope)];
5009
5547
  const touched = outside.map((o) => o.abs).join(", ");
5010
5548
  const granted = await this.approval.request({
5011
5549
  kind: asks ? "shell_command" : "external_path",
5550
+ ...perInvocation ? { perInvocation } : {},
5012
5551
  subject: asks ? command : outside[0].abs,
5013
5552
  scope: scopes[0],
5014
5553
  ...scopes.length > 1 ? { scopes } : {},
@@ -5024,6 +5563,23 @@ It is outside the auto-allowed read-only set and the planner allowlist (${scope}
5024
5563
  };
5025
5564
  }
5026
5565
  }
5566
+ if (webUrls?.length) {
5567
+ const latest = classifyCommand(command, { ...this.policyOptions(), ...this.approval.commandPolicy?.(), allow: this.approval.allowlist?.() });
5568
+ if (latest.tier === "refuse") return { success: false, output: `Command refused: ${latest.reason}`, truncated: false };
5569
+ }
5570
+ for (const probe of wgetVersionCommands(command, this.researchShell.dialect)) {
5571
+ if (!probe) return { success: false, output: `Command refused: the wget executable cannot be checked in an uncertain working directory. ${READ_REFUSAL_HINT}`, truncated: false };
5572
+ const version = await this.execBashImpl(probe, signal);
5573
+ if (!version.success || !/^GNU Wget 1\./.test(version.output)) {
5574
+ return { success: false, output: `Command refused: only GNU Wget 1.x is supported; wget2 can write persistent databases. ${READ_REFUSAL_HINT}`, truncated: false };
5575
+ }
5576
+ }
5577
+ const pinned = pinWebCommandAddresses(command, vettedAddresses, this.researchShell.dialect);
5578
+ command = pinned.command;
5579
+ if (webUrls?.length) {
5580
+ const latest = classifyCommand(command, { ...this.policyOptions(), ...this.approval.commandPolicy?.(), allow: this.approval.allowlist?.(), trustedWebResolves: pinned.resolves });
5581
+ if (latest.tier === "refuse") return { success: false, output: `Command refused: ${latest.reason}`, truncated: false };
5582
+ }
5027
5583
  const outcome = await this.execBashImpl(command, signal);
5028
5584
  if (outcome.success) return outcome;
5029
5585
  const warning = researchShellWarning(this.researchShell);
@@ -5220,10 +5776,7 @@ async function runTool(name, args, fs17, fetcher, signal) {
5220
5776
  return fs17.bash(String(args.command ?? ""), signal);
5221
5777
  case "fetch": {
5222
5778
  if (!fetcher) return { success: false, output: "Web fetching is not available in this environment.", truncated: false };
5223
- const url = String(args.url ?? "");
5224
- const allowed = await fetcher.confirm(url);
5225
- if (!allowed) return { success: false, output: `URL fetch denied by user: ${url}`, truncated: false };
5226
- return fetcher.fetch(url);
5779
+ return fetcher.fetch(String(args.url ?? ""));
5227
5780
  }
5228
5781
  case "web_search": {
5229
5782
  if (!fetcher?.search) {
@@ -5247,9 +5800,11 @@ async function runTool(name, args, fs17, fetcher, signal) {
5247
5800
  }
5248
5801
 
5249
5802
  // src/services/ApprovalPolicy.ts
5250
- function effectiveApprovalMode(setting, autonomous) {
5803
+ var FULL_ALLOWS = /* @__PURE__ */ new Set(["url_fetch", "web_search"]);
5804
+ function effectiveApprovalMode(setting, autonomous, kind) {
5251
5805
  if (setting !== "auto") return setting;
5252
- return autonomous ? "deny" : "ask";
5806
+ if (!autonomous) return "ask";
5807
+ return kind !== void 0 && FULL_ALLOWS.has(kind) ? "allow" : "deny";
5253
5808
  }
5254
5809
  function parseApprovalModeSetting(raw) {
5255
5810
  const value = (raw ?? "").trim().toLowerCase();
@@ -5258,6 +5813,7 @@ function parseApprovalModeSetting(raw) {
5258
5813
  }
5259
5814
  var ApprovalPolicy = class {
5260
5815
  modeOf;
5816
+ autonomousOf;
5261
5817
  entriesOf;
5262
5818
  parsed = null;
5263
5819
  asker;
@@ -5273,6 +5829,7 @@ var ApprovalPolicy = class {
5273
5829
  constructor(opts = {}) {
5274
5830
  const { mode = "ask", preApproved = [] } = opts;
5275
5831
  this.modeOf = typeof mode === "function" ? mode : () => mode;
5832
+ this.autonomousOf = opts.autonomous ?? (() => false);
5276
5833
  this.entriesOf = typeof preApproved === "function" ? preApproved : () => preApproved;
5277
5834
  this.asker = opts.ask;
5278
5835
  this.onDecision = opts.onDecision;
@@ -5282,20 +5839,20 @@ var ApprovalPolicy = class {
5282
5839
  this.onDecision?.(req, granted, source);
5283
5840
  return granted;
5284
5841
  };
5285
- const scopes = approvalScopes(req);
5286
- const key = scopes.join("\n");
5287
5842
  const list = this.allowlist();
5288
- const preApproved = (scope, i) => scopeAllowed(list, scope) || i === 0 && (req.kind === "shell_command" && req.allowedBy !== void 0 || req.kind === "mcp_tool" && req.tool !== void 0 && toolAllowed(list, req.tool));
5289
- const open = scopes.filter((s, i) => !this.granted.has(s) && !preApproved(s, i));
5843
+ if (approvalScopes(req).some((scope) => scopeExcluded(list, scope))) return decide(false, "excluded");
5844
+ if (req.perInvocation) {
5845
+ if (this.modeOf(req.kind) === "deny" || this.autonomousOf()) return decide(false, "mode");
5846
+ if (!this.asker) return decide(false, "no-channel");
5847
+ return decide(await this.asker(req).catch(() => false), "asked");
5848
+ }
5849
+ const { scopes, key, open } = this.standing(req);
5290
5850
  if (open.length === 0) {
5291
5851
  return decide(true, scopes.every((s) => this.granted.has(s)) ? "remembered" : "pre-approved");
5292
5852
  }
5293
5853
  if (scopes.some((s) => this.refused.has(s)) || this.refused.has(key)) return decide(false, "remembered");
5294
- const mode = this.modeOf();
5295
- if (mode === "allow") {
5296
- open.forEach((s) => this.granted.add(s));
5297
- return decide(true, "mode");
5298
- }
5854
+ const mode = this.modeOf(req.kind);
5855
+ if (mode === "allow") return decide(true, "mode");
5299
5856
  if (mode === "deny") return decide(false, "mode");
5300
5857
  if (!this.asker) return decide(false, "no-channel");
5301
5858
  const openKey = open.join("\n");
@@ -5314,6 +5871,44 @@ var ApprovalPolicy = class {
5314
5871
  }
5315
5872
  return decide(answer2, "asked");
5316
5873
  }
5874
+ commandPolicy() {
5875
+ return { autonomous: this.autonomousOf(), approvalMode: this.modeOf("shell_command") };
5876
+ }
5877
+ /**
5878
+ * What `request` would answer without a person, for a caller that may never
5879
+ * prompt (a research subagent): the same standing grants, remembered
5880
+ * refusals and mode the interactive path uses, stopping at `ask`.
5881
+ */
5882
+ peek(req) {
5883
+ const list = this.allowlist();
5884
+ if (approvalScopes(req).some((scope) => scopeExcluded(list, scope))) return { decision: "deny", source: "excluded" };
5885
+ const { scopes, key, open } = this.standing(req);
5886
+ if (open.length === 0) return { decision: "allow", source: scopes.every((s) => this.granted.has(s)) ? "remembered" : "pre-approved" };
5887
+ if (scopes.some((s) => this.refused.has(s)) || this.refused.has(key)) return { decision: "deny", source: "remembered" };
5888
+ const mode = this.modeOf(req.kind);
5889
+ if (mode === "allow") return { decision: "allow", source: "mode" };
5890
+ if (mode === "deny") return { decision: "deny", source: "mode" };
5891
+ return { decision: "ask", source: "no-channel" };
5892
+ }
5893
+ standing(req) {
5894
+ const scopes = approvalScopes(req);
5895
+ const key = scopes.join("\n");
5896
+ const list = this.allowlist();
5897
+ const preApproved = (scope, i) => scopeAllowed(list, scope) || i === 0 && (req.kind === "shell_command" && req.allowedBy !== void 0 || req.kind === "mcp_tool" && req.tool !== void 0 && toolAllowed(list, req.tool));
5898
+ const open = scopes.filter((s, i) => !this.granted.has(s) && !preApproved(s, i));
5899
+ return { scopes, key, open };
5900
+ }
5901
+ /** Report a decision a guard reached before this policy was asked. */
5902
+ record(req, granted, source) {
5903
+ this.onDecision?.(req, granted, source);
5904
+ }
5905
+ grantsUnasked({ kind, scope }) {
5906
+ const list = this.allowlist();
5907
+ if (scopeExcluded(list, scope)) return false;
5908
+ if (this.granted.has(scope) || scopeAllowed(list, scope)) return true;
5909
+ if (this.refused.has(scope)) return false;
5910
+ return this.modeOf(kind) === "allow";
5911
+ }
5317
5912
  /** The standing approvals, parsed from the entries in force now. */
5318
5913
  allowlist() {
5319
5914
  const entries = this.entriesOf();
@@ -5334,8 +5929,582 @@ var ApprovalPolicy = class {
5334
5929
  }
5335
5930
  };
5336
5931
 
5932
+ // src/services/webReadPolicy.ts
5933
+ var DEFAULT_WEB_READ_BUDGET = 40;
5934
+ var ALLOWED = { allowed: true };
5935
+ var WebReadPolicy = class {
5936
+ approval;
5937
+ resolveHost;
5938
+ secrets;
5939
+ env;
5940
+ budget;
5941
+ turn;
5942
+ spent = { turn: void 0, reads: 0 };
5943
+ constructor(opts) {
5944
+ this.approval = opts.approval;
5945
+ this.secrets = opts.secrets ?? (() => []);
5946
+ this.env = opts.env ?? (() => process.env);
5947
+ this.budget = opts.budget ?? (() => DEFAULT_WEB_READ_BUDGET);
5948
+ this.turn = opts.turn ?? (() => void 0);
5949
+ this.resolveHost = opts.resolveHost ?? resolveAll;
5950
+ }
5951
+ /**
5952
+ * Every check that needs no network runs first, then the budget and the
5953
+ * approval, and only then is a fetch's host resolved: a lookup is itself a
5954
+ * request, so `<data>.attacker.example` would leave through DNS even for a
5955
+ * read that is then excluded, over budget or declined.
5956
+ */
5957
+ async decide(read2, opts = {}) {
5958
+ const target = read2.kind === "fetch" ? this.vetFetch(read2.url) : this.vetSearch(read2);
5959
+ const { request, label } = target;
5960
+ if (target.refused) return this.refuse(request, target.refused.source, target.refused.reason);
5961
+ const list = this.approval.allowlist?.();
5962
+ if (list && scopeExcluded(list, request.scope)) {
5963
+ return this.refuse(request, "excluded", `${label} refused: ${request.scope} is excluded by the planner allowlist. Continue without it.`);
5964
+ }
5965
+ const spent = this.spend();
5966
+ if (!spent) {
5967
+ return this.refuse(request, "budget", `${label} refused: this planner turn has used its web-read budget (${this.budget()}). Continue with what you have, and tell the user that more reading would need a higher budget.`);
5968
+ }
5969
+ if (opts.prompting === false) {
5970
+ const standing = this.approval.peek?.(request);
5971
+ if (standing?.decision === "allow") {
5972
+ this.approval.record?.(request, true, standing.source);
5973
+ return this.resolved(target, spent.turn);
5974
+ }
5975
+ this.refund(spent.turn);
5976
+ return this.subagentRefusal(read2, request, standing?.source ?? "no-channel");
5977
+ }
5978
+ if (await this.approval.request(request)) return this.resolved(target, spent.turn);
5979
+ this.refund(spent.turn);
5980
+ return { allowed: false, reason: read2.kind === "fetch" ? `Fetch not approved: ${request.scope}. Continue without it, or ask the user to approve this origin or add it to the planner allowlist.` : "Web search not approved. Continue without it, or fetch a documentation URL you already know." };
5981
+ }
5982
+ subagentRefusal(read2, request, source) {
5983
+ const label = read2.kind === "fetch" ? "Fetch" : "Web search";
5984
+ if (source === "mode") {
5985
+ return this.refuse(request, source, `${label} refused: ${request.scope} is not covered by the planner allowlist in allowlist mode. Note the gap in your digest; the user must add a standing approval or change the approval mode.`);
5986
+ }
5987
+ if (source === "remembered") {
5988
+ return this.refuse(request, source, `${label} refused: ${request.scope} was previously refused in this session. Note the gap in your digest; the user must add a standing approval or start a new session.`);
5989
+ }
5990
+ this.approval.record?.(request, false, source);
5991
+ return { allowed: false, reason: read2.kind === "fetch" ? `Fetch refused: ${request.scope} is not approved in this session, and research agents run in the background where nothing can be approved. Note the gap in your digest; the planner can fetch it directly.` : "Web search refused: research agents run in the background where nothing can be approved. Note the gap in your digest; the planner can search directly." };
5992
+ }
5993
+ /**
5994
+ * Whether a search through `provider` would run without asking anyone, for
5995
+ * a runner whose own search raises no request and is set on or off when
5996
+ * its session starts (ADR-0027). A zero budget disables it; no query is
5997
+ * known yet, so secrets and individual reads cannot be checked or counted.
5998
+ */
5999
+ searchesUnasked(provider) {
6000
+ if (this.budget() === 0) return false;
6001
+ const scope = searchScope(provider);
6002
+ const list = this.approval.allowlist?.();
6003
+ if (list && scopeExcluded(list, scope)) return false;
6004
+ return this.approval.grantsUnasked?.({ kind: "web_search", scope }) ?? false;
6005
+ }
6006
+ /** The last check, the one that leaves the machine: every DNS answer for the host must be public. */
6007
+ async resolved(target, turn) {
6008
+ if (!target.resolve) return ALLOWED;
6009
+ const egress = await vetEgress(target.resolve.hostname, target.resolve.port, this.resolveHost);
6010
+ if (egress.ok) return ALLOWED;
6011
+ this.refund(turn);
6012
+ return this.refuse(target.request, "guard", guardRefusal(egress.reason));
6013
+ }
6014
+ refuse(request, source, reason) {
6015
+ this.approval.record?.(request, false, source);
6016
+ return { allowed: false, reason };
6017
+ }
6018
+ vetFetch(raw) {
6019
+ const label = "Fetch";
6020
+ const request = (subject, scope, origin = scope) => ({ kind: "url_fetch", subject, scope, detail: `Planner research wants to fetch ${origin}` });
6021
+ if (this.carriesSecret([raw])) {
6022
+ return { label, request: request(WITHHELD, WITHHELD), refused: { source: "secret", reason: secretRefusal(label, "URL") } };
6023
+ }
6024
+ let url;
6025
+ try {
6026
+ url = new URL(raw);
6027
+ } catch {
6028
+ return { label, request: request(raw, raw), refused: { source: "guard", reason: "Fetch refused: not a valid URL." } };
6029
+ }
6030
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
6031
+ return { label, request: request(raw, raw), refused: { source: "guard", reason: "Fetch refused: only http and https URLs can be fetched." } };
6032
+ }
6033
+ const fetchRequest = request(raw, `${url.origin}/*`, url.origin);
6034
+ const egress = vetEgressUnresolved(url.hostname, url.port);
6035
+ if (egress && !egress.ok) {
6036
+ return { label, request: fetchRequest, refused: { source: "guard", reason: guardRefusal(egress.reason) } };
6037
+ }
6038
+ return { label, request: fetchRequest, ...egress ? {} : { resolve: { hostname: url.hostname, port: url.port } } };
6039
+ }
6040
+ vetSearch({ query, provider, providerLabel, note }) {
6041
+ const label = "Web search";
6042
+ const detail = `Planner research wants to search the web with ${providerLabel ?? provider}${providerLabel ? "" : ", a third party that receives the query"}${note ? `. ${note}` : ""}`;
6043
+ const request = (subject) => ({ kind: "web_search", subject, scope: searchScope(provider), detail });
6044
+ if (this.carriesSecret([query])) {
6045
+ return { label, request: request(WITHHELD), refused: { source: "secret", reason: secretRefusal(label, "query") } };
6046
+ }
6047
+ return { label, request: request(query) };
6048
+ }
6049
+ spend() {
6050
+ const turn = this.turn();
6051
+ if (this.spent.turn !== turn) this.spent = { turn, reads: 0 };
6052
+ if (this.spent.reads >= this.budget()) return void 0;
6053
+ this.spent.reads += 1;
6054
+ return { turn };
6055
+ }
6056
+ refund(turn) {
6057
+ if (this.spent.turn === turn && this.spent.reads > 0) this.spent.reads -= 1;
6058
+ }
6059
+ carriesSecret(texts) {
6060
+ return carriesSecret(texts, [...this.secrets(), ...sensitiveEnvValues(this.env())]);
6061
+ }
6062
+ };
6063
+ var WITHHELD = "(withheld: it contains a secret)";
6064
+ function searchScope(provider) {
6065
+ return `web_search:${provider}`;
6066
+ }
6067
+ function guardRefusal(reason) {
6068
+ return `Fetch refused: ${reason}. Private, local and non-web addresses are never fetched.`;
6069
+ }
6070
+ function secretRefusal(label, what) {
6071
+ return `${label} refused: the ${what} contains what looks like a secret (an API key, token or credential). Never put credentials in a ${what}; continue without it.`;
6072
+ }
6073
+
6074
+ // src/services/DuckDuckGoSearchProvider.ts
6075
+ var SEARCH_ENDPOINT = "https://lite.duckduckgo.com/lite/?q=";
6076
+ var COOLDOWN_BASE_MS = 3e4;
6077
+ var COOLDOWN_MAX_MS = 5 * 6e4;
6078
+ var CHALLENGE_MARKERS = [
6079
+ /<form\b[^>]*\bid\s*=\s*["']challenge-form["']/i,
6080
+ /<form\b[^>]*\baction\s*=\s*["'][^"']*\/anomaly\.js\b/i,
6081
+ /<div\b[^>]*\bclass\s*=\s*["'][^"']*\banomaly-modal/i
6082
+ ];
6083
+ function looksLikeChallenge(html) {
6084
+ return CHALLENGE_MARKERS.some((marker2) => marker2.test(html));
6085
+ }
6086
+ function decodeEntities(text2) {
6087
+ return text2.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16))).replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec))).replace(/&nbsp;/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
6088
+ }
6089
+ function plainText(html) {
6090
+ return decodeEntities(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
6091
+ }
6092
+ function attributesOf(tag) {
6093
+ const attrs = /* @__PURE__ */ new Map();
6094
+ const pattern = /([a-z_:][-\w:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi;
6095
+ for (let m = pattern.exec(tag); m; m = pattern.exec(tag)) {
6096
+ attrs.set(m[1].toLowerCase(), m[2] ?? m[3] ?? m[4] ?? "");
6097
+ }
6098
+ return attrs;
6099
+ }
6100
+ var RESULT_LINK_CLASS = /(?:^|\s)result(?:-link|__a)(?:\s|$)/;
6101
+ var SNIPPET_ELEMENT = /<(\w+)\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bresult(?:-snippet|__snippet)\b)[^>]*>([\s\S]*?)<\/\1>/i;
6102
+ function resolveResultUrl(href) {
6103
+ let url;
6104
+ try {
6105
+ url = new URL(href, "https://duckduckgo.com/");
6106
+ } catch {
6107
+ return void 0;
6108
+ }
6109
+ if (url.hostname === "duckduckgo.com" || url.hostname.endsWith(".duckduckgo.com")) {
6110
+ const target = url.pathname === "/l/" ? url.searchParams.get("uddg") : null;
6111
+ if (!target) return void 0;
6112
+ try {
6113
+ url = new URL(target);
6114
+ } catch {
6115
+ return void 0;
6116
+ }
6117
+ }
6118
+ return url.protocol === "https:" || url.protocol === "http:" ? url.href : void 0;
6119
+ }
6120
+ function parseSearchHits(html) {
6121
+ const links = [];
6122
+ const anchor = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi;
6123
+ for (let m = anchor.exec(html); m; m = anchor.exec(html)) {
6124
+ const attrs = attributesOf(m[1]);
6125
+ const href = attrs.get("href");
6126
+ if (!href || !RESULT_LINK_CLASS.test(attrs.get("class") ?? "")) continue;
6127
+ links.push({ start: m.index, end: m.index + m[0].length, href: decodeEntities(href), title: plainText(m[2]) });
6128
+ }
6129
+ const hits = [];
6130
+ links.forEach((link, i) => {
6131
+ const url = resolveResultUrl(link.href);
6132
+ if (!url || !link.title) return;
6133
+ const region = html.slice(link.end, links[i + 1]?.start ?? html.length);
6134
+ hits.push({ title: link.title, url, snippet: snippetIn(region) });
6135
+ });
6136
+ return hits;
6137
+ }
6138
+ function snippetIn(region) {
6139
+ const match = SNIPPET_ELEMENT.exec(region);
6140
+ return match ? plainText(match[2]) || void 0 : void 0;
6141
+ }
6142
+ var DuckDuckGoSearchProvider = class {
6143
+ id = "duckduckgo";
6144
+ name = "DuckDuckGo";
6145
+ failures = 0;
6146
+ cooldownUntil = 0;
6147
+ now;
6148
+ constructor(now = Date.now) {
6149
+ this.now = now;
6150
+ }
6151
+ coolingDown() {
6152
+ return this.now() < this.cooldownUntil;
6153
+ }
6154
+ async search(query, get) {
6155
+ const page = await get(SEARCH_ENDPOINT + encodeURIComponent(query));
6156
+ if (!page.ok) {
6157
+ if (page.status === 429 || page.status === 403) return this.block(`HTTP ${page.status}`);
6158
+ return { kind: "error", error: page.error };
6159
+ }
6160
+ if (page.status === 202) return this.block("HTTP 202");
6161
+ const hits = parseSearchHits(page.body).slice(0, 10);
6162
+ if (hits.length === 0) return looksLikeChallenge(page.body) ? this.block("a challenge page") : { kind: "none" };
6163
+ this.failures = 0;
6164
+ this.cooldownUntil = 0;
6165
+ return { kind: "hits", hits };
6166
+ }
6167
+ block(reason) {
6168
+ if (!this.coolingDown()) {
6169
+ this.failures += 1;
6170
+ const backoff = Math.min(COOLDOWN_BASE_MS * 2 ** (this.failures - 1), COOLDOWN_MAX_MS);
6171
+ this.cooldownUntil = this.now() + backoff;
6172
+ }
6173
+ return { kind: "blocked", reason };
6174
+ }
6175
+ };
6176
+ var sharedSearchProvider = new DuckDuckGoSearchProvider();
6177
+
6178
+ // src/services/WebResponseCache.ts
6179
+ var DEFAULT_TTL_MS = 5 * 6e4;
6180
+ var DEFAULT_MAX_ENTRIES = 100;
6181
+ var DEFAULT_MAX_BYTES = 2e7;
6182
+ var WebResponseCache = class {
6183
+ entries = /* @__PURE__ */ new Map();
6184
+ ttlMs;
6185
+ maxEntries;
6186
+ maxBytes;
6187
+ now;
6188
+ bytes = 0;
6189
+ constructor(opts = {}) {
6190
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
6191
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
6192
+ this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
6193
+ this.now = opts.now ?? Date.now;
6194
+ }
6195
+ getFetch(url) {
6196
+ return this.get(`get\0${url}`);
6197
+ }
6198
+ /** `maxAgeMs` is the response's own freshness; it shortens the TTL, never extends it. */
6199
+ setFetch(url, page, maxAgeMs = Infinity) {
6200
+ this.set(`get\0${url}`, page, Buffer.byteLength(page.body), maxAgeMs);
6201
+ }
6202
+ getSearch(providerAndQuery) {
6203
+ return this.get(`search\0${providerAndQuery}`);
6204
+ }
6205
+ setSearch(providerAndQuery, hits) {
6206
+ const bytes = hits.reduce((sum, h) => sum + Buffer.byteLength(h.title) + Buffer.byteLength(h.url) + Buffer.byteLength(h.snippet ?? ""), 0);
6207
+ this.set(`search\0${providerAndQuery}`, hits, bytes, Infinity);
6208
+ }
6209
+ get(key) {
6210
+ const entry = this.entries.get(key);
6211
+ if (!entry) return void 0;
6212
+ if (entry.expiresAt <= this.now()) {
6213
+ this.delete(key);
6214
+ return void 0;
6215
+ }
6216
+ this.entries.delete(key);
6217
+ this.entries.set(key, entry);
6218
+ return entry.value;
6219
+ }
6220
+ set(key, value, bytes, maxAgeMs) {
6221
+ this.delete(key);
6222
+ if (bytes > this.maxBytes) return;
6223
+ this.entries.set(key, { value, bytes, expiresAt: this.now() + Math.min(this.ttlMs, maxAgeMs) });
6224
+ this.bytes += bytes;
6225
+ while (this.entries.size > this.maxEntries || this.bytes > this.maxBytes) {
6226
+ const oldest = this.entries.keys().next().value;
6227
+ if (oldest === void 0) break;
6228
+ this.delete(oldest);
6229
+ }
6230
+ }
6231
+ delete(key) {
6232
+ const entry = this.entries.get(key);
6233
+ if (!entry) return;
6234
+ this.entries.delete(key);
6235
+ this.bytes -= entry.bytes;
6236
+ }
6237
+ };
6238
+ var sharedWebResponseCache = new WebResponseCache();
6239
+
6240
+ // src/services/HttpWebFetcher.ts
6241
+ var http = __toESM(require("node:http"));
6242
+ var https = __toESM(require("node:https"));
6243
+ var import_node_stream = require("node:stream");
6244
+ var zlib = __toESM(require("node:zlib"));
6245
+ var DEFAULT_MAX_CHARS = 4e4;
6246
+ var DEFAULT_TIMEOUT_MS = 2e4;
6247
+ var MAX_BODY_BYTES = 2e6;
6248
+ var MAX_REDIRECTS = 5;
6249
+ var USER_AGENT = "Mozilla/5.0 (compatible; Ordewell-Planner/1.0; +https://github.com/ordewell)";
6250
+ var SEARCH_COOLDOWN_OUTPUT = "Web search is temporarily unavailable: the search provider backed off after pushing back on automated queries. Do not retry now; fetch a documentation URL you already know instead.";
6251
+ function decodeEntities2(text2) {
6252
+ return text2.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&#x27;/g, "'");
6253
+ }
6254
+ function htmlToText(html) {
6255
+ return decodeEntities2(
6256
+ html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ").replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, " ").replace(/<\/(p|div|li|h[1-6]|tr|section|article)>/gi, "\n").replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ")
6257
+ ).replace(/[ \t\f\v]+/g, " ").replace(/\n\s*\n\s*\n+/g, "\n\n").split("\n").map((line) => line.trim()).join("\n").trim();
6258
+ }
6259
+ function formatHits(hits) {
6260
+ const output = hits.map((hit, i) => `${i + 1}. ${hit.title}
6261
+ ${hit.url}${hit.snippet ? `
6262
+ ${hit.snippet}` : ""}`).join("\n\n");
6263
+ return { success: true, output, truncated: false };
6264
+ }
6265
+ function sanitizeError(err) {
6266
+ const msg = err instanceof Error ? err.message : String(err);
6267
+ if (/abort/i.test(msg)) return "timed out";
6268
+ if (/ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH/i.test(msg)) return "host not reachable";
6269
+ if (/ENOTFOUND|getaddrinfo/i.test(msg)) return "host not found";
6270
+ return msg.replace(/https?:\/\/\S+/g, "<url>");
6271
+ }
6272
+ function sharedLifetime(cacheControl, setCookie) {
6273
+ if (setCookie || /(?:^|,)\s*(?:no-store|private|no-cache)\b/i.test(cacheControl)) return 0;
6274
+ const ages = [...cacheControl.matchAll(/(?:^|,)\s*(?:s-)?max-age\s*=\s*"?(\d+)/gi)].map((m) => Number(m[1]) * 1e3);
6275
+ return ages.length ? Math.min(...ages) : Infinity;
6276
+ }
6277
+ var HttpWebFetcher = class _HttpWebFetcher {
6278
+ policy;
6279
+ fetchImpl;
6280
+ maxChars;
6281
+ timeoutMs;
6282
+ resolveHost;
6283
+ cache;
6284
+ searchProvider;
6285
+ prompting;
6286
+ constructor(opts) {
6287
+ this.policy = opts.policy;
6288
+ this.maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
6289
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
6290
+ this.resolveHost = opts.resolveHost ?? resolveAll;
6291
+ this.fetchImpl = opts.fetchImpl ?? pinnedTransport(guardedLookup(this.resolveHost));
6292
+ this.cache = opts.cache ?? new WebResponseCache();
6293
+ this.searchProvider = opts.searchProvider ?? new DuckDuckGoSearchProvider();
6294
+ this.prompting = opts.prompting ?? true;
6295
+ }
6296
+ decide(read2) {
6297
+ return this.policy.decide(read2, this.decideOptions);
6298
+ }
6299
+ get decideOptions() {
6300
+ return { prompting: this.prompting };
6301
+ }
6302
+ forSubagent() {
6303
+ return new _HttpWebFetcher({
6304
+ policy: this.policy,
6305
+ fetchImpl: this.fetchImpl,
6306
+ maxChars: this.maxChars,
6307
+ timeoutMs: this.timeoutMs,
6308
+ resolveHost: this.resolveHost,
6309
+ cache: this.cache,
6310
+ searchProvider: this.searchProvider,
6311
+ prompting: false
6312
+ });
6313
+ }
6314
+ searchesUnasked(provider) {
6315
+ return this.policy.searchesUnasked?.(provider) ?? false;
6316
+ }
6317
+ async fetch(url) {
6318
+ const decision = await this.policy.decide({ kind: "fetch", url }, this.decideOptions);
6319
+ if (!decision.allowed) return { success: false, output: decision.reason, truncated: false };
6320
+ return this.get(url, "Fetch");
6321
+ }
6322
+ async search(query) {
6323
+ const provider = this.searchProvider;
6324
+ if (provider.coolingDown()) return { success: false, output: SEARCH_COOLDOWN_OUTPUT, truncated: false };
6325
+ const decision = await this.policy.decide({ kind: "search", query, provider: provider.id }, this.decideOptions);
6326
+ if (!decision.allowed) return { success: false, output: decision.reason, truncated: false };
6327
+ const cacheKey = `${provider.id}\0${query}`;
6328
+ const cached2 = this.cache.getSearch(cacheKey);
6329
+ if (cached2) return formatHits(cached2);
6330
+ const outcome = await provider.search(query, (url) => this.getSearchPage(url));
6331
+ switch (outcome.kind) {
6332
+ case "hits":
6333
+ this.cache.setSearch(cacheKey, outcome.hits);
6334
+ return formatHits(outcome.hits);
6335
+ case "none":
6336
+ return {
6337
+ success: false,
6338
+ output: `No results could be parsed for "${query}". Search is best-effort; if you already know a documentation URL, use the fetch tool directly.`,
6339
+ truncated: false
6340
+ };
6341
+ case "blocked":
6342
+ return { success: false, output: SEARCH_COOLDOWN_OUTPUT, truncated: false };
6343
+ case "error":
6344
+ return { success: false, output: `Web search failed: ${outcome.error}`, truncated: false };
6345
+ }
6346
+ }
6347
+ async get(url, label) {
6348
+ const cached2 = this.cache.getFetch(url);
6349
+ if (cached2) return this.render(label, cached2.body, cached2.isHtml, false);
6350
+ const page = await this.request(url);
6351
+ if (!page.ok) return { success: false, output: `${label} failed: ${page.error}`, truncated: false };
6352
+ if (page.cacheFor > 0 && !page.truncated && page.origin === new URL(url).origin) {
6353
+ this.cache.setFetch(url, { body: page.body, isHtml: page.isHtml }, page.cacheFor);
6354
+ }
6355
+ return this.render(label, page.body, page.isHtml, page.truncated);
6356
+ }
6357
+ render(label, body, isHtml, byteTruncated) {
6358
+ const text2 = isHtml ? htmlToText(body) : body;
6359
+ if (byteTruncated || text2.length > this.maxChars) {
6360
+ const sliced = text2.slice(0, this.maxChars);
6361
+ return {
6362
+ success: true,
6363
+ output: `${sliced}
6364
+
6365
+ [... truncated to ${this.maxChars} chars, total ${text2.length}${byteTruncated ? "+, body was byte-capped at the source" : ""}]`,
6366
+ truncated: true
6367
+ };
6368
+ }
6369
+ return { success: true, output: text2, truncated: false };
6370
+ }
6371
+ async getSearchPage(url) {
6372
+ const page = await this.request(url);
6373
+ return page.ok ? { ok: true, status: page.status, body: page.body } : { ok: false, status: page.status, error: page.error };
6374
+ }
6375
+ async request(url) {
6376
+ let current = new URL(url);
6377
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
6378
+ const result = await this.hop(current);
6379
+ if (!("redirect" in result)) return result;
6380
+ const refused3 = await this.refuseRedirect(current, result.redirect);
6381
+ if (refused3) return { ok: false, status: result.status, error: refused3 };
6382
+ current = result.redirect;
6383
+ }
6384
+ return { ok: false, error: `too many redirects (>${MAX_REDIRECTS})` };
6385
+ }
6386
+ /**
6387
+ * Why a redirect must not be followed, or `undefined` if it may. The policy
6388
+ * decided only the first URL, so a hop to another origin is a new read with
6389
+ * the same decision; otherwise a redirect would carry the fetch past an
6390
+ * exclusion, a Guarded grant or the budget.
6391
+ */
6392
+ async refuseRedirect(from, to) {
6393
+ if (to.protocol !== "https:" && to.protocol !== "http:") return "redirect to a non-HTTP scheme";
6394
+ if (from.protocol === "https:" && to.protocol === "http:") return "redirect from https to plain http";
6395
+ if (to.origin === from.origin) return void 0;
6396
+ const decision = await this.policy.decide({ kind: "fetch", url: to.href }, this.decideOptions);
6397
+ return decision.allowed ? void 0 : `the page redirected to another origin, which was refused. ${decision.reason}`;
6398
+ }
6399
+ async hop(url) {
6400
+ const controller = new AbortController();
6401
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
6402
+ try {
6403
+ const res = await this.fetchImpl(url.href, {
6404
+ signal: controller.signal,
6405
+ headers: { "user-agent": USER_AGENT, accept: "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8" },
6406
+ redirect: "manual"
6407
+ });
6408
+ if (res.status >= 300 && res.status < 400) {
6409
+ const location = res.headers.get("location");
6410
+ if (!location) return { ok: false, status: res.status, error: `redirect (${res.status}) with no Location` };
6411
+ return { redirect: new URL(location, url), status: res.status };
6412
+ }
6413
+ if (!res.ok) return { ok: false, status: res.status, error: `HTTP ${res.status}` };
6414
+ const contentType = res.headers.get("content-type") ?? "";
6415
+ const cacheFor = sharedLifetime(res.headers.get("cache-control") ?? "", res.headers.get("set-cookie"));
6416
+ const { body, truncated } = await readBounded(res, MAX_BODY_BYTES);
6417
+ return { ok: true, status: res.status, origin: url.origin, body, isHtml: /html|xml/i.test(contentType), truncated, cacheFor };
6418
+ } catch (err) {
6419
+ return { ok: false, error: sanitizeError(err) };
6420
+ } finally {
6421
+ clearTimeout(timer);
6422
+ controller.abort();
6423
+ }
6424
+ }
6425
+ };
6426
+ function isAsyncIterable(value) {
6427
+ return typeof value === "object" && value !== null && Symbol.asyncIterator in value;
6428
+ }
6429
+ async function readBounded(res, maxBytes) {
6430
+ return isAsyncIterable(res.body) ? readFromIterable(res.body, maxBytes) : readFromText(res, maxBytes);
6431
+ }
6432
+ async function readFromIterable(source, maxBytes) {
6433
+ const chunks = [];
6434
+ let total = 0;
6435
+ let truncated = false;
6436
+ for await (const chunk of source) {
6437
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
6438
+ if (total + buf.length > maxBytes) {
6439
+ chunks.push(buf.subarray(0, maxBytes - total));
6440
+ total = maxBytes;
6441
+ truncated = true;
6442
+ break;
6443
+ }
6444
+ chunks.push(buf);
6445
+ total += buf.length;
6446
+ }
6447
+ return { body: Buffer.concat(chunks).toString("utf8"), truncated };
6448
+ }
6449
+ async function readFromText(res, maxBytes) {
6450
+ const text2 = await res.text();
6451
+ if (Buffer.byteLength(text2, "utf8") <= maxBytes) return { body: text2, truncated: false };
6452
+ return { body: Buffer.from(text2, "utf8").subarray(0, maxBytes).toString("utf8"), truncated: true };
6453
+ }
6454
+ function headerGetter(res) {
6455
+ return {
6456
+ get: (name) => {
6457
+ const value = res.headers[name.toLowerCase()];
6458
+ if (value === void 0) return null;
6459
+ return Array.isArray(value) ? value.join(", ") : value;
6460
+ }
6461
+ };
6462
+ }
6463
+ function decodedBody(res) {
6464
+ const encoding = String(res.headers["content-encoding"] ?? "").trim().toLowerCase();
6465
+ const decoder = encoding === "gzip" || encoding === "x-gzip" ? zlib.createGunzip() : encoding === "deflate" ? zlib.createInflate() : encoding === "br" ? zlib.createBrotliDecompress() : void 0;
6466
+ if (!decoder) return res;
6467
+ (0, import_node_stream.pipeline)(res, decoder, () => void 0);
6468
+ return decoder;
6469
+ }
6470
+ function pinnedTransport(lookup2) {
6471
+ return (url, init) => new Promise((resolve9, reject) => {
6472
+ const target = new URL(url);
6473
+ const client = target.protocol === "https:" ? https : http;
6474
+ const req = client.request(
6475
+ {
6476
+ protocol: target.protocol,
6477
+ hostname: target.hostname.replace(/^\[|\]$/g, ""),
6478
+ port: target.port || void 0,
6479
+ path: `${target.pathname}${target.search}`,
6480
+ method: "GET",
6481
+ // Sent, and decoded below, because the platform fetch did the same and
6482
+ // some hosts challenge clients that don't.
6483
+ headers: { ...init.headers, "accept-encoding": "gzip, deflate, br" },
6484
+ signal: init.signal,
6485
+ lookup: lookup2,
6486
+ // A pooled keep-alive socket would be reused without a fresh lookup.
6487
+ agent: false
6488
+ },
6489
+ (res) => {
6490
+ res.on("error", () => void 0);
6491
+ const body = decodedBody(res);
6492
+ resolve9({
6493
+ ok: res.statusCode !== void 0 && res.statusCode >= 200 && res.statusCode < 300,
6494
+ status: res.statusCode ?? 0,
6495
+ headers: headerGetter(res),
6496
+ body,
6497
+ text: async () => (await readFromIterable(body, Infinity)).body
6498
+ });
6499
+ }
6500
+ );
6501
+ req.on("error", reject);
6502
+ req.end();
6503
+ });
6504
+ }
6505
+
5337
6506
  // src/services/PendingApprovals.ts
5338
- var DEFAULT_TIMEOUT_MS = 5 * 6e4;
6507
+ var DEFAULT_TIMEOUT_MS2 = 5 * 6e4;
5339
6508
  var counter = 0;
5340
6509
  var PendingApprovals = class {
5341
6510
  constructor(opts = {}) {
@@ -5356,7 +6525,7 @@ var PendingApprovals = class {
5356
6525
  return Promise.resolve(refused3);
5357
6526
  }
5358
6527
  const pending = { id, request, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
5359
- return new Promise((resolve6) => {
6528
+ return new Promise((resolve9) => {
5360
6529
  const settle = (decision) => {
5361
6530
  const entry = this.entries.get(id);
5362
6531
  if (!entry) return;
@@ -5364,11 +6533,11 @@ var PendingApprovals = class {
5364
6533
  this.entries.delete(id);
5365
6534
  options.onDecision?.(decision);
5366
6535
  this.opts.onSettled?.(id, isGranted(decision), { request, decision });
5367
- resolve6(decision);
6536
+ resolve9(decision);
5368
6537
  };
5369
6538
  let timer = null;
5370
6539
  if (!options.noTimeout) {
5371
- timer = setTimeout(() => settle({ decision: "deny" }), this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
6540
+ timer = setTimeout(() => settle({ decision: "deny" }), this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2);
5372
6541
  timer.unref?.();
5373
6542
  }
5374
6543
  this.entries.set(id, { pending, settle, timer });
@@ -5491,8 +6660,8 @@ function firstLineOf(command) {
5491
6660
  function text(value) {
5492
6661
  return typeof value === "string" ? value : "";
5493
6662
  }
5494
- function searchArg(pattern, path27, include = "") {
5495
- return `${pattern}${path27 ? ` in ${path27}` : ""}${include ? ` (${include})` : ""}`;
6663
+ function searchArg(pattern, path32, include = "") {
6664
+ return `${pattern}${path32 ? ` in ${path32}` : ""}${include ? ` (${include})` : ""}`;
5496
6665
  }
5497
6666
  function hintArg(args) {
5498
6667
  for (const field of HINT_FIELDS) {
@@ -5593,8 +6762,8 @@ function userMessage(id, text2, skills, turnId) {
5593
6762
  const block = settledMessage(id, "user", text2, turnId);
5594
6763
  return skills.length > 0 ? { ...block, skills: skills.map((s) => s.name) } : block;
5595
6764
  }
5596
- function skillLoadNotice({ invokedBy, name, source, path: path27, attaches }) {
5597
- return { invokedBy, name, source, path: path27, ...attaches ? { attaches } : {} };
6765
+ function skillLoadNotice({ invokedBy, name, source, path: path32, attaches }) {
6766
+ return { invokedBy, name, source, path: path32, ...attaches ? { attaches } : {} };
5598
6767
  }
5599
6768
  var LOAD_SKILL_TOOL = /(?:^|_)load_skill$/;
5600
6769
  function surfaceStep(step) {
@@ -5611,8 +6780,8 @@ function loadedSkillName(args) {
5611
6780
  return void 0;
5612
6781
  }
5613
6782
  }
5614
- function skillLoadBlock(id, { invokedBy, name, source, path: path27, attaches }, turnId) {
5615
- return { type: "skill_load", id, invokedBy, name, source, path: path27, ...attaches ? { attaches } : {}, ...turnId ? { turnId } : {} };
6783
+ function skillLoadBlock(id, { invokedBy, name, source, path: path32, attaches }, turnId) {
6784
+ return { type: "skill_load", id, invokedBy, name, source, path: path32, ...attaches ? { attaches } : {}, ...turnId ? { turnId } : {} };
5616
6785
  }
5617
6786
  function subagentBlock(id, { toolCallId, model, usage, turnId, ...fields }) {
5618
6787
  return {
@@ -7079,6 +8248,9 @@ var BaseConfig = class {
7079
8248
  get approvalMode() {
7080
8249
  return parseApprovalModeSetting(process.env.ORDEWELL_APPROVAL_MODE);
7081
8250
  }
8251
+ get webReadBudget() {
8252
+ return nonNegativeInt(process.env.ORDEWELL_WEB_READ_BUDGET, DEFAULT_WEB_READ_BUDGET);
8253
+ }
7082
8254
  get approvalDefaults() {
7083
8255
  return process.env.ORDEWELL_APPROVAL_DEFAULTS !== "false" && process.env.ORDEWELL_APPROVAL_DEFAULTS !== "0";
7084
8256
  }
@@ -7298,28 +8470,28 @@ function posixBinDirs() {
7298
8470
  ];
7299
8471
  }
7300
8472
  function windowsBinDirs(env, home) {
7301
- const join26 = (...parts) => path5.win32.join(...parts);
7302
- const under = (base, ...parts) => base ? [join26(base, ...parts)] : [];
8473
+ const join31 = (...parts) => path5.win32.join(...parts);
8474
+ const under = (base, ...parts) => base ? [join31(base, ...parts)] : [];
7303
8475
  const appData = env.APPDATA;
7304
8476
  const localAppData = env.LOCALAPPDATA;
7305
8477
  const programData = env.ProgramData;
7306
8478
  return [
7307
8479
  // Native per-user installers, PowerShell-driven and otherwise.
7308
- join26(home, ".local", "bin"),
7309
- join26(home, ".opencode", "bin"),
7310
- join26(home, ".claude", "bin"),
7311
- join26(home, ".codex", "bin"),
8480
+ join31(home, ".local", "bin"),
8481
+ join31(home, ".opencode", "bin"),
8482
+ join31(home, ".claude", "bin"),
8483
+ join31(home, ".codex", "bin"),
7312
8484
  ...under(localAppData, "Programs", "claude"),
7313
8485
  // Node package managers. npm's global prefix is `%APPDATA%\npm`; the
7314
8486
  // literal spelling is kept as a fallback for a host that does not export
7315
8487
  // APPDATA (a service account, a stripped CI container).
7316
8488
  ...under(appData, "npm"),
7317
- join26(home, "AppData", "Roaming", "npm"),
8489
+ join31(home, "AppData", "Roaming", "npm"),
7318
8490
  ...under(localAppData, "pnpm"),
7319
8491
  ...under(localAppData, "Yarn", "bin"),
7320
- join26(home, ".bun", "bin"),
8492
+ join31(home, ".bun", "bin"),
7321
8493
  // Windows package managers.
7322
- join26(home, "scoop", "shims"),
8494
+ join31(home, "scoop", "shims"),
7323
8495
  ...under(env.SCOOP, "shims"),
7324
8496
  ...under(env.SCOOP_GLOBAL ?? programData, "scoop", "shims"),
7325
8497
  ...under(programData, "chocolatey", "bin"),
@@ -7329,7 +8501,7 @@ function windowsBinDirs(env, home) {
7329
8501
  ...under(localAppData, "Microsoft", "WindowsApps"),
7330
8502
  // Version managers. Volta's Windows home is not the POSIX `~/.volta`.
7331
8503
  ...under(localAppData, "Volta", "bin"),
7332
- join26(home, ".volta", "bin")
8504
+ join31(home, ".volta", "bin")
7333
8505
  ];
7334
8506
  }
7335
8507
  function wellKnownBinDirs(deps = {}) {
@@ -9431,7 +10603,7 @@ function buildResearchToolsPrompt() {
9431
10603
  }
9432
10604
  function buildSubagentSystemPrompt() {
9433
10605
  return [
9434
- "You are a read-only research subagent working for a project planner. You receive one self-contained research task, explore the workspace with your tools, and reply with a single digest. You never modify files, never fetch URLs, and cannot spawn further agents.",
10606
+ "You are a read-only research subagent working for a project planner. You receive one self-contained research task, explore the workspace with your tools, and reply with a single digest. You never modify files and cannot spawn further agents. You may fetch or search the web, but a read that would need approval is refused \u2014 note the gap in your digest rather than retrying.",
9435
10607
  "",
9436
10608
  "Work efficiently: batch independent tool calls in one reply; prefer glob/grep to locate, then read only what matters. Your tool budget is small \u2014 stop exploring when you can answer the task.",
9437
10609
  "",
@@ -10016,12 +11188,12 @@ function buildConflictRepairPrompt(task, conflict, integrationBranch) {
10016
11188
  const files = conflict.conflictFiles ?? [];
10017
11189
  const asked = `What the task was asked to do:
10018
11190
  ${task.prompt ?? task.description}`;
10019
- const resolve6 = "resolve every conflict so that both sides' intent survives: keep the work already integrated and add what the task contributed.";
11191
+ const resolve9 = "resolve every conflict so that both sides' intent survives: keep the work already integrated and add what the task contributed.";
10020
11192
  const repos = conflict.repos.filter((repo) => repo !== SELF_REPO);
10021
11193
  if (repos.length === 0) {
10022
11194
  return [
10023
11195
  `Task #${task.order} "${task.title}" passed, but merging its branch \`${branch}\` into \`${integrationBranch}\` conflicted${files.length > 0 ? ` in ${files.join(", ")}` : ""}. None of its work has landed yet.`,
10024
- `This is the task's own worktree, on \`${branch}\`, with its work committed. Run \`git merge --no-edit ${integrationBranch}\` here and ${resolve6} Never drop either side wholesale, and never abort the merge or reset it away.`,
11196
+ `This is the task's own worktree, on \`${branch}\`, with its work committed. Run \`git merge --no-edit ${integrationBranch}\` here and ${resolve9} Never drop either side wholesale, and never abort the merge or reset it away.`,
10025
11197
  "Build and test the result the way this project does, commit the merge, and only then call `task_complete`.",
10026
11198
  "",
10027
11199
  asked
@@ -10030,7 +11202,7 @@ ${task.prompt ?? task.description}`;
10030
11202
  const stoppedIn = conflict.conflictRepo ?? repos[0];
10031
11203
  return [
10032
11204
  `Task #${task.order} "${task.title}" passed, but landing its branch \`${branch}\` on \`${integrationBranch}\` conflicted in ${stoppedIn}${files.length > 0 ? ` (${files.join(", ")})` : ""}. A task lands in every repository it changed or in none, so none of its work has landed yet.`,
10033
- `This is the task's own workspace, with each repository at its usual path on \`${branch}\` and the task's work committed. In each repository the task changed \u2014 ${repos.join(", ")} \u2014 run \`git merge --no-edit ${integrationBranch}\` inside that repository's directory, and ${resolve6} Never drop either side wholesale, and never abort a merge or reset it away.`,
11205
+ `This is the task's own workspace, with each repository at its usual path on \`${branch}\` and the task's work committed. In each repository the task changed \u2014 ${repos.join(", ")} \u2014 run \`git merge --no-edit ${integrationBranch}\` inside that repository's directory, and ${resolve9} Never drop either side wholesale, and never abort a merge or reset it away.`,
10034
11206
  "Build and test the result the way this project does, commit the merge in each repository, and only then call `task_complete`.",
10035
11207
  "",
10036
11208
  asked
@@ -11387,7 +12559,7 @@ var RESEARCH_TOOLS = [
11387
12559
  },
11388
12560
  {
11389
12561
  name: "fetch",
11390
- description: "Fetch content from a URL (HTTP/HTTPS). Use for reading API documentation, library references, or package docs. URL confirmation is required \u2014 the tool may pause while awaiting approval.",
12562
+ description: "Fetch content from a URL (HTTP/HTTPS). Use for reading API documentation, library references, or package docs. Depending on the approval settings, the first fetch from a site may wait for the user to approve it. Private and local addresses are always refused, as is a URL that contains a credential, and each turn has a limited number of web reads; a refusal says why.",
11391
12563
  properties: {
11392
12564
  url: { type: "string", description: "The URL to fetch" }
11393
12565
  },
@@ -11395,16 +12567,15 @@ var RESEARCH_TOOLS = [
11395
12567
  },
11396
12568
  {
11397
12569
  name: "web_search",
11398
- description: "Search the web for result titles, URLs and snippets. When to use: you need docs for an unfamiliar library or API and have no URL yet \u2014 search first, then fetch the most promising result. When NOT to use: anything answerable from the workspace itself (use grep or find_symbol).",
12570
+ description: "Search the web for result titles, URLs and snippets. When to use: you need docs for an unfamiliar library or API and have no URL yet \u2014 search first, then fetch the most promising result. When NOT to use: anything answerable from the workspace itself (use grep or find_symbol). Depending on the approval settings, the first search may wait for the user to approve it; a query that contains a credential is refused, and searches count toward the turn's web reads.",
11399
12571
  properties: {
11400
12572
  query: { type: "string", description: "Search query" }
11401
12573
  },
11402
12574
  required: ["query"]
11403
12575
  }
11404
12576
  ];
11405
- var SUBAGENT_EXCLUDED = /* @__PURE__ */ new Set(["fetch", "web_search"]);
11406
12577
  function subagentToolSpecs() {
11407
- return RESEARCH_TOOLS.filter((t) => !SUBAGENT_EXCLUDED.has(t.name));
12578
+ return RESEARCH_TOOLS;
11408
12579
  }
11409
12580
  function withSpawn() {
11410
12581
  return [...RESEARCH_TOOLS, SPAWN_RESEARCH_AGENT_SPEC];
@@ -11650,9 +12821,9 @@ function summarizeToolCall(tool2, argsJson, toolLabel) {
11650
12821
  const scope = args.include ? ` in ${args.include}` : "";
11651
12822
  return `grep ${ellipsize(String(args.pattern), 40)}${scope}${mode}`;
11652
12823
  }
11653
- const path27 = args.filePath || args.file_path || args.path;
11654
- if (path27) {
11655
- const basename4 = String(path27).split("/").pop() || String(path27);
12824
+ const path32 = args.filePath || args.file_path || args.path;
12825
+ if (path32) {
12826
+ const basename4 = String(path32).split("/").pop() || String(path32);
11656
12827
  return `${display} ${basename4}`;
11657
12828
  }
11658
12829
  if (args.pattern) {
@@ -11667,7 +12838,7 @@ function summarizeToolCall(tool2, argsJson, toolLabel) {
11667
12838
  }
11668
12839
  function classifyOutcome(success, output) {
11669
12840
  if (!success) {
11670
- if (/^Command refused:/.test(output) || /is not available\. You are a read-only research subagent/.test(output)) return "refused";
12841
+ if (/^(Command|Fetch|Web search) refused:/.test(output) || /is not available\. You are a read-only research subagent/.test(output)) return "refused";
11671
12842
  if (/not approved/i.test(output) || /^Access denied:/.test(output) || /needs user approval/i.test(output)) return "denied";
11672
12843
  return "failure";
11673
12844
  }
@@ -11732,7 +12903,7 @@ ${turn.text}`;
11732
12903
  const results = [];
11733
12904
  for (const tc of turn.toolCalls) {
11734
12905
  if (!ALLOWED_TOOL_NAMES.has(tc.name)) {
11735
- const refusal = `Tool "${tc.name}" is not available. You are a read-only research subagent: your only tools are ${[...ALLOWED_TOOL_NAMES].join(", ")}. You cannot fetch URLs, spawn further agents, or modify files. If a specific URL or resource would have answered your question, name it in your digest so the planner can fetch it directly. Otherwise continue with the available tools, then reply with your digest.`;
12906
+ const refusal = `Tool "${tc.name}" is not available. You are a read-only research subagent: your only tools are ${[...ALLOWED_TOOL_NAMES].join(", ")}. You cannot spawn further agents or modify files. If a specific resource would have answered your question, name it in your digest so the planner can fetch it directly. Otherwise continue with the available tools, then reply with your digest.`;
11736
12907
  results.push({ name: tc.name, output: refusal, truncated: false, totalChars: refusal.length, id: tc.id });
11737
12908
  continue;
11738
12909
  }
@@ -11740,7 +12911,7 @@ ${turn.text}`;
11740
12911
  if (tc.name === "read_file" && !("maxBytes" in args) && !("limit" in args)) args.limit = 2e3;
11741
12912
  const toolArgs = JSON.stringify(tc.args);
11742
12913
  deps.onProgress?.({ type: "tool_call", tool: tc.name, toolArgs, toolCallId: tc.id, subagentId: deps.subagentId });
11743
- const res = await executeTool(tc.name, args, fs17, void 0, deps.signal);
12914
+ const res = await executeTool(tc.name, args, fs17, deps.fetcher, deps.signal);
11744
12915
  const output = res.output.length > SUBAGENT_LIMITS.toolOutputMaxChars ? res.output.slice(0, SUBAGENT_LIMITS.toolOutputMaxChars) + `
11745
12916
  [... truncated to ${SUBAGENT_LIMITS.toolOutputMaxChars} chars, total ${res.output.length}]` : res.output;
11746
12917
  const stepEntry = {
@@ -11869,7 +13040,7 @@ var BaseAiService = class _BaseAiService {
11869
13040
  * steering/failed result and the loop continues sequentially: a subagent can
11870
13041
  * never fail a plan or a turn.
11871
13042
  */
11872
- async executeSpawnAgent(args, fs17, onProgress, signal, subagentId) {
13043
+ async executeSpawnAgent(args, fs17, onProgress, signal, subagentId, fetcher) {
11873
13044
  const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
11874
13045
  if (!prompt) {
11875
13046
  return { success: false, output: 'spawn_research_agent requires a non-empty "prompt" string: a self-contained task for the agent, including what its digest must report back.', truncated: false };
@@ -11884,6 +13055,10 @@ var BaseAiService = class _BaseAiService {
11884
13055
  fs: fs17,
11885
13056
  signal,
11886
13057
  subagentId,
13058
+ // The subagent gets web tools only behind a non-prompting gate: no
13059
+ // approval channel exists in the background, so a read that would ask is
13060
+ // refused instead (ADR-0027, ADR-0008 T6).
13061
+ fetcher: fetcher?.forSubagent?.(),
11887
13062
  model: this.config.researchSubagentModel,
11888
13063
  usage: () => usage,
11889
13064
  onProgress: (progress) => {
@@ -11919,7 +13094,7 @@ var BaseAiService = class _BaseAiService {
11919
13094
  const subagentId = (0, import_uuid3.v4)();
11920
13095
  spawnSubagentIds.set(i, subagentId);
11921
13096
  onProgress({ type: "tool_call", tool: toolCalls[i].name, toolArgs: JSON.stringify(toolCalls[i].args), subagentId, toolCallId: toolCalls[i].id });
11922
- precomputed.set(i, await this.executeSpawnAgent({ ...toolCalls[i].args }, fs17, onProgress, signal, subagentId));
13097
+ precomputed.set(i, await this.executeSpawnAgent({ ...toolCalls[i].args }, fs17, onProgress, signal, subagentId, fetcher));
11923
13098
  }) : Promise.resolve(),
11924
13099
  parallelIndexes.length > 1 ? mapWithConcurrency(parallelIndexes, TOOL_ROUND_CONCURRENCY, async (i) => {
11925
13100
  onProgress({ type: "tool_call", tool: toolCalls[i].name, toolArgs: JSON.stringify(toolCalls[i].args), toolCallId: toolCalls[i].id });
@@ -12942,6 +14117,12 @@ var listModelsInput = import_v4.z.object({
12942
14117
  var runCommandInput = import_v4.z.object({
12943
14118
  command: import_v4.z.string().min(1).describe("One shell command line, run from the workspace root.")
12944
14119
  });
14120
+ var fetchInput = import_v4.z.object({
14121
+ url: import_v4.z.string().min(1).describe("The http or https URL to read.")
14122
+ });
14123
+ var webSearchInput = import_v4.z.object({
14124
+ query: import_v4.z.string().trim().min(1).describe("What to search for.")
14125
+ });
12945
14126
  var loadSkillInput = import_v4.z.object({
12946
14127
  name: import_v4.z.string().min(1).describe("The name of an Ordewell planner skill to load.")
12947
14128
  });
@@ -13088,6 +14269,23 @@ var PLANNER_TOOLS = [
13088
14269
  (h) => h.runCommand?.bind(h),
13089
14270
  PLANNER_READ_ONLY
13090
14271
  ),
14272
+ // Fallbacks (ADR-0027): a runner's own fetch or search, where it has one, is
14273
+ // decided by the same policy and carries the provider's own summaries, so
14274
+ // the descriptions send the model there first.
14275
+ tool(
14276
+ "fetch",
14277
+ "Read a web page over http or https: documentation, a changelog, an API reference. A fallback: if you have a built-in web fetch tool, use that instead. Whether a site may be read is up to the user's settings, and a refusal says why; private and local addresses and URLs that carry a credential are always refused, and each planning turn has a limited number of web reads.",
14278
+ fetchInput,
14279
+ (h) => h.fetch?.bind(h),
14280
+ PLANNER_READ_ONLY
14281
+ ),
14282
+ tool(
14283
+ "web_search",
14284
+ "Search the web for result titles, URLs and snippets, then read the most promising result with a fetch. A fallback: if you have a built-in web search tool, use that instead. Whether a search may run is up to the user's settings, and a refusal says why; a query that carries a credential is refused, and searches count toward the turn's web reads.",
14285
+ webSearchInput,
14286
+ (h) => h.webSearch?.bind(h),
14287
+ PLANNER_READ_ONLY
14288
+ ),
13091
14289
  tool(
13092
14290
  "load_skill",
13093
14291
  "Load an Ordewell planner skill by name. Returns its instructions; only model-invocable planner skills can be loaded.",
@@ -13117,8 +14315,14 @@ var OrdewellMcpServer = class {
13117
14315
  issueTaskToken(scope, handler = {}) {
13118
14316
  return this.issue({ role: "task", scope: { ...scope }, handler, revoked: new AbortController() });
13119
14317
  }
13120
- issuePlannerToken(scope, handler = {}) {
13121
- return this.issue({ role: "planner", scope: { ...scope }, handler, revoked: new AbortController() });
14318
+ async issuePlannerToken(scope, handler = {}) {
14319
+ const hidden = [...scope.hidden ?? []];
14320
+ const credential = await this.issue({ role: "planner", scope: { ...scope, hidden }, handler, revoked: new AbortController() });
14321
+ return hidden.length > 0 ? { ...credential, hidden } : credential;
14322
+ }
14323
+ /** Every token still good here: secrets the outbound check keeps out of a URL (ADR-0027). */
14324
+ heldTokens() {
14325
+ return [...this.grants.keys()];
13122
14326
  }
13123
14327
  /** Refuse every later request carrying `token`, and abort the calls it has in flight. */
13124
14328
  revoke(token) {
@@ -13129,14 +14333,14 @@ var OrdewellMcpServer = class {
13129
14333
  }
13130
14334
  async dispose() {
13131
14335
  for (const token of [...this.grants.keys()]) this.revoke(token);
13132
- const http = this.http;
14336
+ const http2 = this.http;
13133
14337
  this.http = void 0;
13134
14338
  this.listening = void 0;
13135
14339
  this.boundUrl = void 0;
13136
14340
  this.boundHost = void 0;
13137
- if (!http) return;
13138
- http.closeAllConnections();
13139
- await new Promise((resolve6) => http.close(() => resolve6()));
14341
+ if (!http2) return;
14342
+ http2.closeAllConnections();
14343
+ await new Promise((resolve9) => http2.close(() => resolve9()));
13140
14344
  }
13141
14345
  async issue(grant) {
13142
14346
  const url = await this.start();
@@ -13145,29 +14349,29 @@ var OrdewellMcpServer = class {
13145
14349
  return { url, token };
13146
14350
  }
13147
14351
  start() {
13148
- this.listening ??= new Promise((resolve6, reject) => {
13149
- const http = (0, import_http.createServer)((req, res) => {
14352
+ this.listening ??= new Promise((resolve9, reject) => {
14353
+ const http2 = (0, import_http.createServer)((req, res) => {
13150
14354
  this.handle(req, res).catch(() => {
13151
14355
  if (!res.headersSent) reply(res, 500, "Internal error");
13152
14356
  else res.destroy();
13153
14357
  });
13154
14358
  });
13155
- http.once("error", (err) => {
14359
+ http2.once("error", (err) => {
13156
14360
  this.listening = void 0;
13157
14361
  reject(err);
13158
14362
  });
13159
- http.listen(0, "127.0.0.1", () => {
13160
- const address = http.address();
14363
+ http2.listen(0, "127.0.0.1", () => {
14364
+ const address = http2.address();
13161
14365
  if (address === null || typeof address === "string") {
13162
14366
  reject(new Error("Ordewell MCP server bound to no TCP port"));
13163
14367
  return;
13164
14368
  }
13165
14369
  this.boundHost = `127.0.0.1:${address.port}`;
13166
14370
  this.boundUrl = `http://${this.boundHost}${ORDEWELL_MCP_PATH}`;
13167
- resolve6(this.boundUrl);
14371
+ resolve9(this.boundUrl);
13168
14372
  });
13169
- http.unref();
13170
- this.http = http;
14373
+ http2.unref();
14374
+ this.http = http2;
13171
14375
  });
13172
14376
  return this.listening;
13173
14377
  }
@@ -13188,7 +14392,7 @@ var OrdewellMcpServer = class {
13188
14392
  }
13189
14393
  const mcp = new import_server.Server({ name: "ordewell", version: "1" }, { capabilities: { tools: {} } });
13190
14394
  if (grant.role === "task") serveTools(mcp, TASK_TOOLS, grant.handler, grant.revoked.signal, this.heartbeatMs);
13191
- else serveTools(mcp, PLANNER_TOOLS, grant.handler, grant.revoked.signal, this.heartbeatMs);
14395
+ else serveTools(mcp, PLANNER_TOOLS.filter((tool2) => !grant.scope.hidden?.includes(tool2.name)), grant.handler, grant.revoked.signal, this.heartbeatMs);
13192
14396
  const transport = new import_streamableHttp.StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
13193
14397
  res.on("close", () => {
13194
14398
  void transport.close();
@@ -13239,14 +14443,15 @@ function mcpClientConfig(credential) {
13239
14443
  return {
13240
14444
  name: ORDEWELL_MCP_SERVER_NAME,
13241
14445
  url: credential.url,
13242
- headers: { Authorization: bearerHeaderValue(credential.token) }
14446
+ headers: { Authorization: bearerHeaderValue(credential.token) },
14447
+ ...credential.hidden?.length ? { hidden: credential.hidden } : {}
13243
14448
  };
13244
14449
  }
13245
14450
  function ownerOnlyConfigFile(fileName, contents) {
13246
14451
  const dir = (0, import_fs3.mkdtempSync)((0, import_path3.join)((0, import_os.tmpdir)(), "ordewell-mcp-"));
13247
- const path27 = (0, import_path3.join)(dir, fileName);
13248
- (0, import_fs3.writeFileSync)(path27, contents, { mode: 384 });
13249
- return { path: path27, remove: () => (0, import_fs3.rmSync)(dir, { recursive: true, force: true }) };
14452
+ const path32 = (0, import_path3.join)(dir, fileName);
14453
+ (0, import_fs3.writeFileSync)(path32, contents, { mode: 384 });
14454
+ return { path: path32, remove: () => (0, import_fs3.rmSync)(dir, { recursive: true, force: true }) };
13250
14455
  }
13251
14456
 
13252
14457
  // src/services/harness/ordewellBinding.ts
@@ -13256,13 +14461,13 @@ var McpAttachError = class extends Error {
13256
14461
  this.name = "McpAttachError";
13257
14462
  }
13258
14463
  };
13259
- function ordewellToolNames(role) {
13260
- return (role === "task" ? TASK_TOOLS : PLANNER_TOOLS).map((tool2) => tool2.name);
14464
+ function ordewellToolNames(role, hidden = []) {
14465
+ return (role === "task" ? TASK_TOOLS : PLANNER_TOOLS).map((tool2) => tool2.name).filter((tool2) => !hidden.includes(tool2));
13261
14466
  }
13262
14467
  function prefixedToolNames(prefix) {
13263
14468
  return {
13264
14469
  toolName: (tool2) => `${prefix}${tool2}`,
13265
- toolNames: (role) => ordewellToolNames(role).map((tool2) => `${prefix}${tool2}`),
14470
+ toolNames: (role, hidden) => ordewellToolNames(role, hidden).map((tool2) => `${prefix}${tool2}`),
13266
14471
  hasPrefix: (name) => name.startsWith(prefix)
13267
14472
  };
13268
14473
  }
@@ -13278,8 +14483,8 @@ async function awaitAttach(probe, timeoutMs, pollMs) {
13278
14483
  const state = await probe(left);
13279
14484
  if (state !== "pending") return state === "connected";
13280
14485
  if (pollMs > 0) {
13281
- await new Promise((resolve6) => {
13282
- const timer = setTimeout(resolve6, pollMs);
14486
+ await new Promise((resolve9) => {
14487
+ const timer = setTimeout(resolve9, pollMs);
13283
14488
  timer.unref?.();
13284
14489
  });
13285
14490
  }
@@ -13287,7 +14492,7 @@ async function awaitAttach(probe, timeoutMs, pollMs) {
13287
14492
  }
13288
14493
 
13289
14494
  // src/services/TaskOrchestrator.ts
13290
- var path19 = __toESM(require("path"));
14495
+ var path24 = __toESM(require("path"));
13291
14496
 
13292
14497
  // src/services/taskSkills.ts
13293
14498
  var path15 = __toESM(require("path"));
@@ -13656,11 +14861,11 @@ async function committedInHead(files) {
13656
14861
  }
13657
14862
  const result = /* @__PURE__ */ new Map();
13658
14863
  await Promise.all([...byDir].map(async ([dir, inDir]) => {
13659
- const relative7 = (file) => path15.relative(dir, file).split(path15.sep).join("/");
14864
+ const relative11 = (file) => path15.relative(dir, file).split(path15.sep).join("/");
13660
14865
  try {
13661
- const { stdout } = await runGit("git", ["ls-tree", "--name-only", "HEAD", "--", ...inDir.map(relative7)], { cwd: dir, env: cleanEnv() });
14866
+ const { stdout } = await runGit("git", ["ls-tree", "--name-only", "HEAD", "--", ...inDir.map(relative11)], { cwd: dir, env: cleanEnv() });
13662
14867
  const listed2 = new Set(stdout.split("\n").map((line) => line.trim()).filter(Boolean));
13663
- for (const file of inDir) result.set(file, listed2.has(relative7(file)));
14868
+ for (const file of inDir) result.set(file, listed2.has(relative11(file)));
13664
14869
  } catch {
13665
14870
  }
13666
14871
  }));
@@ -13691,13 +14896,13 @@ function checkPlanSkills(tasks, lookup2) {
13691
14896
  function checkTaskSkillsEdit(task, skills, lookup2) {
13692
14897
  return check([{ taskId: task.id, owner: `Task "${task.title}"`, ...attached(skills) }], lookup2);
13693
14898
  }
13694
- function subtaskEntries(owner, spec, path27 = []) {
14899
+ function subtaskEntries(owner, spec, path32 = []) {
13695
14900
  const subtasks = spec?.subtasks;
13696
14901
  if (!Array.isArray(subtasks)) return [];
13697
14902
  return subtasks.flatMap((sub, i) => {
13698
14903
  if (typeof sub !== "object" || sub === null) return [];
13699
14904
  const child = sub;
13700
- const at = [...path27, typeof child.title === "string" && child.title.trim() ? `"${child.title}"` : `#${i + 1}`];
14905
+ const at = [...path32, typeof child.title === "string" && child.title.trim() ? `"${child.title}"` : `#${i + 1}`];
13701
14906
  return [{ owner: `${owner}: subtask ${at.join(" > ")}`, ...attached(child.skills) }, ...subtaskEntries(owner, child, at)];
13702
14907
  });
13703
14908
  }
@@ -13928,7 +15133,7 @@ ${reminder.join("\n\n")}${COMPLETION_INSTRUCTION}`;
13928
15133
  }
13929
15134
 
13930
15135
  // src/services/VerdictEngine.ts
13931
- var IDLE_TIMEOUT_MS = 6e4;
15136
+ var IDLE_TIMEOUT_MS = 5 * 6e4;
13932
15137
  var VerdictEngine = class {
13933
15138
  pendingVerdicts = /* @__PURE__ */ new Map();
13934
15139
  sessions = /* @__PURE__ */ new Map();
@@ -14110,14 +15315,14 @@ var VerdictEngine = class {
14110
15315
  if (this.toolCheckpoints.has(taskId)) {
14111
15316
  return Promise.resolve({ kind: "withdrawn", why: "another checkpoint is still waiting for an answer. Wait for it before asking again." });
14112
15317
  }
14113
- return new Promise((resolve6) => {
15318
+ return new Promise((resolve9) => {
14114
15319
  const settle = (answer2) => {
14115
15320
  signal.removeEventListener("abort", onAbort);
14116
15321
  if (this.toolCheckpoints.get(taskId) === settle) {
14117
15322
  this.toolCheckpoints.delete(taskId);
14118
15323
  this.checkpointQuestions.delete(taskId);
14119
15324
  }
14120
- resolve6(answer2);
15325
+ resolve9(answer2);
14121
15326
  };
14122
15327
  const onAbort = () => {
14123
15328
  settle({ kind: "withdrawn", why: "the call was cancelled." });
@@ -15576,6 +16781,7 @@ var SETTINGS_ENV_REFUSED = [
15576
16781
  "ORDEWELL_APPROVAL_MODE",
15577
16782
  "ORDEWELL_APPROVAL_ALLOW",
15578
16783
  "ORDEWELL_APPROVAL_DEFAULTS",
16784
+ "ORDEWELL_WEB_READ_BUDGET",
15579
16785
  // Where settings are persisted.
15580
16786
  "ORDEWELL_SETTINGS_PATH",
15581
16787
  // Runtime options and dynamic loaders.
@@ -15627,10 +16833,10 @@ var DIRENV_TIMEOUT_MS = 5e3;
15627
16833
  var KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
15628
16834
  function defaultDeps() {
15629
16835
  return {
15630
- run: (command, args, opts) => new Promise((resolve6) => {
16836
+ run: (command, args, opts) => new Promise((resolve9) => {
15631
16837
  (0, import_child_process4.execFile)(command, args, { ...opts, timeout: DIRENV_TIMEOUT_MS, encoding: "utf8" }, (err, stdout, stderr) => {
15632
16838
  const code = err ? typeof err.code === "number" ? Number(err.code) : -1 : 0;
15633
- resolve6({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
16839
+ resolve9({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
15634
16840
  });
15635
16841
  }),
15636
16842
  readFile: (file) => {
@@ -15712,10 +16918,10 @@ async function fromDirenv(cwd, deps) {
15712
16918
  }
15713
16919
  return { env, blocked: null };
15714
16920
  }
15715
- function findUp(start, relative7, deps) {
16921
+ function findUp(start, relative11, deps) {
15716
16922
  let dir = path16.resolve(start);
15717
16923
  for (; ; ) {
15718
- const candidate = path16.join(dir, relative7);
16924
+ const candidate = path16.join(dir, relative11);
15719
16925
  if (deps.readFile(candidate) !== null) return candidate;
15720
16926
  const parent = path16.dirname(dir);
15721
16927
  if (parent === dir) return null;
@@ -16174,6 +17380,7 @@ function killTree(proc, deps = {}) {
16174
17380
  // src/services/harness/openCodeOrdewell.ts
16175
17381
  var NAMES = prefixedToolNames(`${ORDEWELL_MCP_SERVER_NAME}_`);
16176
17382
  var RULE = NAMES.toolName("*");
17383
+ var OPENCODE_SEARCH_OFF = { websearch: "deny", codesearch: "deny" };
16177
17384
  var OPENCODE_PLANNER_PERMISSION = {
16178
17385
  "*": "ask",
16179
17386
  read: "allow",
@@ -16183,10 +17390,10 @@ var OPENCODE_PLANNER_PERMISSION = {
16183
17390
  lsp: "allow",
16184
17391
  skill: "allow",
16185
17392
  todoread: "allow",
16186
- websearch: "allow",
16187
- codesearch: "allow",
16188
- webfetch: "ask",
17393
+ websearch: "ask",
17394
+ codesearch: "ask",
16189
17395
  external_directory: "ask",
17396
+ webfetch: "deny",
16190
17397
  edit: "deny",
16191
17398
  write: "deny",
16192
17399
  patch: "deny",
@@ -16201,6 +17408,8 @@ var OPENCODE_PLANNER_PERMISSION = {
16201
17408
  browser: "deny"
16202
17409
  };
16203
17410
  var OPENCODE_CONFIG_VARIABLE = "OPENCODE_CONFIG_CONTENT";
17411
+ var OPENCODE_PLANNER_ENV = { OPENCODE_DISABLE_PROJECT_CONFIG: "1" };
17412
+ var OPENCODE_SEARCH_PROVIDER = { provider: "opencode", providerLabel: "OpenCode's web search (Exa, unless another search provider is chosen in OpenCode)" };
16204
17413
  function isRecord2(value) {
16205
17414
  return typeof value === "object" && value !== null && !Array.isArray(value);
16206
17415
  }
@@ -16237,7 +17446,7 @@ function withoutParentOrdewell(content) {
16237
17446
  const kept = withoutOrdewell(parsed);
16238
17447
  return JSON.stringify(kept) === JSON.stringify(parsed) ? content : JSON.stringify(kept);
16239
17448
  }
16240
- function mergeOrdewellConfig(existing, mcp, role) {
17449
+ function mergeOrdewellConfig(existing, mcp, role, projectServers = {}) {
16241
17450
  let base = {};
16242
17451
  if (existing?.trim()) {
16243
17452
  let parsed;
@@ -16254,8 +17463,15 @@ function mergeOrdewellConfig(existing, mcp, role) {
16254
17463
  delete base.permissions;
16255
17464
  }
16256
17465
  const policy = typeof base.permission === "string" ? { "*": base.permission } : base.permission;
17466
+ const servers = {
17467
+ ...role === "planner" ? projectServers : {},
17468
+ ...mcp ? { [mcp.name]: { type: "remote", url: mcp.url, headers: mcp.headers, enabled: true } } : {}
17469
+ };
16257
17470
  const ours = {
16258
- ...mcp ? { mcp: { [mcp.name]: { type: "remote", url: mcp.url, headers: mcp.headers, enabled: true } } } : {},
17471
+ ...Object.keys(servers).length > 0 ? { mcp: servers } : {},
17472
+ // A language server opened on a file the planner reads can run the
17473
+ // repository's toolchain; 1.x's `read` opens every file it reads (ADR-0028).
17474
+ ...role === "planner" ? { lsp: false } : {},
16259
17475
  permission: { ...role === "planner" ? OPENCODE_PLANNER_PERMISSION : {}, ...mcp ? { [RULE]: "allow" } : {} }
16260
17476
  };
16261
17477
  return JSON.stringify(deepMerge({ ...base, ...policy === void 0 ? {} : { permission: policy } }, ours));
@@ -16263,12 +17479,17 @@ function mergeOrdewellConfig(existing, mcp, role) {
16263
17479
  var OPENCODE_ORDEWELL = {
16264
17480
  toolName: NAMES.toolName,
16265
17481
  toolNames: NAMES.toolNames,
17482
+ plannerHidden: () => [],
16266
17483
  /** A permission request's `permission`, which for a tool is its name. */
16267
17484
  isOrdewellAsk: (permission) => NAMES.hasPrefix(permission),
16268
17485
  /** `/mcp` lists a server only once it has started connecting, as `pending` until it settles. */
16269
17486
  attachState: (status) => status === "connected" ? "connected" : status === void 0 || status === null || status === "pending" ? "pending" : "failed",
16270
17487
  configContent: mergeOrdewellConfig,
16271
- sessionRules: () => [{ action: RULE, resource: "*", effect: "allow" }]
17488
+ sessionRules: () => [{ action: RULE, resource: "*", effect: "allow" }],
17489
+ plannerInstructions: (ownSearch = true) => [
17490
+ `Read web pages with \`${NAMES.toolName("fetch")}\`. Your own \`webfetch\` is off while planning: it follows redirects Ordewell cannot check.`,
17491
+ ownSearch ? `Search the web with your own \`websearch\` first. If \`websearch\` fails, is cancelled or is unavailable for your provider, search with \`${NAMES.toolName("web_search")}\` instead.` : `Your own \`websearch\` is off for this session: no search provider is chosen in OpenCode. Search the web with \`${NAMES.toolName("web_search")}\`.`
17492
+ ].join(" ")
16272
17493
  };
16273
17494
 
16274
17495
  // src/services/harness/runnerEnv.ts
@@ -16296,8 +17517,8 @@ var STDERR_TAIL_CHARS = 4e3;
16296
17517
  var RunnerProcess = class {
16297
17518
  constructor(deps) {
16298
17519
  this.deps = deps;
16299
- this.ended = new Promise((resolve6) => {
16300
- this.markEnded = resolve6;
17520
+ this.ended = new Promise((resolve9) => {
17521
+ this.markEnded = resolve9;
16301
17522
  });
16302
17523
  }
16303
17524
  deps;
@@ -16326,9 +17547,10 @@ var RunnerProcess = class {
16326
17547
  throw err;
16327
17548
  }
16328
17549
  }
16329
- async launch(cwd, describe, handshake) {
17550
+ async launch(cwd, describe2, handshake) {
16330
17551
  assertWorkspaceExists(cwd, { isDirectory: this.deps.isDirectory });
16331
- const command = describe();
17552
+ const workspace = await (this.deps.workspaceEnv ?? workspaceEnvOf)(cwd);
17553
+ const command = describe2(workspace);
16332
17554
  const resolvePath = this.deps.resolvePath ?? augmentedPath;
16333
17555
  const PATH = await resolvePath();
16334
17556
  const launch = await planDirectLaunch(command.command, command.args, {
@@ -16338,7 +17560,6 @@ var RunnerProcess = class {
16338
17560
  if (!isExecutableResolved(command.command, launch, PATH, { platform: this.deps.platform, exists: this.deps.exists })) {
16339
17561
  throw new ExecutableNotFoundError(command.command, PATH);
16340
17562
  }
16341
- const workspace = await (this.deps.workspaceEnv ?? workspaceEnvOf)(cwd);
16342
17563
  this.spawnEnv = runnerEnv(PATH, workspace, command.env?.(workspace));
16343
17564
  const child = spawnInOwnGroup((detached) => this.deps.spawn(launch.file, launch.args, {
16344
17565
  env: this.spawnEnv,
@@ -16461,8 +17682,8 @@ var StdioAgentAdapter = class {
16461
17682
  async start(opts) {
16462
17683
  this.role = opts.kind;
16463
17684
  try {
16464
- await this.runner.start(opts.cwd, () => {
16465
- const spec = this.spawnSpec(opts);
17685
+ await this.runner.start(opts.cwd, (workspace) => {
17686
+ const spec = this.spawnSpec(opts, workspace);
16466
17687
  return { command: spec.command, args: spec.args, env: () => spec.env ?? {} };
16467
17688
  }, (child) => {
16468
17689
  this.process = child;
@@ -16506,7 +17727,7 @@ var StdioAgentAdapter = class {
16506
17727
  onEvent({ type: "error", message: this.exitMessage() });
16507
17728
  return;
16508
17729
  }
16509
- await new Promise((resolve6) => {
17730
+ await new Promise((resolve9) => {
16510
17731
  let settled = false;
16511
17732
  const finish = () => {
16512
17733
  if (settled) return;
@@ -16517,7 +17738,7 @@ var StdioAgentAdapter = class {
16517
17738
  signal?.removeEventListener("abort", onAbort);
16518
17739
  this.process?.removeListener("exit", onExit);
16519
17740
  this.process?.removeListener("error", onExit);
16520
- resolve6();
17741
+ resolve9();
16521
17742
  };
16522
17743
  const emit = (event) => {
16523
17744
  onEvent(event);
@@ -16644,6 +17865,8 @@ ${lines2.map((line) => `${line}
16644
17865
  var CLAUDE_ORDEWELL = {
16645
17866
  toolName: MCP_CLIENT_TOOL_NAMES.toolName,
16646
17867
  toolNames: MCP_CLIENT_TOOL_NAMES.toolNames,
17868
+ /** ADR-0027: `WebSearch` and `WebFetch` carry Claude's web reads, both asked through the policy. */
17869
+ plannerHidden: () => ["fetch", "web_search"],
16647
17870
  /** `can_use_tool`'s `tool_name`. */
16648
17871
  isOrdewellAsk: (toolName) => MCP_CLIENT_TOOL_NAMES.hasPrefix(toolName),
16649
17872
  /** `mcp_status` reports `pending` until the CLI's connection attempt settles. */
@@ -16655,24 +17878,24 @@ var CLAUDE_ORDEWELL = {
16655
17878
  * search, and a model that has to look an Ordewell tool up first tends to
16656
17879
  * go on without it.
16657
17880
  */
16658
- launch(mcp, role) {
17881
+ launch(mcp, role, servers = {}) {
16659
17882
  const config = ownerOnlyConfigFile("mcp.json", JSON.stringify({
16660
- mcpServers: { [mcp.name]: { type: "http", url: mcp.url, headers: mcp.headers, alwaysLoad: true } }
17883
+ mcpServers: { ...servers, [mcp.name]: { type: "http", url: mcp.url, headers: mcp.headers, alwaysLoad: true } }
16661
17884
  }));
16662
- return { args: ["--mcp-config", config.path, "--allowedTools", MCP_CLIENT_TOOL_NAMES.toolNames(role).join(",")], config };
17885
+ return { args: ["--mcp-config", config.path, "--allowedTools", MCP_CLIENT_TOOL_NAMES.toolNames(role, mcp.hidden).join(",")], config };
16663
17886
  }
16664
17887
  };
16665
17888
 
16666
17889
  // src/services/harness/settleWithin.ts
16667
17890
  function settleWithin(promise, wait) {
16668
- return new Promise((resolve6, reject) => {
17891
+ return new Promise((resolve9, reject) => {
16669
17892
  let settled = false;
16670
17893
  const settle = (outcome) => {
16671
17894
  if (settled) return;
16672
17895
  settled = true;
16673
17896
  clearTimeout(timer);
16674
17897
  try {
16675
- resolve6(outcome());
17898
+ resolve9(outcome());
16676
17899
  } catch (err) {
16677
17900
  reject(err);
16678
17901
  }
@@ -16689,6 +17912,180 @@ function settleWithin(promise, wait) {
16689
17912
  });
16690
17913
  }
16691
17914
 
17915
+ // src/services/harness/claudeRepoConfig.ts
17916
+ var os6 = __toESM(require("os"));
17917
+ var path20 = __toESM(require("path"));
17918
+
17919
+ // src/services/harness/repoConfig.ts
17920
+ var import_fs4 = require("fs");
17921
+ var path19 = __toESM(require("path"));
17922
+ var NO_SERVERS = { servers: [], unreadable: [] };
17923
+ var INSTRUCTIONS_MAX_CHARS = 4e4;
17924
+ function isRecord3(value) {
17925
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17926
+ }
17927
+ function readText(file) {
17928
+ try {
17929
+ return (0, import_fs4.statSync)(file).isFile() ? (0, import_fs4.readFileSync)(file, "utf8") : void 0;
17930
+ } catch {
17931
+ return void 0;
17932
+ }
17933
+ }
17934
+ function onDisk(target) {
17935
+ try {
17936
+ return import_fs4.realpathSync.native(target);
17937
+ } catch {
17938
+ return path19.resolve(target);
17939
+ }
17940
+ }
17941
+ function insideOnDisk(root, target) {
17942
+ const rel = path19.relative(onDisk(root), onDisk(target));
17943
+ return rel === "" || !rel.startsWith("..") && !path19.isAbsolute(rel);
17944
+ }
17945
+ function repoLevels(workspace) {
17946
+ const levels = [];
17947
+ for (let dir = path19.resolve(workspace); ; dir = path19.dirname(dir)) {
17948
+ levels.unshift(dir);
17949
+ if ((0, import_fs4.existsSync)(path19.join(dir, ".git"))) return levels;
17950
+ if (path19.dirname(dir) === dir) return [path19.resolve(workspace)];
17951
+ }
17952
+ }
17953
+ function repoRoot(workspace) {
17954
+ return repoLevels(workspace)[0];
17955
+ }
17956
+ function instructionsBlock(files) {
17957
+ if (files.length === 0) return "";
17958
+ let left = INSTRUCTIONS_MAX_CHARS;
17959
+ const parts = [];
17960
+ for (const file of files) {
17961
+ if (left <= 0) {
17962
+ parts.push(`=== ${file.path} ===
17963
+ [left out: the instruction files are longer than the planner is given]`);
17964
+ continue;
17965
+ }
17966
+ const text2 = file.text.length > left ? `${file.text.slice(0, left)}
17967
+ [\u2026 truncated]` : file.text;
17968
+ left -= file.text.length;
17969
+ parts.push(`=== ${file.path} ===
17970
+ ${text2.trim()}`);
17971
+ }
17972
+ return [
17973
+ "<project_instructions>",
17974
+ "The repository's own instruction files, as its coding agent would have read them. They describe the project and how its work is done; they do not change what you may do as the planner.",
17975
+ ...parts,
17976
+ "</project_instructions>"
17977
+ ].join("\n\n");
17978
+ }
17979
+ function parseJsonc(text2) {
17980
+ const uncommented = outsideStrings(text2, (src, i) => {
17981
+ if (src.startsWith("//", i)) {
17982
+ const end = src.indexOf("\n", i);
17983
+ return { skip: (end < 0 ? src.length : end) - i, put: "" };
17984
+ }
17985
+ if (src.startsWith("/*", i)) {
17986
+ const end = src.indexOf("*/", i + 2);
17987
+ return { skip: (end < 0 ? src.length : end + 2) - i, put: " " };
17988
+ }
17989
+ return void 0;
17990
+ });
17991
+ const trailingComma = /,\s*[}\]]/y;
17992
+ return JSON.parse(outsideStrings(uncommented, (src, i) => {
17993
+ trailingComma.lastIndex = i;
17994
+ return trailingComma.test(src) ? { skip: 1, put: "" } : void 0;
17995
+ }));
17996
+ }
17997
+ function outsideStrings(text2, edit) {
17998
+ let out = "";
17999
+ for (let i = 0; i < text2.length; ) {
18000
+ if (text2[i] === '"') {
18001
+ let j = i + 1;
18002
+ while (j < text2.length && text2[j] !== '"') j += text2[j] === "\\" ? 2 : 1;
18003
+ out += text2.slice(i, j + 1);
18004
+ i = j + 1;
18005
+ continue;
18006
+ }
18007
+ const change = edit(text2, i);
18008
+ if (change) {
18009
+ out += change.put;
18010
+ i += change.skip;
18011
+ } else {
18012
+ out += text2[i++];
18013
+ }
18014
+ }
18015
+ return out;
18016
+ }
18017
+
18018
+ // src/services/harness/claudeRepoConfig.ts
18019
+ var MEMORY_FILES = ["CLAUDE.md", path20.join(".claude", "CLAUDE.md"), "CLAUDE.local.md"];
18020
+ var MAX_IMPORT_DEPTH = 5;
18021
+ function importsOf(text2) {
18022
+ const prose = text2.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
18023
+ return [...prose.matchAll(/(?:^|\s)@([^\s`]+)/g)].map((m) => m[1].replace(/[.,;:)]+$/, ""));
18024
+ }
18025
+ function withImports(workspace, file, depth, seen, into) {
18026
+ if (seen.has(file)) return;
18027
+ seen.add(file);
18028
+ const text2 = readText(file);
18029
+ if (text2 === void 0) return;
18030
+ into.push({ path: path20.relative(workspace, file), text: text2 });
18031
+ if (depth >= MAX_IMPORT_DEPTH) return;
18032
+ for (const target of importsOf(text2)) {
18033
+ if (target.startsWith("~")) continue;
18034
+ const abs = path20.resolve(path20.dirname(file), target);
18035
+ if (insideOnDisk(workspace, abs)) withImports(workspace, abs, depth + 1, seen, into);
18036
+ }
18037
+ }
18038
+ function server(name, entry, source) {
18039
+ if (!isRecord3(entry)) return "not an object";
18040
+ const remote = entry.type === "http" || entry.type === "sse" || entry.type === void 0 && typeof entry.url === "string";
18041
+ if (remote) {
18042
+ if (typeof entry.url !== "string") return "no url";
18043
+ return { name, source, transport: "remote", target: entry.url, names: isRecord3(entry.headers) ? Object.keys(entry.headers) : [], definition: entry };
18044
+ }
18045
+ if (typeof entry.command !== "string") return "no command";
18046
+ const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
18047
+ return { name, source, transport: "stdio", target: [entry.command, ...args].join(" "), names: isRecord3(entry.env) ? Object.keys(entry.env) : [], definition: entry };
18048
+ }
18049
+ var CLAUDE_REPO_CONFIG = {
18050
+ instructions(workspace) {
18051
+ const files = [];
18052
+ const seen = /* @__PURE__ */ new Set();
18053
+ for (const name of MEMORY_FILES) withImports(workspace, path20.join(workspace, name), 0, seen, files);
18054
+ return files;
18055
+ },
18056
+ projectServers(workspace) {
18057
+ const source = ".mcp.json";
18058
+ const text2 = readText(path20.join(workspace, source));
18059
+ if (text2 === void 0) return NO_SERVERS;
18060
+ let parsed;
18061
+ try {
18062
+ parsed = JSON.parse(text2);
18063
+ } catch (err) {
18064
+ return { servers: [], unreadable: [{ source, reason: err instanceof Error ? err.message : String(err) }] };
18065
+ }
18066
+ const entries = isRecord3(parsed) && isRecord3(parsed.mcpServers) ? parsed.mcpServers : {};
18067
+ const found = { servers: [], unreadable: [] };
18068
+ for (const [name, entry] of Object.entries(entries)) {
18069
+ const read2 = server(name, entry, source);
18070
+ if (typeof read2 === "string") found.unreadable.push({ source: `${source} (${name})`, reason: read2 });
18071
+ else found.servers.push(read2);
18072
+ }
18073
+ return found;
18074
+ }
18075
+ };
18076
+ function claudeLocalServers(configDir2, workspace) {
18077
+ const file = configDir2 ? path20.join(configDir2, ".claude.json") : path20.join(os6.homedir(), ".claude.json");
18078
+ const text2 = readText(file);
18079
+ if (text2 === void 0) return {};
18080
+ try {
18081
+ const parsed = JSON.parse(text2);
18082
+ const project = isRecord3(parsed) && isRecord3(parsed.projects) ? parsed.projects[path20.resolve(workspace)] : void 0;
18083
+ return isRecord3(project) && isRecord3(project.mcpServers) ? project.mcpServers : {};
18084
+ } catch {
18085
+ return {};
18086
+ }
18087
+ }
18088
+
16692
18089
  // src/services/harness/ClaudeCodeAdapter.ts
16693
18090
  var DISALLOWED_TOOLS = [
16694
18091
  "Edit",
@@ -16702,20 +18099,25 @@ var DISALLOWED_TOOLS = [
16702
18099
  "ExitPlanMode"
16703
18100
  ];
16704
18101
  var PLANNER_PERMISSION_MODE = "default";
18102
+ var PLANNER_SETTINGS = JSON.stringify({
18103
+ disableAllHooks: true,
18104
+ permissions: { ask: ["WebFetch", "WebFetch(domain:*)", "WebSearch", "mcp__*", "Read", "Grep", "Glob", "LS"] }
18105
+ });
16705
18106
  var PATH_TOOLS = {
16706
18107
  Read: { field: "file_path", directory: false },
16707
18108
  Grep: { field: "path", directory: true },
16708
18109
  Glob: { field: "path", directory: true },
16709
18110
  LS: { field: "path", directory: true }
16710
18111
  };
16711
- function plannerAsk(name, input, server) {
18112
+ function plannerAsk(name, input, server4) {
16712
18113
  if (name.startsWith("mcp__")) {
16713
18114
  const tool2 = name.split("__").slice(2).join("__");
16714
- return { kind: "mcp", scope: name, tool: tool2 || name, ...server ? { server } : {} };
18115
+ return { kind: "mcp", scope: name, tool: tool2 || name, ...server4 ? { server: server4 } : {} };
16715
18116
  }
16716
18117
  if (name === "WebFetch" && typeof input.url === "string") return { kind: "fetch", url: input.url };
18118
+ if (name === "WebSearch" && typeof input.query === "string") return { kind: "search", query: input.query, provider: "anthropic" };
16717
18119
  const pathTool = PATH_TOOLS[name];
16718
- const target = pathTool ? input[pathTool.field] : void 0;
18120
+ const target = pathTool ? input[pathTool.field] ?? (pathTool.directory ? "." : void 0) : void 0;
16719
18121
  if (pathTool && typeof target === "string") return { kind: "path", path: target, directory: pathTool.directory };
16720
18122
  return { kind: "other" };
16721
18123
  }
@@ -16823,7 +18225,7 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
16823
18225
  steers = /* @__PURE__ */ new Map();
16824
18226
  /** The last turn's `result` ended it, so a steer's echo now opens a turn the CLI started for that message. */
16825
18227
  turnClosed = false;
16826
- spawnSpec(opts) {
18228
+ spawnSpec(opts, workspace) {
16827
18229
  if (opts.kind === "task") return this.taskSpawnSpec(opts);
16828
18230
  const args = [
16829
18231
  ...PROTOCOL_ARGS,
@@ -16834,10 +18236,16 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
16834
18236
  PLANNER_PERMISSION_MODE,
16835
18237
  "--disallowedTools",
16836
18238
  DISALLOWED_TOOLS.join(","),
18239
+ // In `-p` a repository's settings apply trusted or not: their hooks,
18240
+ // `env` and helper commands run before the first turn (ADR-0028).
18241
+ "--setting-sources",
18242
+ "user",
18243
+ "--settings",
18244
+ PLANNER_SETTINGS,
16837
18245
  "--append-system-prompt",
16838
18246
  opts.systemPrompt
16839
18247
  ];
16840
- if (opts.mcp) args.push(...this.ordewellServerArgs(opts.mcp, "planner"));
18248
+ if (opts.mcp) args.push(...this.ordewellServerArgs(opts.mcp, "planner", this.plannerServers(opts, workspace)));
16841
18249
  if (opts.model) args.push("--model", opts.model);
16842
18250
  if (opts.effort && opts.effort !== "adaptive") args.push("--effort", opts.effort);
16843
18251
  if (opts.resumeSessionId) {
@@ -16875,10 +18283,23 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
16875
18283
  if (opts.mcp) args.push(...this.ordewellServerArgs(opts.mcp, "task"));
16876
18284
  return { command: "claude", args };
16877
18285
  }
18286
+ /**
18287
+ * What user-only settings stop the CLI loading that is not the
18288
+ * repository's to choose (ADR-0028): the user's own local-scope servers,
18289
+ * under the `CLAUDE_CONFIG_DIR` the planner runs with, and the project
18290
+ * servers approved for it.
18291
+ */
18292
+ plannerServers(opts, workspace) {
18293
+ const configDir2 = workspace.CLAUDE_CONFIG_DIR ?? process.env.CLAUDE_CONFIG_DIR;
18294
+ return {
18295
+ ...claudeLocalServers(configDir2, opts.cwd),
18296
+ ...Object.fromEntries((opts.projectServers ?? []).map((server4) => [server4.name, server4.definition]))
18297
+ };
18298
+ }
16878
18299
  /** The file holding the token lives exactly as long as the process that reads it. */
16879
- ordewellServerArgs(mcp, role) {
18300
+ ordewellServerArgs(mcp, role, servers) {
16880
18301
  this.removeMcpConfig();
16881
- const { args, config } = CLAUDE_ORDEWELL.launch(mcp, role);
18302
+ const { args, config } = CLAUDE_ORDEWELL.launch(mcp, role, servers);
16882
18303
  this.mcpConfig = config;
16883
18304
  void this.processEnded.then(() => config.remove());
16884
18305
  this.givenOrdewell = true;
@@ -16927,8 +18348,8 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
16927
18348
  if (!this.process) return Promise.resolve(null);
16928
18349
  this.controlCount += 1;
16929
18350
  const requestId = `ordewell-${request.subtype}-${this.controlCount}`;
16930
- const answered = new Promise((resolve6) => {
16931
- this.pendingControl.set(requestId, resolve6);
18351
+ const answered = new Promise((resolve9) => {
18352
+ this.pendingControl.set(requestId, resolve9);
16932
18353
  });
16933
18354
  this.writeLine({ type: "control_request", request_id: requestId, request });
16934
18355
  return settleWithin(answered, { timeoutMs, ended: this.processEnded, onTimeout: () => null, onEnded: () => null }).finally(() => {
@@ -17259,14 +18680,14 @@ function codexSandboxUnavailableMessage(role = "planner") {
17259
18680
  ].join("\n");
17260
18681
  }
17261
18682
  function runProbe(deps, cwd, env, flags) {
17262
- return new Promise((resolve6) => {
18683
+ return new Promise((resolve9) => {
17263
18684
  let output = "";
17264
18685
  let settled = false;
17265
18686
  const finish = (code) => {
17266
18687
  if (settled) return;
17267
18688
  settled = true;
17268
18689
  clearTimeout(timer);
17269
- resolve6({ code, output });
18690
+ resolve9({ code, output });
17270
18691
  };
17271
18692
  const timer = setTimeout(() => finish(null), PROBE_TIMEOUT_MS);
17272
18693
  timer.unref?.();
@@ -17281,27 +18702,306 @@ function runProbe(deps, cwd, env, flags) {
17281
18702
  finish(null);
17282
18703
  return;
17283
18704
  }
17284
- proc.stdout?.on("data", (chunk) => {
17285
- output += chunk.toString();
17286
- });
17287
- proc.stderr?.on("data", (chunk) => {
17288
- output += chunk.toString();
17289
- });
17290
- proc.on("error", () => finish(null));
17291
- proc.on("exit", (code) => finish(code));
17292
- });
18705
+ proc.stdout?.on("data", (chunk) => {
18706
+ output += chunk.toString();
18707
+ });
18708
+ proc.stderr?.on("data", (chunk) => {
18709
+ output += chunk.toString();
18710
+ });
18711
+ proc.on("error", () => finish(null));
18712
+ proc.on("exit", (code) => finish(code));
18713
+ });
18714
+ }
18715
+
18716
+ // src/services/harness/codexRepoConfig.ts
18717
+ var path21 = __toESM(require("path"));
18718
+
18719
+ // src/services/harness/toml.ts
18720
+ function parseToml(text2) {
18721
+ return new TomlReader(text2).document();
18722
+ }
18723
+ var TomlError = class extends Error {
18724
+ };
18725
+ var BARE_KEY = /[A-Za-z0-9_-]/;
18726
+ var ESCAPES = { b: "\b", t: " ", n: "\n", f: "\f", r: "\r", '"': '"', "\\": "\\", e: "\x1B" };
18727
+ var TomlReader = class {
18728
+ constructor(src) {
18729
+ this.src = src;
18730
+ }
18731
+ src;
18732
+ i = 0;
18733
+ document() {
18734
+ const root = {};
18735
+ let table = root;
18736
+ for (; ; ) {
18737
+ this.skipBlank(true);
18738
+ if (this.i >= this.src.length) return root;
18739
+ if (this.src.startsWith("[[", this.i)) {
18740
+ this.i += 2;
18741
+ const path32 = this.key();
18742
+ this.expect("]]");
18743
+ const parent = this.tableAt(root, path32.slice(0, -1));
18744
+ const last = path32[path32.length - 1];
18745
+ const list = parent[last] ?? (parent[last] = []);
18746
+ if (!Array.isArray(list)) throw this.fail(`${path32.join(".")} is not an array of tables`);
18747
+ table = {};
18748
+ list.push(table);
18749
+ } else if (this.src[this.i] === "[") {
18750
+ this.i += 1;
18751
+ const path32 = this.key();
18752
+ this.expect("]");
18753
+ table = this.tableAt(root, path32);
18754
+ } else {
18755
+ const path32 = this.key();
18756
+ this.skipBlank(false);
18757
+ this.expect("=");
18758
+ this.skipBlank(false);
18759
+ const owner = this.tableAt(table, path32.slice(0, -1));
18760
+ owner[path32[path32.length - 1]] = this.value();
18761
+ }
18762
+ this.endOfLine();
18763
+ }
18764
+ }
18765
+ tableAt(from, path32) {
18766
+ let table = from;
18767
+ for (const part of path32) {
18768
+ const next = table[part];
18769
+ if (next === void 0) table = table[part] = {};
18770
+ else if (isRecord3(next)) table = next;
18771
+ else if (Array.isArray(next) && isRecord3(next[next.length - 1])) table = next[next.length - 1];
18772
+ else throw this.fail(`${part} is not a table`);
18773
+ }
18774
+ return table;
18775
+ }
18776
+ key() {
18777
+ const parts = [];
18778
+ for (; ; ) {
18779
+ this.skipBlank(false);
18780
+ const ch = this.src[this.i];
18781
+ if (ch === '"') parts.push(this.basicString());
18782
+ else if (ch === "'") parts.push(this.literalString());
18783
+ else {
18784
+ const start = this.i;
18785
+ while (this.i < this.src.length && BARE_KEY.test(this.src[this.i])) this.i++;
18786
+ if (this.i === start) throw this.fail("expected a key");
18787
+ parts.push(this.src.slice(start, this.i));
18788
+ }
18789
+ this.skipBlank(false);
18790
+ if (this.src[this.i] !== ".") return parts;
18791
+ this.i++;
18792
+ }
18793
+ }
18794
+ value() {
18795
+ const ch = this.src[this.i];
18796
+ if (this.src.startsWith('"""', this.i)) return this.multilineString('"""');
18797
+ if (this.src.startsWith("'''", this.i)) return this.multilineString("'''");
18798
+ if (ch === '"') return this.basicString();
18799
+ if (ch === "'") return this.literalString();
18800
+ if (ch === "[") return this.array();
18801
+ if (ch === "{") return this.inlineTable();
18802
+ const start = this.i;
18803
+ while (this.i < this.src.length && !/[\s,\]}#]/.test(this.src[this.i])) this.i++;
18804
+ const raw = this.src.slice(start, this.i);
18805
+ if (!raw) throw this.fail("expected a value");
18806
+ if (raw === "true" || raw === "false") return raw === "true";
18807
+ const number = raw.replace(/_/g, "");
18808
+ if (/^[+-]?(\d+(\.\d+)?([eE][+-]?\d+)?|0x[0-9a-fA-F]+|0o[0-7]+|0b[01]+|inf|nan)$/.test(number)) return Number(number);
18809
+ return raw;
18810
+ }
18811
+ array() {
18812
+ this.i++;
18813
+ const items = [];
18814
+ for (; ; ) {
18815
+ this.skipBlank(true);
18816
+ if (this.src[this.i] === "]") {
18817
+ this.i++;
18818
+ return items;
18819
+ }
18820
+ items.push(this.value());
18821
+ this.skipBlank(true);
18822
+ if (this.src[this.i] === ",") this.i++;
18823
+ else if (this.src[this.i] !== "]") throw this.fail("expected , or ] in an array");
18824
+ }
18825
+ }
18826
+ inlineTable() {
18827
+ this.i++;
18828
+ const table = {};
18829
+ for (; ; ) {
18830
+ this.skipBlank(true);
18831
+ if (this.src[this.i] === "}") {
18832
+ this.i++;
18833
+ return table;
18834
+ }
18835
+ const path32 = this.key();
18836
+ this.expect("=");
18837
+ this.skipBlank(false);
18838
+ this.tableAt(table, path32.slice(0, -1))[path32[path32.length - 1]] = this.value();
18839
+ this.skipBlank(true);
18840
+ if (this.src[this.i] === ",") this.i++;
18841
+ else if (this.src[this.i] !== "}") throw this.fail("expected , or } in an inline table");
18842
+ }
18843
+ }
18844
+ basicString() {
18845
+ this.i++;
18846
+ let out = "";
18847
+ while (this.src[this.i] !== '"') {
18848
+ if (this.i >= this.src.length || this.src[this.i] === "\n") throw this.fail("unterminated string");
18849
+ out += this.src[this.i] === "\\" ? this.escape() : this.src[this.i++];
18850
+ }
18851
+ this.i++;
18852
+ return out;
18853
+ }
18854
+ literalString() {
18855
+ const end = this.src.indexOf("'", this.i + 1);
18856
+ const body = end < 0 ? "" : this.src.slice(this.i + 1, end);
18857
+ if (end < 0 || body.includes("\n")) throw this.fail("unterminated string");
18858
+ this.i = end + 1;
18859
+ return body;
18860
+ }
18861
+ multilineString(quote) {
18862
+ this.i += 3;
18863
+ if (this.src[this.i] === "\n") this.i++;
18864
+ else if (this.src.startsWith("\r\n", this.i)) this.i += 2;
18865
+ let out = "";
18866
+ for (; ; ) {
18867
+ if (this.i >= this.src.length) throw this.fail("unterminated string");
18868
+ if (this.src.startsWith(quote, this.i)) {
18869
+ let extra = 0;
18870
+ while (extra < 2 && this.src[this.i + 3 + extra] === quote[0]) extra++;
18871
+ out += quote[0].repeat(extra);
18872
+ this.i += 3 + extra;
18873
+ return out;
18874
+ }
18875
+ if (quote === '"""' && this.src[this.i] === "\\") {
18876
+ const rest = /^\\[ \t]*\r?\n[\s]*/.exec(this.src.slice(this.i, this.i + 256));
18877
+ if (rest) {
18878
+ this.i += rest[0].length;
18879
+ continue;
18880
+ }
18881
+ out += this.escape();
18882
+ continue;
18883
+ }
18884
+ out += this.src[this.i++];
18885
+ }
18886
+ }
18887
+ escape() {
18888
+ const code = this.src[this.i + 1];
18889
+ if (code in ESCAPES) {
18890
+ this.i += 2;
18891
+ return ESCAPES[code];
18892
+ }
18893
+ const width = code === "u" ? 4 : code === "U" ? 8 : 0;
18894
+ const hex = this.src.slice(this.i + 2, this.i + 2 + width);
18895
+ if (!width || !/^[0-9a-fA-F]+$/.test(hex) || hex.length !== width) throw this.fail("bad escape");
18896
+ this.i += 2 + width;
18897
+ return String.fromCodePoint(parseInt(hex, 16));
18898
+ }
18899
+ /** Spaces, tabs and comments; newlines too when `newlines`. */
18900
+ skipBlank(newlines) {
18901
+ while (this.i < this.src.length) {
18902
+ const ch = this.src[this.i];
18903
+ if (ch === " " || ch === " " || newlines && (ch === "\n" || ch === "\r")) this.i++;
18904
+ else if (ch === "#") while (this.i < this.src.length && this.src[this.i] !== "\n") this.i++;
18905
+ else return;
18906
+ }
18907
+ }
18908
+ endOfLine() {
18909
+ this.skipBlank(false);
18910
+ if (this.i < this.src.length && this.src[this.i] !== "\n" && this.src[this.i] !== "\r") throw this.fail("expected the end of the line");
18911
+ }
18912
+ expect(token) {
18913
+ this.skipBlank(false);
18914
+ if (!this.src.startsWith(token, this.i)) throw this.fail(`expected ${token}`);
18915
+ this.i += token.length;
18916
+ }
18917
+ fail(message) {
18918
+ const line = this.src.slice(0, this.i).split("\n").length;
18919
+ return new TomlError(`line ${line}: ${message}`);
18920
+ }
18921
+ };
18922
+
18923
+ // src/services/harness/codexRepoConfig.ts
18924
+ var CONFIG_FILE = path21.join(".codex", "config.toml");
18925
+ function stringsOf(value) {
18926
+ return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
18927
+ }
18928
+ function keysOf(value) {
18929
+ return isRecord3(value) ? Object.keys(value) : [];
18930
+ }
18931
+ function server2(name, entry, source) {
18932
+ if (!isRecord3(entry)) return "not a table";
18933
+ if (entry.enabled === false) return null;
18934
+ if (typeof entry.url === "string") {
18935
+ const names2 = [...keysOf(entry.http_headers), ...keysOf(entry.env_http_headers), ...typeof entry.bearer_token_env_var === "string" ? ["Authorization"] : []];
18936
+ return { name, source, transport: "remote", target: entry.url, names: names2, definition: entry };
18937
+ }
18938
+ if (typeof entry.command !== "string") return "no command or url";
18939
+ const names = [...keysOf(entry.env), ...stringsOf(entry.env_vars)];
18940
+ return { name, source, transport: "stdio", target: [entry.command, ...stringsOf(entry.args)].join(" "), names, definition: entry };
18941
+ }
18942
+ var CODEX_REPO_CONFIG = {
18943
+ /** Codex reads one file per directory from the repository root down, an override in place of the plain one. */
18944
+ instructions(workspace) {
18945
+ const files = [];
18946
+ for (const dir of repoLevels(workspace)) {
18947
+ for (const name of ["AGENTS.override.md", "AGENTS.md"]) {
18948
+ const text2 = readText(path21.join(dir, name));
18949
+ if (text2 === void 0) continue;
18950
+ files.push({ path: path21.relative(workspace, path21.join(dir, name)) || name, text: text2 });
18951
+ break;
18952
+ }
18953
+ }
18954
+ return files;
18955
+ },
18956
+ /** Each level's `.codex/config.toml`, the one nearer the workspace winning for a server both name. */
18957
+ projectServers(workspace) {
18958
+ const byName = /* @__PURE__ */ new Map();
18959
+ const unreadable = [];
18960
+ for (const dir of repoLevels(workspace)) {
18961
+ const file = path21.join(dir, CONFIG_FILE);
18962
+ const text2 = readText(file);
18963
+ if (text2 === void 0) continue;
18964
+ const source = path21.relative(workspace, file);
18965
+ let parsed;
18966
+ try {
18967
+ parsed = parseToml(text2);
18968
+ } catch (err) {
18969
+ unreadable.push({ source, reason: err instanceof Error ? err.message : String(err) });
18970
+ continue;
18971
+ }
18972
+ for (const [name, entry] of Object.entries(isRecord3(parsed.mcp_servers) ? parsed.mcp_servers : {})) {
18973
+ const read2 = server2(name, entry, source);
18974
+ if (typeof read2 === "string") unreadable.push({ source: `${source} (${name})`, reason: read2 });
18975
+ else if (read2) byName.set(name, read2);
18976
+ else byName.delete(name);
18977
+ }
18978
+ }
18979
+ return { servers: [...byName.values()], unreadable };
18980
+ }
18981
+ };
18982
+ function untrustedProjects(workspace) {
18983
+ const projects = {};
18984
+ for (let dir = path21.resolve(workspace); ; dir = path21.dirname(dir)) {
18985
+ projects[dir] = { trust_level: "untrusted" };
18986
+ if (path21.dirname(dir) === dir) return projects;
18987
+ }
17293
18988
  }
17294
18989
 
17295
18990
  // src/services/harness/codexOrdewell.ts
18991
+ var CODEX_SEARCH_PROVIDER = "openai";
17296
18992
  function headerEnv(mcp) {
17297
18993
  return Object.fromEntries(Object.keys(mcp.headers).map((header, i) => [header, `${MCP_TOKEN_VARIABLE_PREFIX}${i}`]));
17298
18994
  }
17299
18995
  var COUNT_WORDS = ["no", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"];
18996
+ var countOf = (names) => COUNT_WORDS[names.length] ?? String(names.length);
17300
18997
  var quoted2 = (names) => listed(names.map((name) => `\`${name}\``));
17301
18998
  var CODEX_ORDEWELL = {
17302
18999
  // Codex names a call by server and tool; the model reaches the tool by Claude Code's name for it.
17303
19000
  toolName: MCP_CLIENT_TOOL_NAMES.toolName,
17304
19001
  toolNames: MCP_CLIENT_TOOL_NAMES.toolNames,
19002
+ // With its own search on, a model offered Ordewell's too tends to take that instead. Codex has no fetch of its own.
19003
+ plannerHidden: (start) => start.nativeSearch ? ["web_search"] : [],
19004
+ sessionSearchProvider: CODEX_SEARCH_PROVIDER,
17305
19005
  /** An elicitation Codex raises for one of the server's tools: an approval the grant should have made unnecessary. */
17306
19006
  isOrdewellAsk: ({ method, params }) => method === "mcpServer/elicitation/request" && params.serverName === ORDEWELL_MCP_SERVER_NAME,
17307
19007
  /** `mcpServer/startupStatus/updated`'s status, unsent until the thread has tried to connect. */
@@ -17327,25 +19027,28 @@ var CODEX_ORDEWELL = {
17327
19027
  taskInstructions() {
17328
19028
  const tools = ordewellToolNames("task");
17329
19029
  return [
17330
- `This task has ${COUNT_WORDS[tools.length] ?? tools.length} tools from the \`${ORDEWELL_MCP_SERVER_NAME}\` MCP server: ${quoted2(tools)}.`,
19030
+ `This task has ${countOf(tools)} tools from the \`${ORDEWELL_MCP_SERVER_NAME}\` MCP server: ${quoted2(tools)}.`,
17331
19031
  `They are not in your tool list up front. Find them with the tool discovery you have (the \`exec\` tool's \`ALL_TOOLS\` list) and call them by their full names, ${quoted2(this.toolNames("task"))}.`,
17332
19032
  `Look for them before you finish; the task is reported complete through \`${TASK_COMPLETE_TOOL}\`.`
17333
19033
  ].join(" ");
17334
19034
  },
17335
19035
  /**
17336
19036
  * The same discovery note for a planner, which also needs to know that its
17337
- * commands go through `run_command`: Codex's own shell is sandboxed without
17338
- * network, so `gh` or a cloud CLI fails there, and a model that tried its
17339
- * shell first concludes the command cannot run.
19037
+ * commands and fetches go through Ordewell's tools: Codex's own shell is
19038
+ * sandboxed without network, so `gh`, a cloud CLI or `curl` fails there, and
19039
+ * a model that tried its shell first concludes the command cannot run.
17340
19040
  */
17341
- plannerInstructions() {
17342
- const tools = ordewellToolNames("planner");
19041
+ plannerInstructions(nativeSearch, hidden) {
19042
+ const tools = ordewellToolNames("planner", hidden);
17343
19043
  return [
17344
- `You have ${COUNT_WORDS[tools.length] ?? tools.length} tools from the \`${ORDEWELL_MCP_SERVER_NAME}\` MCP server: ${quoted2(tools)}.`,
17345
- `They are not in your tool list up front. Find them with the tool discovery you have (the \`exec\` tool's \`ALL_TOOLS\` list) and call them by their full names, ${quoted2(this.toolNames("planner"))}.`,
17346
- `Run shell commands with \`${this.toolName("run_command")}\`, not your own shell: yours is sandboxed without network, so git remotes, gh and cloud CLIs fail there.`
19044
+ `You have ${countOf(tools)} tools from the \`${ORDEWELL_MCP_SERVER_NAME}\` MCP server: ${quoted2(tools)}.`,
19045
+ `They are not in your tool list up front. Find them with the tool discovery you have (the \`exec\` tool's \`ALL_TOOLS\` list) and call them by their full names, ${quoted2(this.toolNames("planner", hidden))}.`,
19046
+ `Run shell commands with \`${this.toolName("run_command")}\`, not your own shell: yours is sandboxed without network, so git remotes, gh and cloud CLIs fail there.`,
19047
+ `Fetch web pages with \`${this.toolName("fetch")}\`, not curl or your shell.`,
19048
+ nativeSearch ? "Search the web with your built-in web search." : `Your built-in web search is off for this session; search with \`${this.toolName("web_search")}\`.`
17347
19049
  ].join(" ");
17348
- }
19050
+ },
19051
+ plannerWebSearch: (nativeSearch) => nativeSearch ? "cached" : "disabled"
17349
19052
  };
17350
19053
 
17351
19054
  // src/services/harness/codexRpc.ts
@@ -17365,7 +19068,7 @@ var CodexRpc = class {
17365
19068
  }
17366
19069
  /** The request as the awaited form of {@link send}, for steps that wait on one answer and nothing else. */
17367
19070
  call(method, params) {
17368
- return new Promise((resolve6) => this.send(method, params, (response) => resolve6(response)));
19071
+ return new Promise((resolve9) => this.send(method, params, (response) => resolve9(response)));
17369
19072
  }
17370
19073
  /**
17371
19074
  * A request framed as the line to write, left to the caller to write — for
@@ -17466,6 +19169,34 @@ function shownToolName(item) {
17466
19169
  const tool2 = item.tool ?? "mcp_tool";
17467
19170
  return item.server === ORDEWELL_MCP_SERVER_NAME ? CODEX_ORDEWELL.toolName(tool2) : tool2;
17468
19171
  }
19172
+ function webSearchSummary(item) {
19173
+ const action = item.action;
19174
+ switch (action?.type) {
19175
+ case "search": {
19176
+ const queries = action.queries?.length ? action.queries : [action.query ?? item.query ?? ""];
19177
+ return queries.filter(Boolean).join("\n");
19178
+ }
19179
+ case "openPage":
19180
+ return `open ${action.url ?? "(no URL)"}`;
19181
+ case "findInPage":
19182
+ return `find ${JSON.stringify(action.pattern ?? "")} in ${action.url ?? "(no URL)"}`;
19183
+ default:
19184
+ return item.query ?? "";
19185
+ }
19186
+ }
19187
+ function webSearchArgs(item) {
19188
+ const action = item.action;
19189
+ switch (action?.type) {
19190
+ case "search":
19191
+ return action.queries?.length ? { queries: action.queries } : { query: action.query ?? item.query ?? "" };
19192
+ case "openPage":
19193
+ return { url: action.url ?? "" };
19194
+ case "findInPage":
19195
+ return { url: action.url ?? "", pattern: action.pattern ?? "" };
19196
+ default:
19197
+ return { query: item.query ?? "" };
19198
+ }
19199
+ }
17469
19200
  var REVIEW_DECISIONS = { allow: "accept", allowForTask: "acceptForSession", deny: "decline" };
17470
19201
  var TASK_APPROVALS = {
17471
19202
  "item/commandExecution/requestApproval": {
@@ -17638,6 +19369,8 @@ ${this.exitMessage()}`);
17638
19369
  ...threadConfig
17639
19370
  };
17640
19371
  }
19372
+ const nativeSearch = opts?.nativeSearch ?? false;
19373
+ const servers = { ...Object.fromEntries((opts?.projectServers ?? []).map((server4) => [server4.name, server4.definition])), ...config.mcp_servers };
17641
19374
  return {
17642
19375
  ...common,
17643
19376
  sandbox: "read-only",
@@ -17646,13 +19379,22 @@ ${this.exitMessage()}`);
17646
19379
  // changes it asks about are still declined: the sandbox holds, and commands go
17647
19380
  // through Ordewell's `run_command`.
17648
19381
  approvalPolicy: "on-request",
17649
- ...threadConfig,
19382
+ // Codex's search raises no request the policy could decide call by
19383
+ // call, so it is decided once here (ADR-0027).
19384
+ // The repository's own config never loads for a planner, so its servers
19385
+ // are only those approved, handed over as parsed (ADR-0028).
19386
+ config: {
19387
+ ...config,
19388
+ ...Object.keys(servers).length > 0 ? { mcp_servers: servers } : {},
19389
+ projects: opts ? untrustedProjects(opts.cwd) : {},
19390
+ web_search: CODEX_ORDEWELL.plannerWebSearch(nativeSearch)
19391
+ },
17650
19392
  // `developerInstructions` layers on top of Codex's own base prompt, the
17651
19393
  // way Claude Code's `--append-system-prompt` does. `baseInstructions`
17652
19394
  // replaces it — which takes Codex's description of its own tools with
17653
19395
  // it, and a planner that has forgotten it can read the workspace
17654
19396
  // answers from a web search instead.
17655
- developerInstructions: [opts?.systemPrompt, opts?.mcp ? CODEX_ORDEWELL.plannerInstructions() : ""].filter(Boolean).join("\n\n") || void 0
19397
+ developerInstructions: [opts?.systemPrompt, opts?.mcp ? CODEX_ORDEWELL.plannerInstructions(nativeSearch, opts.mcp.hidden) : ""].filter(Boolean).join("\n\n") || void 0
17656
19398
  };
17657
19399
  }
17658
19400
  /**
@@ -17665,8 +19407,8 @@ ${this.exitMessage()}`);
17665
19407
  return awaitAttach(async (left) => {
17666
19408
  const known = CODEX_ORDEWELL.attachState(this.mcpStartup);
17667
19409
  if (known !== "pending") return known;
17668
- const reported = new Promise((resolve6) => {
17669
- this.mcpStartupSettled = () => resolve6(CODEX_ORDEWELL.attachState(this.mcpStartup));
19410
+ const reported = new Promise((resolve9) => {
19411
+ this.mcpStartupSettled = () => resolve9(CODEX_ORDEWELL.attachState(this.mcpStartup));
17670
19412
  });
17671
19413
  const state = await settleWithin(reported, {
17672
19414
  timeoutMs: left,
@@ -17691,8 +19433,8 @@ ${this.exitMessage()}`);
17691
19433
  const turn = this.turn;
17692
19434
  if (!this.process || !turn) return Promise.resolve(false);
17693
19435
  this.interruptRequested = true;
17694
- const acknowledged = new Promise((resolve6) => {
17695
- this.whenNamed(turn, () => this.rpc.send("turn/interrupt", { threadId: this.threadId, turnId: turn.id }, (response) => resolve6(response.ok)));
19436
+ const acknowledged = new Promise((resolve9) => {
19437
+ this.whenNamed(turn, () => this.rpc.send("turn/interrupt", { threadId: this.threadId, turnId: turn.id }, (response) => resolve9(response.ok)));
17696
19438
  });
17697
19439
  return settleWithin(acknowledged, { timeoutMs, ended: this.processEnded, onTimeout: () => false, onEnded: () => false });
17698
19440
  }
@@ -17713,7 +19455,7 @@ ${this.exitMessage()}`);
17713
19455
  const turn = this.turn;
17714
19456
  if (!this.process || !turn) return Promise.resolve(false);
17715
19457
  const clientId = (0, import_crypto7.randomUUID)();
17716
- return new Promise((resolve6) => {
19458
+ return new Promise((resolve9) => {
17717
19459
  let settled = false;
17718
19460
  const steer = {
17719
19461
  id,
@@ -17722,7 +19464,7 @@ ${this.exitMessage()}`);
17722
19464
  if (settled) return;
17723
19465
  settled = true;
17724
19466
  if (!accepted) turn.steers.delete(clientId);
17725
- resolve6(accepted);
19467
+ resolve9(accepted);
17726
19468
  }
17727
19469
  };
17728
19470
  turn.steers.set(clientId, steer);
@@ -18071,16 +19813,16 @@ ${this.exitMessage()}`);
18071
19813
  holdMcpToolApproval(msg, method, params, emit) {
18072
19814
  if (method !== "mcpServer/elicitation/request") return false;
18073
19815
  const meta = params._meta;
18074
- const server = params.serverName;
18075
- if (meta?.codex_approval_kind !== "mcp_tool_call" || typeof server !== "string") return false;
19816
+ const server4 = params.serverName;
19817
+ if (meta?.codex_approval_kind !== "mcp_tool_call" || typeof server4 !== "string") return false;
18076
19818
  const asked = typeof params.message === "string" ? /run tool "([^"]+)"/.exec(params.message)?.[1] : void 0;
18077
- const tool2 = asked ?? this.mcpCallsInProgress.get(server);
19819
+ const tool2 = asked ?? this.mcpCallsInProgress.get(server4);
18078
19820
  if (!tool2) return false;
18079
19821
  const id = String(msg.id);
18080
19822
  const input = typeof meta.tool_params === "object" && meta.tool_params !== null ? meta.tool_params : {};
18081
- const scope = `mcp__${server}__${tool2}`;
19823
+ const scope = `mcp__${server4}__${tool2}`;
18082
19824
  this.openPermissions.set(id, { requestId: msg.id, method, params });
18083
- emit({ type: "permission_request", id, name: scope, detail: JSON.stringify(input), input, ask: { kind: "mcp", scope, tool: tool2, server } });
19825
+ emit({ type: "permission_request", id, name: scope, detail: JSON.stringify(input), input, ask: { kind: "mcp", scope, tool: tool2, server: server4 } });
18084
19826
  return true;
18085
19827
  }
18086
19828
  /** A tool item entering `inProgress` — announce the call so the timeline moves. */
@@ -18100,11 +19842,11 @@ ${this.exitMessage()}`);
18100
19842
  case "dynamicToolCall":
18101
19843
  emit({ type: "tool_call", id: item.id, name: item.tool ?? "tool", args: item.arguments ?? {}, ...subagentId ? { subagentId } : {} });
18102
19844
  return;
19845
+ // Announced on completion instead: at start the query is still empty.
18103
19846
  case "webSearch":
18104
- emit({ type: "tool_call", id: item.id, name: "web_search", args: { query: item.query }, ...subagentId ? { subagentId } : {} });
18105
19847
  return;
18106
19848
  case "fileChange": {
18107
- const paths = (item.changes ?? []).map((change) => change.path).filter((path27) => !!path27);
19849
+ const paths = (item.changes ?? []).map((change) => change.path).filter((path32) => !!path32);
18108
19850
  this.fileChangePaths.set(item.id, paths);
18109
19851
  if (this.startOpts?.kind === "task") {
18110
19852
  for (const change of item.changes ?? []) this.announceFileChange(item.id, change, emit, subagentId);
@@ -18175,9 +19917,10 @@ ${item.text}` : item.text });
18175
19917
  });
18176
19918
  return;
18177
19919
  // The query is empty when the search starts and filled when it lands, so
18178
- // the result — not the call — is what carries what was actually searched.
19920
+ // the call is announced only now, with what was actually searched.
18179
19921
  case "webSearch":
18180
- emit({ type: "tool_result", id, name: "web_search", output: item.query ?? "", success: true, ...subagentId ? { subagentId } : {} });
19922
+ emit({ type: "tool_call", id, name: "web_search", args: webSearchArgs(item), ...subagentId ? { subagentId } : {} });
19923
+ emit({ type: "tool_result", id, name: "web_search", output: webSearchSummary(item), success: true, ...subagentId ? { subagentId } : {} });
18181
19924
  return;
18182
19925
  case "fileChange":
18183
19926
  this.fileChangePaths.delete(id);
@@ -18205,20 +19948,20 @@ ${item.text}` : item.text });
18205
19948
  announceFileChange(itemId, change, emit, subagentId) {
18206
19949
  const rows = this.fileChangeRows.get(itemId) ?? /* @__PURE__ */ new Map();
18207
19950
  this.fileChangeRows.set(itemId, rows);
18208
- const path27 = this.shownChangePath(change);
18209
- const known = rows.get(path27);
19951
+ const path32 = this.shownChangePath(change);
19952
+ const known = rows.get(path32);
18210
19953
  if (known) return known;
18211
- const row = rows.size === 0 ? itemId : `${itemId}:${path27}`;
18212
- rows.set(path27, row);
18213
- emit({ type: "tool_call", id: row, name: changeName(change), args: { path: path27 }, ...subagentId ? { subagentId } : {} });
19954
+ const row = rows.size === 0 ? itemId : `${itemId}:${path32}`;
19955
+ rows.set(path32, row);
19956
+ emit({ type: "tool_call", id: row, name: changeName(change), args: { path: path32 }, ...subagentId ? { subagentId } : {} });
18214
19957
  return row;
18215
19958
  }
18216
19959
  /** Relative to the task's worktree, where every path but a stray one lies; a move names both ends. */
18217
19960
  shownChangePath(change) {
18218
- const shown = (path27) => {
19961
+ const shown = (path32) => {
18219
19962
  const cwd = this.startOpts?.cwd;
18220
- const rel = cwd ? (0, import_path4.relative)(cwd, path27) : path27;
18221
- return rel && rel !== ".." && !rel.startsWith(`..${import_path4.sep}`) && !(0, import_path4.isAbsolute)(rel) ? rel : path27;
19963
+ const rel = cwd ? (0, import_path4.relative)(cwd, path32) : path32;
19964
+ return rel && rel !== ".." && !rel.startsWith(`..${import_path4.sep}`) && !(0, import_path4.isAbsolute)(rel) ? rel : path32;
18222
19965
  };
18223
19966
  const from = shown(change.path ?? "");
18224
19967
  return change.kind?.move_path ? `${from} \u2192 ${shown(change.kind.move_path)}` : from;
@@ -18262,10 +20005,91 @@ ${item.text}` : item.text });
18262
20005
  };
18263
20006
 
18264
20007
  // src/services/harness/OpenCodeAdapter.ts
18265
- var import_crypto9 = require("crypto");
20008
+ var import_crypto10 = require("crypto");
18266
20009
 
18267
- // src/services/harness/openCodeTransport.ts
20010
+ // src/services/harness/projectServers.ts
18268
20011
  var import_crypto8 = require("crypto");
20012
+ var path22 = __toESM(require("path"));
20013
+ function canonical(value) {
20014
+ if (Array.isArray(value)) return value.map(canonical);
20015
+ if (!isRecord3(value)) return value;
20016
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
20017
+ }
20018
+ function projectServerScope(server4) {
20019
+ const hash = (0, import_crypto8.createHash)("sha256").update(JSON.stringify(canonical(server4.definition))).digest("hex").slice(0, 12);
20020
+ return `mcp-server:${server4.name}@${hash}`;
20021
+ }
20022
+ var scopeName = (scope) => scope.slice(0, scope.lastIndexOf("@"));
20023
+ function fileProjectServerApprovals(file = path22.join(globalDataDir(), "project-mcp-servers.json")) {
20024
+ const load = () => {
20025
+ const text2 = readText(file);
20026
+ if (text2 === void 0) return {};
20027
+ try {
20028
+ const parsed = JSON.parse(text2);
20029
+ const repositories = isRecord3(parsed) && isRecord3(parsed.repositories) ? parsed.repositories : {};
20030
+ return Object.fromEntries(Object.entries(repositories).map(([repo, scopes]) => [repo, Array.isArray(scopes) ? scopes.filter((s) => typeof s === "string") : []]));
20031
+ } catch {
20032
+ return {};
20033
+ }
20034
+ };
20035
+ return {
20036
+ approved: (repoRoot2, scope) => load()[repoRoot2]?.includes(scope) ?? false,
20037
+ remember(repoRoot2, scope) {
20038
+ const repositories = load();
20039
+ repositories[repoRoot2] = [...(repositories[repoRoot2] ?? []).filter((s) => scopeName(s) !== scopeName(scope)), scope];
20040
+ writePrivateFile(file, `${JSON.stringify({ repositories }, null, 2)}
20041
+ `);
20042
+ }
20043
+ };
20044
+ }
20045
+ function describe(server4, scope) {
20046
+ const what = server4.transport === "stdio" ? `Starting it runs this command as you${server4.names.length ? `, setting ${server4.names.join(", ")}` : ""}.` : `Starting it connects to this URL${server4.names.length ? `, sending ${server4.names.join(", ")}` : ""}.`;
20047
+ return [
20048
+ `The repository declares this MCP server for the planner in ${server4.source}. ${what}`,
20049
+ "Any ${VAR} in it is filled in from your environment.",
20050
+ `Approving is remembered for this repository until the definition changes; the planner allowlist entry ${scope} starts it without asking.`
20051
+ ].join(" ");
20052
+ }
20053
+ function startRequest(server4) {
20054
+ const scope = projectServerScope(server4);
20055
+ return { kind: "mcp_server", subject: `${server4.name}: ${server4.target}`, scope, detail: describe(server4, scope) };
20056
+ }
20057
+ async function decideProjectServers(servers, opts) {
20058
+ const decision = { started: [], held: [] };
20059
+ for (const server4 of servers) {
20060
+ const req = startRequest(server4);
20061
+ const { scope } = req;
20062
+ const list = opts.approval?.allowlist?.();
20063
+ let started = false;
20064
+ if (list && scopeExcluded(list, scope)) {
20065
+ opts.approval?.record?.(req, false, "excluded");
20066
+ } else if (opts.approvals.approved(opts.repoRoot, scope)) {
20067
+ opts.approval?.record?.(req, true, "remembered");
20068
+ started = true;
20069
+ } else if (opts.approval) {
20070
+ const unasked = opts.approval.peek?.(req).decision === "allow";
20071
+ started = await opts.approval.request(req).catch(() => false);
20072
+ if (started && !unasked) opts.approvals.remember(opts.repoRoot, scope);
20073
+ }
20074
+ (started ? decision.started : decision.held).push(server4);
20075
+ }
20076
+ return decision;
20077
+ }
20078
+ function recordUnusable(servers, approval) {
20079
+ for (const server4 of servers) approval?.record?.(startRequest(server4), false, "unsupported");
20080
+ }
20081
+ var NOT_APPROVED = "not approved for planning";
20082
+ function heldServersNote(held, unreadable = []) {
20083
+ const lines2 = [
20084
+ ...held.map(({ server: server4, reason }) => `- ${server4.name} (${server4.source}): ${reason}`),
20085
+ ...unreadable.map((file) => `- ${file.source}: Ordewell could not read it (${file.reason})`)
20086
+ ];
20087
+ if (lines2.length === 0) return "";
20088
+ return ["The repository declares MCP servers whose tools are not available to you while planning. Work that needs them belongs in the plan's tasks.", ...lines2].join("\n");
20089
+ }
20090
+
20091
+ // src/services/harness/openCodeTransport.ts
20092
+ var import_crypto9 = require("crypto");
18269
20093
  var STREAM_CONNECT_TIMEOUT_MS = 5e3;
18270
20094
  var OPENCODE_BUILTINS = /* @__PURE__ */ new Set([
18271
20095
  "bash",
@@ -18302,9 +20126,9 @@ var OPENCODE_BUILTINS = /* @__PURE__ */ new Set([
18302
20126
  function openCodePlannerAsk(permission, details, servers) {
18303
20127
  const meta = details.metadata ?? {};
18304
20128
  const pattern = details.patterns?.[0];
18305
- if (permission === "webfetch") {
18306
- const url = typeof meta.url === "string" ? meta.url : pattern;
18307
- return url ? { kind: "fetch", url } : { kind: "other" };
20129
+ if (permission === "websearch" || permission === "codesearch") {
20130
+ const query = typeof meta.query === "string" ? meta.query : pattern;
20131
+ return query ? { kind: "search", query, ...OPENCODE_SEARCH_PROVIDER } : { kind: "other" };
18308
20132
  }
18309
20133
  if (permission === "external_directory") {
18310
20134
  if (typeof meta.filepath === "string") return { kind: "path", path: meta.filepath, directory: false };
@@ -18312,9 +20136,13 @@ function openCodePlannerAsk(permission, details, servers) {
18312
20136
  return dir ? { kind: "path", path: dir, directory: true } : { kind: "other" };
18313
20137
  }
18314
20138
  if (OPENCODE_BUILTINS.has(permission)) return { kind: "other" };
18315
- const server = servers.filter((s) => permission.startsWith(`${s}_`)).sort((a, b) => b.length - a.length)[0];
18316
- if (!server) return { kind: "other" };
18317
- return { kind: "mcp", scope: permission, tool: permission.slice(server.length + 1), server };
20139
+ const server4 = servers.filter((s) => permission.startsWith(`${s}_`)).sort((a, b) => b.length - a.length)[0];
20140
+ if (!server4) return { kind: "other" };
20141
+ return { kind: "mcp", scope: permission, tool: permission.slice(server4.length + 1), server: server4 };
20142
+ }
20143
+ function plannerSystem(opts, ownSearch = true) {
20144
+ const system = [opts.systemPrompt, opts.mcp ? OPENCODE_ORDEWELL.plannerInstructions(ownSearch) : ""].filter(Boolean).join("\n\n");
20145
+ return system ? { system } : {};
18318
20146
  }
18319
20147
  function splitModelId(id) {
18320
20148
  const slash = id.indexOf("/");
@@ -18322,14 +20150,14 @@ function splitModelId(id) {
18322
20150
  return { providerID: id.slice(0, slash), modelID: id.slice(slash + 1) };
18323
20151
  }
18324
20152
  function delay(ms, signal) {
18325
- return new Promise((resolve6) => {
20153
+ return new Promise((resolve9) => {
18326
20154
  const onAbort = () => {
18327
20155
  clearTimeout(timer);
18328
- resolve6();
20156
+ resolve9();
18329
20157
  };
18330
20158
  const timer = setTimeout(() => {
18331
20159
  signal?.removeEventListener("abort", onAbort);
18332
- resolve6();
20160
+ resolve9();
18333
20161
  }, ms);
18334
20162
  timer.unref?.();
18335
20163
  signal?.addEventListener("abort", onAbort, { once: true });
@@ -18356,7 +20184,7 @@ function autoApproves(task) {
18356
20184
  return task.flags.modeSettings.approvals === "auto";
18357
20185
  }
18358
20186
  function newUserMessageId() {
18359
- return `msg_${(0, import_crypto8.randomBytes)(12).toString("hex")}`;
20187
+ return `msg_${(0, import_crypto9.randomBytes)(12).toString("hex")}`;
18360
20188
  }
18361
20189
  var PendingSteers = class {
18362
20190
  /** User message id → the Ordewell message id it was sent for. */
@@ -18531,8 +20359,8 @@ function streamTurn() {
18531
20359
  }
18532
20360
  function turnLatch() {
18533
20361
  let markDone;
18534
- const ended = new Promise((resolve6) => {
18535
- markDone = resolve6;
20362
+ const ended = new Promise((resolve9) => {
20363
+ markDone = resolve9;
18536
20364
  });
18537
20365
  const turn = { live: false, done: false, ended, finish: () => {
18538
20366
  turn.done = true;
@@ -18542,9 +20370,9 @@ function turnLatch() {
18542
20370
  }
18543
20371
  async function settleTurn(turn, how, onEvent) {
18544
20372
  const { signal } = how;
18545
- const aborted = new Promise((resolve6) => {
18546
- if (signal?.aborted) resolve6();
18547
- signal?.addEventListener("abort", () => resolve6(), { once: true });
20373
+ const aborted = new Promise((resolve9) => {
20374
+ if (signal?.aborted) resolve9();
20375
+ signal?.addEventListener("abort", () => resolve9(), { once: true });
18548
20376
  });
18549
20377
  await Promise.race([turn.ended, aborted, how.processEnded]);
18550
20378
  if (signal?.aborted) return false;
@@ -18563,8 +20391,8 @@ async function interruptAcknowledged(turn, processEnded, timeoutMs) {
18563
20391
  async function openEventStream(connect, onFrame, onActivity) {
18564
20392
  const streamAbort = new AbortController();
18565
20393
  let connected;
18566
- const ready = new Promise((resolve6) => {
18567
- connected = resolve6;
20394
+ const ready = new Promise((resolve9) => {
20395
+ connected = resolve9;
18568
20396
  });
18569
20397
  const live = streamEvents(connect, streamAbort.signal, onFrame, connected, onActivity);
18570
20398
  await settleWithin(ready, { timeoutMs: STREAM_CONNECT_TIMEOUT_MS, onTimeout: () => void 0 });
@@ -18600,7 +20428,35 @@ async function streamEvents(connect, signal, onFrame, onConnected, onActivity) {
18600
20428
  var ACTIVE_POLL_INTERVAL_MS = 1e3;
18601
20429
  var IDLE_POLLS_BEFORE_START = 3;
18602
20430
  var PLANNER_RULES = Object.entries(OPENCODE_PLANNER_PERMISSION).map(([action, effect]) => ({ action, resource: "*", effect }));
20431
+ var SEARCH_OFF_RULES = Object.entries(OPENCODE_SEARCH_OFF).map(([action, effect]) => ({ action, resource: "*", effect }));
20432
+ var CODE_MODE_OFF_RULES = [{ action: "execute", resource: "*", effect: "deny" }];
20433
+ function codeModeOnly(version) {
20434
+ return `OpenCode${version ? ` ${version}` : ""} cannot run the planner safely: it can only offer Ordewell's tools inside code mode, which runs code the planner writes without asking. The planner needs an OpenCode version that can list MCP tools outside code mode. Update OpenCode, or choose another planner runner.`;
20435
+ }
20436
+ var UNUSABLE = "not usable on this OpenCode version: its tools are only reachable through code mode's `execute`, which the planner may not use";
18603
20437
  var TASK_RULES = [{ action: "question", resource: "*", effect: "deny" }];
20438
+ function isRecord4(value) {
20439
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20440
+ }
20441
+ function searchProviderChosen(config) {
20442
+ const layers = Array.isArray(config) ? config : isRecord4(config) && Array.isArray(config.data) ? config.data : [];
20443
+ for (let i = layers.length - 1; i >= 0; i--) {
20444
+ const layer = layers[i];
20445
+ if (!isRecord4(layer) || layer.type !== "document" || !isRecord4(layer.info) || layer.info.websearch === void 0) continue;
20446
+ const setting = layer.info.websearch;
20447
+ return isRecord4(setting) && typeof setting.provider === "string" && setting.provider !== "";
20448
+ }
20449
+ return false;
20450
+ }
20451
+ function rulesDeny(rules, action) {
20452
+ if (!Array.isArray(rules)) return false;
20453
+ const last = [...rules].reverse().find((rule) => isRecord4(rule) && rule.action === action && (rule.resource === "*" || rule.resource === void 0));
20454
+ return isRecord4(last) && last.effect === "deny";
20455
+ }
20456
+ function transportFields(definition) {
20457
+ const fields = definition.type === "remote" ? ["type", "url", "headers"] : ["type", "command", "environment"];
20458
+ return Object.fromEntries(fields.filter((field) => definition[field] !== void 0).map((field) => [field, definition[field]]));
20459
+ }
18604
20460
  function newTurnState() {
18605
20461
  return { ...streamTurn(), toolNames: /* @__PURE__ */ new Map(), stepModels: /* @__PURE__ */ new Map(), briefs: /* @__PURE__ */ new Map(), streamedMessages: /* @__PURE__ */ new Set() };
18606
20462
  }
@@ -18638,6 +20494,12 @@ var OpenCodeV2 = class {
18638
20494
  agent;
18639
20495
  turn = null;
18640
20496
  interruptRequested = false;
20497
+ /** Whether a planner's own `websearch` is on for its session (ADR-0027). */
20498
+ ownSearch = true;
20499
+ /** Approved project servers whose tools are listed as tools of their own. */
20500
+ exposed = [];
20501
+ /** Approved project servers whose tools this OpenCode would list only inside `execute`, which a planner may not use. */
20502
+ unusable = [];
18641
20503
  permissions = new OpenCodePermissions(
18642
20504
  (id, sessionId, decision) => this.replyPermission(id, sessionId, decision),
18643
20505
  () => this.opts.mcp
@@ -18645,13 +20507,18 @@ var OpenCodeV2 = class {
18645
20507
  nativeSessionId() {
18646
20508
  return this.sessionId;
18647
20509
  }
20510
+ unusableProjectServers() {
20511
+ return [...this.unusable];
20512
+ }
18648
20513
  async start() {
18649
- await this.exposeOrdewellTools();
20514
+ await this.exposeServers();
18650
20515
  const resume = this.opts.resumeSessionId;
18651
20516
  if (resume) {
18652
20517
  const existing = await this.host.json("GET", `/api/session/${resume}`).catch(() => null);
18653
- if (existing?.data?.id) {
20518
+ const resumable = this.role === "task" || rulesDeny(existing?.data?.permissions, "execute");
20519
+ if (existing?.data?.id && resumable) {
18654
20520
  this.sessionId = existing.data.id;
20521
+ if (this.role === "planner") this.ownSearch = !rulesDeny(existing.data.permissions, "websearch");
18655
20522
  if (this.model) await this.host.request("POST", `/api/session/${this.sessionId}/model`, { model: this.model }).catch(() => void 0);
18656
20523
  await this.host.request("POST", `/api/session/${this.sessionId}/agent`, { agent: this.agent }).catch(() => void 0);
18657
20524
  await this.applyInstructions();
@@ -18659,10 +20526,11 @@ var OpenCodeV2 = class {
18659
20526
  }
18660
20527
  if (this.opts.kind === "task") throw new Error(`OpenCode could not resume session ${resume}: the server does not know it.`);
18661
20528
  }
20529
+ if (this.role === "planner") this.ownSearch = searchProviderChosen(await this.host.json("GET", "/api/config").catch(() => null));
18662
20530
  const created = await this.host.json("POST", "/api/session", {
18663
20531
  agent: this.agent,
18664
20532
  ...this.model ? { model: this.model } : {},
18665
- permissions: [...this.role === "planner" ? PLANNER_RULES : TASK_RULES, ...this.ordewellRules()]
20533
+ permissions: [...this.role === "planner" ? [...PLANNER_RULES, ...this.ownSearch ? [] : SEARCH_OFF_RULES, ...CODE_MODE_OFF_RULES] : TASK_RULES, ...this.ordewellRules()]
18666
20534
  });
18667
20535
  if (!created?.data?.id) throw new Error(`The OpenCode ${this.role} server did not return a session id.`);
18668
20536
  this.sessionId = created.data.id;
@@ -18670,16 +20538,31 @@ var OpenCodeV2 = class {
18670
20538
  }
18671
20539
  /**
18672
20540
  * 2.x lists an MCP server's tools inside its code-mode `execute` tool, where
18673
- * a model told to call `task_complete` does not look for it. The server is
18674
- * replaced at runtime with `codemode: false`, which lists its tools as tools
18675
- * of their own: the config it started with is in the 1.x shape both versions
18676
- * read, and that shape drops the setting. If the route is gone, the tools
18677
- * are still reachable through `execute`.
18678
- */
18679
- async exposeOrdewellTools() {
20541
+ * a model told to call `task_complete` does not look for it, and where a
20542
+ * planner may not reach them: `execute` runs model-written code over every
20543
+ * tool at once, past per-tool approval. Each server is replaced at runtime
20544
+ * with `codemode: false`, which lists its tools as tools of their own: the
20545
+ * config it started with is in the 1.x shape both versions read, and that
20546
+ * shape drops the setting. Replaced before anything lists the servers, a
20547
+ * local one starts once; replaced later, OpenCode stops it and starts the
20548
+ * new one. If the route is gone, Ordewell's tools are reachable only
20549
+ * through `execute`: a task keeps that, but a planner would have to choose
20550
+ * between an unchecked code runner and no way to submit a plan, so it is
20551
+ * not started. A project server's tools are then not reachable at all.
20552
+ */
20553
+ async exposeServers() {
18680
20554
  const mcp = this.opts.mcp;
18681
- if (!mcp) return;
18682
- await this.host.request("PUT", `/api/experimental/mcp/${mcp.name}`, { config: { type: "remote", url: mcp.url, headers: mcp.headers, codemode: false } }).catch(() => void 0);
20555
+ if (mcp && !await this.expose(mcp.name, { type: "remote", url: mcp.url, headers: mcp.headers }) && this.role === "planner") {
20556
+ throw new Error(codeModeOnly(this.host.version));
20557
+ }
20558
+ const project = this.opts.kind === "planner" ? this.opts.projectServers ?? [] : [];
20559
+ for (const server4 of project) {
20560
+ if (await this.expose(server4.name, transportFields(server4.definition))) this.exposed.push(server4.name);
20561
+ else this.unusable.push(server4);
20562
+ }
20563
+ }
20564
+ async expose(name, config) {
20565
+ return this.host.request("PUT", `/api/experimental/mcp/${name}`, { config: { ...config, codemode: false } }).then((response) => response.ok, () => false);
18683
20566
  }
18684
20567
  /** Allow the Ordewell server's tools, so a call never waits on a person (ADR-0022, S3). */
18685
20568
  ordewellRules() {
@@ -18687,8 +20570,11 @@ var OpenCodeV2 = class {
18687
20570
  }
18688
20571
  /** The planner's system prompt rides on the session as an instruction entry — a prompt has no system field. */
18689
20572
  async applyInstructions() {
18690
- if (this.opts.kind !== "planner" || !this.opts.systemPrompt) return;
18691
- const response = await this.host.request("PUT", `/api/experimental/session/${this.sessionId}/instructions/entries/ordewell-planner`, { value: this.opts.systemPrompt });
20573
+ if (this.opts.kind !== "planner") return;
20574
+ const unusable = heldServersNote(this.unusable.map((server4) => ({ server: server4, reason: UNUSABLE })));
20575
+ const system = [plannerSystem(this.opts, this.ownSearch).system, unusable].filter(Boolean).join("\n\n");
20576
+ if (!system) return;
20577
+ const response = await this.host.request("PUT", `/api/experimental/session/${this.sessionId}/instructions/entries/ordewell-planner`, { value: system });
18692
20578
  if (!response.ok) throw new Error(`OpenCode did not take the planner instructions: ${response.status} ${response.statusText}`);
18693
20579
  }
18694
20580
  async send(message, onEvent, signal, onActivity) {
@@ -18946,7 +20832,8 @@ var OpenCodeV2 = class {
18946
20832
  const request = this.permissionRequest(ask);
18947
20833
  if (!request) return;
18948
20834
  if (this.role !== "task") {
18949
- const target = openCodePlannerAsk(request.name, { patterns: ask.resources }, this.host.mcpServers?.() ?? []);
20835
+ const listed2 = (this.host.mcpServers?.() ?? []).filter((name) => !this.unusable.some((server4) => server4.name === name));
20836
+ const target = openCodePlannerAsk(request.name, { patterns: ask.resources }, [...listed2, ...this.exposed]);
18950
20837
  this.permissions.ask({ ...request, ask: target }, session, false, state.seen, onEvent);
18951
20838
  } else this.permissions.ask(request, session, this.opts.kind === "task" && autoApproves(this.opts), state.seen, onEvent);
18952
20839
  }
@@ -19068,18 +20955,20 @@ var OpenCodeAdapter = class {
19068
20955
  async launch(opts) {
19069
20956
  if (opts.kind === "task") this.task = opts;
19070
20957
  else this.planner = opts;
19071
- const password = (0, import_crypto9.randomBytes)(24).toString("base64url");
20958
+ const password = (0, import_crypto10.randomBytes)(24).toString("base64url");
19072
20959
  const command = () => ({
19073
20960
  command: "opencode",
19074
20961
  args: ["serve", "--hostname", "127.0.0.1", "--port", "0"],
19075
20962
  env: (workspaceEnv) => {
19076
20963
  const existing = workspaceEnv[OPENCODE_CONFIG_VARIABLE] ?? process.env[OPENCODE_CONFIG_VARIABLE];
19077
- const ordewellConfig = opts.kind === "planner" ? OPENCODE_ORDEWELL.configContent(existing, opts.mcp ?? null, "planner") ?? OPENCODE_ORDEWELL.configContent(void 0, opts.mcp ?? null, "planner") : opts.mcp ? OPENCODE_ORDEWELL.configContent(existing, opts.mcp, "task") : null;
20964
+ const projectServers = opts.kind === "planner" ? Object.fromEntries((opts.projectServers ?? []).map((server4) => [server4.name, server4.definition])) : {};
20965
+ const ordewellConfig = opts.kind === "planner" ? OPENCODE_ORDEWELL.configContent(existing, opts.mcp ?? null, "planner", projectServers) ?? OPENCODE_ORDEWELL.configContent(void 0, opts.mcp ?? null, "planner", projectServers) : opts.mcp ? OPENCODE_ORDEWELL.configContent(existing, opts.mcp, "task") : null;
19078
20966
  if (opts.mcp && ordewellConfig === null) {
19079
20967
  console.error("[opencode] OPENCODE_CONFIG_CONTENT is not a JSON object, so the Ordewell tools were not injected.");
19080
20968
  }
19081
20969
  this.ordewell = ordewellConfig === null ? null : opts.mcp ?? null;
19082
20970
  return {
20971
+ ...opts.kind === "planner" ? OPENCODE_PLANNER_ENV : {},
19083
20972
  ...ordewellConfig === null ? {} : { [OPENCODE_CONFIG_VARIABLE]: ordewellConfig },
19084
20973
  OPENCODE_SERVER_USERNAME: SERVER_USERNAME,
19085
20974
  OPENCODE_SERVER_PASSWORD: password
@@ -19099,7 +20988,7 @@ var OpenCodeAdapter = class {
19099
20988
  * only until the address is known.
19100
20989
  */
19101
20990
  serverAddress() {
19102
- return new Promise((resolve6) => {
20991
+ return new Promise((resolve9) => {
19103
20992
  let partial = "";
19104
20993
  const stopReading = this.runner.readStdout((text2) => {
19105
20994
  const lines2 = (partial + text2).split("\n");
@@ -19114,7 +21003,7 @@ var OpenCodeAdapter = class {
19114
21003
  const settle = (url) => {
19115
21004
  stopReading();
19116
21005
  clearTimeout(timer);
19117
- resolve6(url);
21006
+ resolve9(url);
19118
21007
  };
19119
21008
  void this.processEnded.then(() => settle(null));
19120
21009
  });
@@ -19125,10 +21014,12 @@ var OpenCodeAdapter = class {
19125
21014
  if (!this.baseUrl) {
19126
21015
  throw new Error(this.runner.withStderrTail(`The OpenCode ${this.role()} server did not start.`));
19127
21016
  }
19128
- if (await this.speaksV2()) {
21017
+ const version = await this.v2Version();
21018
+ if (version !== null) {
19129
21019
  this.v2 = new OpenCodeV2({
19130
- request: (method, path27, body, signal) => this.request(method, path27, body, signal),
19131
- json: (method, path27, body, signal) => this.json(method, path27, body, signal),
21020
+ version,
21021
+ request: (method, path32, body, signal) => this.request(method, path32, body, signal),
21022
+ json: (method, path32, body, signal) => this.json(method, path32, body, signal),
19132
21023
  openEvents: (signal) => this.deps.fetch(`${this.baseUrl}/api/event`, { signal, headers: this.headers() }),
19133
21024
  processEnded: this.processEnded,
19134
21025
  isExited: () => this.exited,
@@ -19178,7 +21069,7 @@ var OpenCodeAdapter = class {
19178
21069
  tools: DISABLED_TOOLS,
19179
21070
  ...model ? { model } : {},
19180
21071
  ...this.planner?.effort ? { variant: this.planner.effort } : {},
19181
- ...this.planner?.systemPrompt ? { system: this.planner.systemPrompt } : {}
21072
+ ...this.planner ? plannerSystem(this.planner) : {}
19182
21073
  };
19183
21074
  const reply2 = await this.json("POST", `/session/${this.sessionId}/message`, body, signal);
19184
21075
  if (signal?.aborted) {
@@ -19394,9 +21285,9 @@ var OpenCodeAdapter = class {
19394
21285
  * only 2.x answers `/api/info` with its version. A 1.x server — or one that
19395
21286
  * answers with anything else — keeps the 1.x protocol this class speaks.
19396
21287
  */
19397
- async speaksV2() {
21288
+ async v2Version() {
19398
21289
  const info = await this.json("GET", "/api/info").catch(() => null);
19399
- return typeof info === "object" && info !== null && typeof info.version === "string";
21290
+ return typeof info === "object" && info !== null && typeof info.version === "string" ? info.version : null;
19400
21291
  }
19401
21292
  role() {
19402
21293
  return this.task ? "task" : "planner";
@@ -19639,13 +21530,13 @@ var OpenCodeAdapter = class {
19639
21530
  onActivity
19640
21531
  );
19641
21532
  }
19642
- async json(method, path27, body, signal) {
19643
- const response = await this.request(method, path27, body, signal);
19644
- if (!response.ok) throw new Error(`${method} ${path27} failed: ${response.status} ${response.statusText}`);
21533
+ async json(method, path32, body, signal) {
21534
+ const response = await this.request(method, path32, body, signal);
21535
+ if (!response.ok) throw new Error(`${method} ${path32} failed: ${response.status} ${response.statusText}`);
19645
21536
  return await response.json().catch(() => null);
19646
21537
  }
19647
- request(method, path27, body, signal) {
19648
- return this.deps.fetch(`${this.baseUrl}${path27}`, {
21538
+ request(method, path32, body, signal) {
21539
+ return this.deps.fetch(`${this.baseUrl}${path32}`, {
19649
21540
  method,
19650
21541
  headers: this.headers({ "content-type": "application/json" }),
19651
21542
  ...body === void 0 ? {} : { body: JSON.stringify(body) },
@@ -19658,6 +21549,9 @@ var OpenCodeAdapter = class {
19658
21549
  nativeSessionId() {
19659
21550
  return this.sessionId;
19660
21551
  }
21552
+ unusableProjectServers() {
21553
+ return this.v2?.unusableProjectServers() ?? [];
21554
+ }
19661
21555
  /** Asks the server, which lists every MCP server it has with the state of its connection. */
19662
21556
  async mcpAttached() {
19663
21557
  const ordewell = this.ordewell;
@@ -19692,11 +21586,68 @@ var OpenCodeAdapter = class {
19692
21586
  }
19693
21587
  };
19694
21588
 
21589
+ // src/services/harness/openCodeRepoConfig.ts
21590
+ var path23 = __toESM(require("path"));
21591
+ var CONFIG_FILES = ["opencode.json", "opencode.jsonc", path23.join(".opencode", "opencode.json"), path23.join(".opencode", "opencode.jsonc")];
21592
+ var INSTRUCTION_NAMES = ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"];
21593
+ function server3(name, entry, source) {
21594
+ if (!isRecord3(entry)) return "not an object";
21595
+ if (entry.enabled === false) return null;
21596
+ if (entry.type === "remote") {
21597
+ if (typeof entry.url !== "string") return "no url";
21598
+ return { name, source, transport: "remote", target: entry.url, names: isRecord3(entry.headers) ? Object.keys(entry.headers) : [], definition: entry };
21599
+ }
21600
+ const command = Array.isArray(entry.command) ? entry.command.map(String) : [];
21601
+ if (entry.type !== "local" || command.length === 0) return "no command or url";
21602
+ return { name, source, transport: "stdio", target: command.join(" "), names: isRecord3(entry.environment) ? Object.keys(entry.environment) : [], definition: entry };
21603
+ }
21604
+ var OPENCODE_REPO_CONFIG = {
21605
+ instructions(workspace) {
21606
+ const levels = repoLevels(workspace);
21607
+ for (const name of INSTRUCTION_NAMES) {
21608
+ const files = levels.flatMap((dir) => {
21609
+ const text2 = readText(path23.join(dir, name));
21610
+ return text2 === void 0 ? [] : [{ path: path23.relative(workspace, path23.join(dir, name)) || name, text: text2 }];
21611
+ });
21612
+ if (files.length > 0) return files;
21613
+ }
21614
+ return [];
21615
+ },
21616
+ /** Every level's config, the one nearer the workspace winning for a server both name. */
21617
+ projectServers(workspace) {
21618
+ const byName = /* @__PURE__ */ new Map();
21619
+ const unreadable = [];
21620
+ for (const dir of repoLevels(workspace)) {
21621
+ for (const name of CONFIG_FILES) {
21622
+ const file = path23.join(dir, name);
21623
+ const text2 = readText(file);
21624
+ if (text2 === void 0) continue;
21625
+ const source = path23.relative(workspace, file);
21626
+ let parsed;
21627
+ try {
21628
+ parsed = parseJsonc(text2);
21629
+ } catch (err) {
21630
+ unreadable.push({ source, reason: err instanceof Error ? err.message : String(err) });
21631
+ continue;
21632
+ }
21633
+ const entries = isRecord3(parsed) && isRecord3(parsed.mcp) ? parsed.mcp : {};
21634
+ for (const [serverName, entry] of Object.entries(entries)) {
21635
+ const read2 = server3(serverName, entry, source);
21636
+ if (typeof read2 === "string") unreadable.push({ source: `${source} (${serverName})`, reason: read2 });
21637
+ else if (read2) byName.set(serverName, read2);
21638
+ else byName.delete(serverName);
21639
+ }
21640
+ }
21641
+ }
21642
+ return { servers: [...byName.values()], unreadable };
21643
+ }
21644
+ };
21645
+
19695
21646
  // src/services/harness/connectors.ts
19696
21647
  var CONNECTORS = {
19697
- "claude-code": { create: (deps) => new ClaudeCodeAdapter(deps), ordewellTools: CLAUDE_ORDEWELL },
19698
- codex: { create: (deps) => new CodexAdapter(deps), ordewellTools: CODEX_ORDEWELL },
19699
- opencode: { create: (deps) => new OpenCodeAdapter(deps), ordewellTools: OPENCODE_ORDEWELL }
21648
+ "claude-code": { create: (deps) => new ClaudeCodeAdapter(deps), ordewellTools: CLAUDE_ORDEWELL, repoConfig: CLAUDE_REPO_CONFIG },
21649
+ codex: { create: (deps) => new CodexAdapter(deps), ordewellTools: CODEX_ORDEWELL, repoConfig: CODEX_REPO_CONFIG },
21650
+ opencode: { create: (deps) => new OpenCodeAdapter(deps), ordewellTools: OPENCODE_ORDEWELL, repoConfig: OPENCODE_REPO_CONFIG }
19700
21651
  };
19701
21652
  function connectorFor(runner) {
19702
21653
  return Object.hasOwn(CONNECTORS, runner) ? CONNECTORS[runner] : void 0;
@@ -19909,7 +21860,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
19909
21860
  this.notifications.warn(message);
19910
21861
  };
19911
21862
  if (resolved.blockedEnvrc) {
19912
- warn(`blocked:${resolved.blockedEnvrc}`, `direnv has blocked ${resolved.blockedEnvrc}, so tasks start without its variables. Run \`direnv allow\` in ${path19.dirname(resolved.blockedEnvrc)} to use them.`);
21863
+ warn(`blocked:${resolved.blockedEnvrc}`, `direnv has blocked ${resolved.blockedEnvrc}, so tasks start without its variables. Run \`direnv allow\` in ${path24.dirname(resolved.blockedEnvrc)} to use them.`);
19913
21864
  }
19914
21865
  if (resolved.trackedEnvFile) {
19915
21866
  warn(`tracked:${resolved.trackedEnvFile}`, `Ignored ${resolved.trackedEnvFile}: git tracks it, and a committed file must not choose the environment agents run in. Untrack it to use it.`);
@@ -20912,7 +22863,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
20912
22863
  uncommittedSkillFolder(name) {
20913
22864
  const root = this.workspaceRootFn();
20914
22865
  const skill = this.skillsAt(this.skillRoots(root)).findSkill(name);
20915
- return skill?.source === "workspace" ? path19.relative(root, path19.dirname(skill.path)).split(path19.sep).join("/") : void 0;
22866
+ return skill?.source === "workspace" ? path24.relative(root, path24.dirname(skill.path)).split(path24.sep).join("/") : void 0;
20916
22867
  }
20917
22868
  /**
20918
22869
  * The roots whose `.ordewell/skills/` an attempt at `cwd` reads: in a repo
@@ -21151,7 +23102,7 @@ ${errors.map((e) => `- ${e}`).join("\n")}`
21151
23102
  };
21152
23103
 
21153
23104
  // src/services/ModelDiscovery.ts
21154
- var path20 = __toESM(require("path"));
23105
+ var path25 = __toESM(require("path"));
21155
23106
  var import_child_process6 = require("child_process");
21156
23107
  var import_util4 = require("util");
21157
23108
  var fsSync3 = __toESM(require("fs"));
@@ -21166,7 +23117,7 @@ var defaultAppServerClient = async (command, args, options) => {
21166
23117
  const PATH = await augmentedPath();
21167
23118
  const launch = await planDirectLaunch(command, args).catch(() => null);
21168
23119
  if (!launch) return null;
21169
- return new Promise((resolve6) => {
23120
+ return new Promise((resolve9) => {
21170
23121
  let child;
21171
23122
  try {
21172
23123
  child = (0, import_child_process6.spawn)(launch.file, launch.args, {
@@ -21175,7 +23126,7 @@ var defaultAppServerClient = async (command, args, options) => {
21175
23126
  windowsVerbatimArguments: launch.verbatim
21176
23127
  });
21177
23128
  } catch {
21178
- resolve6(null);
23129
+ resolve9(null);
21179
23130
  return;
21180
23131
  }
21181
23132
  let settled = false;
@@ -21184,7 +23135,7 @@ var defaultAppServerClient = async (command, args, options) => {
21184
23135
  settled = true;
21185
23136
  clearTimeout(timer);
21186
23137
  killTree(child);
21187
- resolve6(value);
23138
+ resolve9(value);
21188
23139
  };
21189
23140
  const timer = setTimeout(() => finish(null), options?.timeout ?? 2e4);
21190
23141
  child.on("error", () => finish(null));
@@ -21251,7 +23202,7 @@ function geminiDiscoveryCacheKey(apiKey) {
21251
23202
  return {
21252
23203
  key: `gemini-models:${hash}`,
21253
23204
  dir,
21254
- file: path20.join(dir, `gemini_models_${hash}.json`)
23205
+ file: path25.join(dir, `gemini_models_${hash}.json`)
21255
23206
  };
21256
23207
  }
21257
23208
  function readGeminiCache(apiKey) {
@@ -21448,10 +23399,10 @@ async function discoverFromCommand(manifest, execImpl) {
21448
23399
  return { models: applyVariants(buildFallbackModels(manifest), manifest), fromFallback: true };
21449
23400
  }
21450
23401
  function mergeCanonicalAliases(discovered, manifest) {
21451
- const canonical = manifest.modelDiscovery.canonicalAliases;
21452
- if (!canonical || canonical.length === 0) return discovered;
23402
+ const canonical2 = manifest.modelDiscovery.canonicalAliases;
23403
+ if (!canonical2 || canonical2.length === 0) return discovered;
21453
23404
  const seen = new Set(discovered.map((m) => m.modelId));
21454
- const gaps = canonical.filter((f) => !seen.has(f.modelId)).map((f) => ({
23405
+ const gaps = canonical2.filter((f) => !seen.has(f.modelId)).map((f) => ({
21455
23406
  modelId: f.modelId,
21456
23407
  modelLabel: f.modelLabel,
21457
23408
  runnerProvider: f.modelId.includes("/") ? f.modelId.split("/")[0] : void 0,
@@ -21775,7 +23726,7 @@ var ModelDiscovery = class {
21775
23726
 
21776
23727
  // src/services/ModelCatalog.ts
21777
23728
  var fs11 = __toESM(require("fs"));
21778
- var path21 = __toESM(require("path"));
23729
+ var path26 = __toESM(require("path"));
21779
23730
  var CACHE_KEY = "model-catalog";
21780
23731
  var CACHE_TTL_MS = 60 * 60 * 1e3;
21781
23732
  function storageKey(baseUrl) {
@@ -21827,7 +23778,7 @@ var ModelCatalog = class {
21827
23778
  const url = (baseUrl || "https://openrouter.ai/api/v1").replace(/\/+$/, "");
21828
23779
  const key = storageKey(url);
21829
23780
  const cacheDir = globalCacheDir();
21830
- const cacheFile = path21.join(cacheDir, `${key.replace(/[:/]/g, "_")}.json`);
23781
+ const cacheFile = path26.join(cacheDir, `${key.replace(/[:/]/g, "_")}.json`);
21831
23782
  if (fs11.existsSync(cacheFile)) fs11.rmSync(cacheFile);
21832
23783
  } catch {
21833
23784
  }
@@ -21835,7 +23786,7 @@ var ModelCatalog = class {
21835
23786
  static readCache(key) {
21836
23787
  try {
21837
23788
  const cacheDir = globalCacheDir();
21838
- const cacheFile = path21.join(cacheDir, `${key.replace(/[:/]/g, "_")}.json`);
23789
+ const cacheFile = path26.join(cacheDir, `${key.replace(/[:/]/g, "_")}.json`);
21839
23790
  if (!fs11.existsSync(cacheFile)) return null;
21840
23791
  const raw = fs11.readFileSync(cacheFile, "utf8");
21841
23792
  const data = JSON.parse(raw);
@@ -21849,7 +23800,7 @@ var ModelCatalog = class {
21849
23800
  try {
21850
23801
  const cacheDir = globalCacheDir();
21851
23802
  fs11.mkdirSync(cacheDir, { recursive: true });
21852
- const cacheFile = path21.join(cacheDir, `${key.replace(/[:/]/g, "_")}.json`);
23803
+ const cacheFile = path26.join(cacheDir, `${key.replace(/[:/]/g, "_")}.json`);
21853
23804
  fs11.writeFileSync(
21854
23805
  cacheFile,
21855
23806
  JSON.stringify({ models, cachedAt: Date.now() }, null, 2)
@@ -22269,6 +24220,14 @@ function plannerToolHandler(host) {
22269
24220
  const outcome = await host.runCommand(command, signal);
22270
24221
  return { text: outcome.output || (outcome.success ? "(no output)" : "The command failed with no output."), ...outcome.success ? {} : { isError: true } };
22271
24222
  },
24223
+ async fetch({ url }) {
24224
+ return outcomeReply(await host.fetchUrl(url));
24225
+ },
24226
+ async webSearch({ query }) {
24227
+ const outcome = await host.webSearch(query);
24228
+ if (!outcome) return { isError: true, text: "Web search is not available in this session. If you already know a documentation URL, fetch it instead." };
24229
+ return outcomeReply(outcome);
24230
+ },
22272
24231
  async listRunners() {
22273
24232
  const catalog = await host.liveCatalog();
22274
24233
  return answer({
@@ -22423,6 +24382,9 @@ function catalogDetail(catalog) {
22423
24382
  })
22424
24383
  };
22425
24384
  }
24385
+ function outcomeReply(outcome) {
24386
+ return { text: outcome.output || (outcome.success ? "(nothing returned)" : "The read failed with no message."), ...outcome.success ? {} : { isError: true } };
24387
+ }
22426
24388
  function answer(body) {
22427
24389
  return { text: JSON.stringify(body) };
22428
24390
  }
@@ -22519,15 +24481,28 @@ var TurnStream = class {
22519
24481
  };
22520
24482
 
22521
24483
  // src/services/harness/plannerPermissions.ts
22522
- var READ_ONLY_DENIAL = "The Ordewell planner is read-only: it changes no files. Use run_command for commands; changes belong to the plan's tasks.";
24484
+ var import_fs5 = require("fs");
24485
+ var UNKNOWN_TOOL_DENIAL = "The Ordewell planner is read-only and its envelope has no rule for this tool, so it did not run. Read with the read tools, use run_command for commands and fetch or web_search for the web; changes belong to the plan's tasks.";
22523
24486
  var OPS_HINT = "Continue without it. If it would change something outside the repository, add it to the plan as an ops task; if it only reads, ask the user to approve it or add it to the planner allowlist.";
22524
24487
  var ALLOW2 = { decision: "allow" };
22525
24488
  var SUBJECT_MAX_CHARS = 400;
22526
24489
  function deny(note) {
22527
24490
  return { decision: "deny", note };
22528
24491
  }
24492
+ function onDisk2(target) {
24493
+ try {
24494
+ return import_fs5.realpathSync.native(target);
24495
+ } catch {
24496
+ return void 0;
24497
+ }
24498
+ }
24499
+ function readTarget(root, target) {
24500
+ const written = resolveWithin(root, target);
24501
+ const real = onDisk2(written.abs);
24502
+ return real === void 0 ? written : resolveWithin(onDisk2(root) ?? root, real);
24503
+ }
22529
24504
  async function decidePlannerPermission(ask, input, gate) {
22530
- if (!ask || ask.kind === "other") return deny(READ_ONLY_DENIAL);
24505
+ if (!ask || ask.kind === "other") return deny(ask?.note ?? UNKNOWN_TOOL_DENIAL);
22531
24506
  if (!gate) return deny("Nobody can approve this here. Continue without it.");
22532
24507
  switch (ask.kind) {
22533
24508
  case "mcp": {
@@ -22542,11 +24517,25 @@ async function decidePlannerPermission(ask, input, gate) {
22542
24517
  });
22543
24518
  return granted ? ALLOW2 : deny(`Not approved: ${ask.scope}. ${OPS_HINT}`);
22544
24519
  }
22545
- case "fetch":
24520
+ case "fetch": {
22546
24521
  if (!gate.fetcher) return deny("This fetch cannot be approved here.");
22547
- return await gate.fetcher.confirm(ask.url) ? ALLOW2 : deny(`Fetch denied (blocked host or not approved): ${ask.url}. ${OPS_HINT}`);
24522
+ const decision = await gate.fetcher.decide({ kind: "fetch", url: ask.url });
24523
+ return decision.allowed ? ALLOW2 : deny(decision.reason);
24524
+ }
24525
+ case "search": {
24526
+ if (!gate.fetcher) return deny("This search cannot be approved here.");
24527
+ const decision = await gate.fetcher.decide({
24528
+ kind: "search",
24529
+ query: ask.query,
24530
+ provider: ask.provider,
24531
+ providerLabel: ask.providerLabel,
24532
+ // It runs at the provider, which already holds the conversation the query came from.
24533
+ note: `${ask.provider} runs this search itself and already sees this planner's conversation.`
24534
+ });
24535
+ return decision.allowed ? ALLOW2 : deny(decision.reason);
24536
+ }
22548
24537
  case "path": {
22549
- const { abs, inside } = resolveWithin(gate.workspaceRoot, ask.path);
24538
+ const { abs, inside } = readTarget(gate.workspaceRoot, ask.path);
22550
24539
  if (inside) return ALLOW2;
22551
24540
  const granted = await gate.approval.request({
22552
24541
  kind: "external_path",
@@ -22578,23 +24567,31 @@ var CliAgentAiService = class {
22578
24567
  const runner = runnerForProvider(config.aiProvider);
22579
24568
  if (!runner) throw new Error(`${config.aiProvider} is not a coding-agent planner.`);
22580
24569
  this.runner = runner;
24570
+ const spawn2 = deps.spawn ?? import_child_process8.spawn;
22581
24571
  this.processDeps = {
22582
- spawn: deps.spawn ?? import_child_process8.spawn,
24572
+ spawn: (command, args, options) => {
24573
+ const child = spawn2(command, args, options);
24574
+ this.environmentSecrets = sensitiveEnvValues(options.env);
24575
+ return child;
24576
+ },
22583
24577
  fetch: deps.fetch ?? globalThis.fetch,
22584
24578
  resolvePath: deps.resolvePath,
22585
24579
  platform: deps.platform,
22586
24580
  isDirectory: deps.isDirectory,
22587
- exists: deps.exists
24581
+ exists: deps.exists,
24582
+ workspaceEnv: deps.workspaceEnv
22588
24583
  };
22589
24584
  this.makeAdapter = deps.createAdapter ?? createPlannerAdapter;
22590
24585
  this.workspaceRoot = deps.workspaceRoot ?? (() => process.cwd());
22591
24586
  this.mcpServer = deps.mcpServer ?? sharedMcpServer();
24587
+ this.projectServerApprovals = deps.projectServerApprovals ?? fileProjectServerApprovals();
22592
24588
  }
22593
24589
  config;
22594
24590
  runner;
22595
24591
  processDeps;
22596
24592
  makeAdapter;
22597
24593
  workspaceRoot;
24594
+ environmentSecrets = [];
22598
24595
  adapter = null;
22599
24596
  /**
22600
24597
  * The agent's own session id, kept across a process death so the next turn
@@ -22604,9 +24601,18 @@ var CliAgentAiService = class {
22604
24601
  lastNativeSessionId = null;
22605
24602
  /** `gate` is what the agent's own tool requests are decided against; absent, only Ordewell's tools are allowed (ADR-0026). */
22606
24603
  conversation = null;
24604
+ /**
24605
+ * What the runner's own session-wide search is decided against
24606
+ * (ADR-0027), asked again before every turn: the session's mode, its
24607
+ * exclusions or its budget can change between turns. Absent, it is off.
24608
+ */
24609
+ searchesUnasked;
22607
24610
  mcpServer;
24611
+ projectServerApprovals;
22608
24612
  /** The planner token the running process was spawned with; revoked with the process (ADR-0022, A3). */
22609
24613
  plannerToken = null;
24614
+ /** The {@link PlannerStartOptions.nativeSearch} the running process was spawned with. */
24615
+ adapterNativeSearch = false;
22610
24616
  activeAbort = null;
22611
24617
  /**
22612
24618
  * Every subagent this conversation has reported starting, and finishing.
@@ -22637,12 +24643,17 @@ var CliAgentAiService = class {
22637
24643
  nativeSessionId() {
22638
24644
  return this.adapter?.nativeSessionId() ?? null;
22639
24645
  }
24646
+ heldSecrets() {
24647
+ return this.environmentSecrets;
24648
+ }
22640
24649
  reset() {
22641
24650
  this.activeAbort?.abort();
22642
24651
  this.activeAbort = null;
22643
24652
  this.disposeAdapter();
24653
+ this.environmentSecrets = [];
22644
24654
  this.lastNativeSessionId = null;
22645
24655
  this.conversation = null;
24656
+ this.searchesUnasked = void 0;
22646
24657
  this.subagents.started.clear();
22647
24658
  this.subagents.finished.clear();
22648
24659
  }
@@ -22662,15 +24673,19 @@ var CliAgentAiService = class {
22662
24673
  req.autonomousDefault ?? true,
22663
24674
  { harness: true, isolatedExecution: req.isolatedExecution, skills: req.skills }
22664
24675
  );
24676
+ const fetcher = req.fetcher;
24677
+ const cwd = this.workspaceRoot();
22665
24678
  const startOptions = {
22666
24679
  kind: "planner",
22667
- cwd: this.workspaceRoot(),
22668
- systemPrompt,
24680
+ cwd,
24681
+ ...await this.repoConfig(systemPrompt, cwd, req.approval),
22669
24682
  model: this.plannerModel(),
22670
24683
  effort: this.config.plannerThinkingEffort
22671
24684
  };
24685
+ if (req.approval && fetcher?.searchesUnasked) this.searchesUnasked = (provider) => fetcher.searchesUnasked?.(provider) ?? false;
22672
24686
  const tools = req.plannerTools;
22673
- await this.startAdapter(startOptions, tools);
24687
+ const adapter = await this.startAdapter(startOptions, tools);
24688
+ recordUnusable(adapter.unusableProjectServers?.() ?? [], req.approval);
22674
24689
  const gate = req.approval ? { approval: req.approval, fetcher: req.fetcher, workspaceRoot: startOptions.cwd } : void 0;
22675
24690
  this.conversation = { startOptions, tools, gate };
22676
24691
  const opening = this.openingMessage(req);
@@ -22905,25 +24920,26 @@ var CliAgentAiService = class {
22905
24920
  }
22906
24921
  /** One spawn with the Ordewell server injected: the adapter once the runner reports it connected, otherwise why not, with nothing left running. */
22907
24922
  async startWithTools(opts, tools) {
22908
- const server = this.mcpServer;
22909
- if (!server) return "no Ordewell MCP server was given";
24923
+ const server4 = this.mcpServer;
24924
+ if (!server4) return "no Ordewell MCP server was given";
22910
24925
  const adapter = this.newAdapter();
22911
24926
  if (!adapter.mcpAttached) {
22912
24927
  adapter.dispose();
22913
24928
  return `${this.runner} cannot be handed Ordewell's MCP server`;
22914
24929
  }
24930
+ const start = { ...opts, nativeSearch: this.nativeSearch() };
22915
24931
  let credential;
22916
24932
  try {
22917
- credential = await server.issuePlannerToken({ sessionId: tools.sessionId }, tools.handler);
24933
+ credential = await server4.issuePlannerToken({ sessionId: tools.sessionId, hidden: connectorFor(this.runner)?.ordewellTools.plannerHidden(start) }, tools.handler);
22918
24934
  } catch (err) {
22919
24935
  adapter.dispose();
22920
24936
  return `Ordewell's MCP server could not issue a token (${err instanceof Error ? err.message : String(err)})`;
22921
24937
  }
22922
24938
  try {
22923
- await adapter.start({ ...opts, mcp: mcpClientConfig(credential) });
24939
+ await adapter.start({ ...start, mcp: mcpClientConfig(credential) });
22924
24940
  } catch (err) {
22925
24941
  adapter.dispose();
22926
- server.revoke(credential.token);
24942
+ server4.revoke(credential.token);
22927
24943
  throw err;
22928
24944
  }
22929
24945
  let failure = `${this.runner} did not report Ordewell's MCP server connected`;
@@ -22931,13 +24947,14 @@ var CliAgentAiService = class {
22931
24947
  if (await adapter.mcpAttached()) {
22932
24948
  this.adapter = adapter;
22933
24949
  this.plannerToken = credential.token;
24950
+ this.adapterNativeSearch = start.nativeSearch ?? false;
22934
24951
  return adapter;
22935
24952
  }
22936
24953
  } catch (err) {
22937
24954
  failure = `${this.runner} could not check Ordewell's MCP attach state (${err instanceof Error ? err.message : String(err)})`;
22938
24955
  }
22939
24956
  adapter.dispose();
22940
- server.revoke(credential.token);
24957
+ server4.revoke(credential.token);
22941
24958
  return failure;
22942
24959
  }
22943
24960
  /** Kill the planner process, and with it the token it was spawned with. */
@@ -22947,19 +24964,46 @@ var CliAgentAiService = class {
22947
24964
  if (this.plannerToken) this.mcpServer?.revoke(this.plannerToken);
22948
24965
  this.plannerToken = null;
22949
24966
  }
24967
+ /** Whether the web-read policy lets the runner's own session-wide search run unasked now (ADR-0027). */
24968
+ nativeSearch() {
24969
+ const provider = connectorFor(this.runner)?.ordewellTools.sessionSearchProvider;
24970
+ return provider !== void 0 && (this.searchesUnasked?.(provider) ?? false);
24971
+ }
22950
24972
  /**
22951
24973
  * The live agent process, restarted from its own session id if it died
22952
24974
  * between turns. Resume is a hint: when it fails, the caller's next
22953
24975
  * `startConversation` reseeds from Ordewell's transcript, which is the same
22954
24976
  * degradation `restoreChat` already performs on every surface.
24977
+ *
24978
+ * Also restarted when the policy's decision on the runner's own search has
24979
+ * changed since the process was spawned: the running thread would otherwise
24980
+ * keep searching unasked after Full turned Guarded. Codex 0.162 applies
24981
+ * `thread/resume`'s `config.web_search` either way (checked live).
22955
24982
  */
22956
24983
  async ensureAdapter() {
22957
- if (this.adapter) return this.adapter;
24984
+ if (this.adapter && this.adapterNativeSearch === this.nativeSearch()) return this.adapter;
24985
+ this.disposeAdapter();
22958
24986
  const conversation = this.conversation;
22959
24987
  if (!conversation) throw new Error("No active planner conversation.");
22960
24988
  await this.startAdapter({ ...conversation.startOptions, resumeSessionId: this.lastNativeSessionId ?? void 0 }, conversation.tools);
22961
24989
  return this.adapter;
22962
24990
  }
24991
+ /**
24992
+ * What the planner gets of the repository's config, its runner being
24993
+ * launched without it (ADR-0028): the instruction files, which are text, in
24994
+ * its system prompt, and the project MCP servers it may start. Decided once
24995
+ * per conversation, so a respawn starts the same servers without asking.
24996
+ */
24997
+ async repoConfig(systemPrompt, cwd, approval) {
24998
+ const reader = connectorFor(this.runner)?.repoConfig;
24999
+ if (!reader) return { systemPrompt };
25000
+ const found = reader.projectServers(cwd);
25001
+ const { started, held } = await decideProjectServers(found.servers, { repoRoot: repoRoot(cwd), approval, approvals: this.projectServerApprovals });
25002
+ return {
25003
+ systemPrompt: [systemPrompt, instructionsBlock(reader.instructions(cwd)), heldServersNote(held.map((server4) => ({ server: server4, reason: NOT_APPROVED })), found.unreadable)].filter(Boolean).join("\n\n"),
25004
+ ...started.length > 0 ? { projectServers: started } : {}
25005
+ };
25006
+ }
22963
25007
  plannerModel() {
22964
25008
  const id = (this.config.orchestratorModel ?? "").trim();
22965
25009
  return id || void 0;
@@ -23002,8 +25046,11 @@ ${corrective}`, catalog, onProgress, signal),
23002
25046
  const previousConversation = this.conversation;
23003
25047
  const previousSessionId = this.lastNativeSessionId;
23004
25048
  const previousToken = this.plannerToken;
25049
+ const previousNativeSearch = this.adapterNativeSearch;
25050
+ const previousSearchesUnasked = this.searchesUnasked;
23005
25051
  this.adapter = null;
23006
25052
  this.plannerToken = null;
25053
+ this.searchesUnasked = void 0;
23007
25054
  let submitted;
23008
25055
  let refused3 = [];
23009
25056
  let ended = false;
@@ -23034,11 +25081,13 @@ ${corrective}`, catalog, onProgress, signal),
23034
25081
  }
23035
25082
  }
23036
25083
  };
25084
+ const cwd = this.workspaceRoot();
23037
25085
  const startOptions = {
23038
25086
  kind: "planner",
23039
- cwd: this.workspaceRoot(),
23040
- systemPrompt: `${prompt}
23041
- Before submitting, call list_runners and list_models to check the available assignments.`,
25087
+ cwd,
25088
+ // No approval channel here: only servers a person approved before start.
25089
+ ...await this.repoConfig(`${prompt}
25090
+ Before submitting, call list_runners and list_models to check the available assignments.`, cwd, void 0),
23042
25091
  model: this.plannerModel(),
23043
25092
  effort: this.config.plannerThinkingEffort
23044
25093
  };
@@ -23064,6 +25113,8 @@ Before submitting, call list_runners and list_models to check the available assi
23064
25113
  if (this.plannerToken) this.mcpServer?.revoke(this.plannerToken);
23065
25114
  this.adapter = previous;
23066
25115
  this.plannerToken = previousToken;
25116
+ this.adapterNativeSearch = previousNativeSearch;
25117
+ this.searchesUnasked = previousSearchesUnasked;
23067
25118
  this.conversation = previousConversation;
23068
25119
  this.lastNativeSessionId = previousSessionId;
23069
25120
  }
@@ -24135,213 +26186,6 @@ function forkPlanState(plan, tasks, dialogue, now) {
24135
26186
  };
24136
26187
  }
24137
26188
 
24138
- // src/services/HttpWebFetcher.ts
24139
- var import_promises = require("node:dns/promises");
24140
- var DEFAULT_MAX_CHARS = 4e4;
24141
- var DEFAULT_TIMEOUT_MS2 = 2e4;
24142
- var MAX_BODY_BYTES = 2e6;
24143
- var MAX_REDIRECTS = 5;
24144
- var USER_AGENT = "Mozilla/5.0 (compatible; Ordewell-Planner/1.0; +https://github.com/ordewell)";
24145
- var SEARCH_ENDPOINT = "https://lite.duckduckgo.com/lite/?q=";
24146
- function decodeEntities(text2) {
24147
- return text2.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&#x27;/g, "'");
24148
- }
24149
- function htmlToText(html) {
24150
- return decodeEntities(
24151
- html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ").replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, " ").replace(/<\/(p|div|li|h[1-6]|tr|section|article)>/gi, "\n").replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ")
24152
- ).replace(/[ \t\f\v]+/g, " ").replace(/\n\s*\n\s*\n+/g, "\n\n").split("\n").map((line) => line.trim()).join("\n").trim();
24153
- }
24154
- function parseSearchHits(html) {
24155
- const hits = [];
24156
- const linkPattern = /<a\b[^>]*class="[^"]*result(?:-link|__a)[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
24157
- const snippetPattern = /class="[^"]*result(?:-snippet|__snippet)[^"]*"[^>]*>([\s\S]*?)</gi;
24158
- const snippets = [];
24159
- for (let m = snippetPattern.exec(html); m; m = snippetPattern.exec(html)) {
24160
- snippets.push(decodeEntities(m[1].replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim());
24161
- }
24162
- for (let m = linkPattern.exec(html); m; m = linkPattern.exec(html)) {
24163
- const title = decodeEntities(m[2].replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
24164
- if (!title) continue;
24165
- hits.push({ title, url: decodeEntities(m[1]), snippet: snippets[hits.length] });
24166
- }
24167
- return hits;
24168
- }
24169
- function isPrivateIp(ip) {
24170
- if (!ip) return true;
24171
- if (ip === "0.0.0.0" || ip === "::" || ip === "::1" || ip === "::ffff:127.0.0.1") return true;
24172
- if (/^127\./.test(ip)) return true;
24173
- if (/^169\.254\./.test(ip)) return true;
24174
- if (/^10\./.test(ip)) return true;
24175
- if (/^192\.168\./.test(ip)) return true;
24176
- const m172 = /^172\.(\d+)\./.exec(ip);
24177
- if (m172 && Number(m172[1]) >= 16 && Number(m172[1]) <= 31) return true;
24178
- if (/^fe[89ab][0-9a-f]/i.test(ip)) return true;
24179
- return false;
24180
- }
24181
- function isIpLiteral(host) {
24182
- return /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || /^\[?[0-9a-fA-F:]+\]?$/i.test(host);
24183
- }
24184
- function sanitizeError(err) {
24185
- const msg = err instanceof Error ? err.message : String(err);
24186
- if (/abort/i.test(msg)) return "timed out";
24187
- if (/ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH/i.test(msg)) return "host not reachable";
24188
- if (/ENOTFOUND|getaddrinfo/i.test(msg)) return "host not found";
24189
- return msg.replace(/https?:\/\/\S+/g, "<url>");
24190
- }
24191
- var HttpWebFetcher = class {
24192
- approval;
24193
- fetchImpl;
24194
- maxChars;
24195
- timeoutMs;
24196
- isBlockedHost;
24197
- resolveHost;
24198
- constructor(opts) {
24199
- this.approval = opts.approval;
24200
- this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
24201
- this.maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
24202
- this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
24203
- this.isBlockedHost = opts.isBlockedHost ?? this.defaultIsBlocked.bind(this);
24204
- this.resolveHost = opts.resolveHost ?? (async (h) => {
24205
- const r = await (0, import_promises.lookup)(h);
24206
- return [r.address];
24207
- });
24208
- }
24209
- async defaultIsBlocked(host, port) {
24210
- if (!["", "80", "443"].includes(port)) return true;
24211
- if (isIpLiteral(host)) return isPrivateIp(host.startsWith("[") ? host.slice(1, -1) : host);
24212
- const lower = host.toLowerCase();
24213
- if (lower === "localhost" || lower === "ip6-localhost" || lower === "ip6-loopback") return true;
24214
- try {
24215
- const addrs = await this.resolveHost(host);
24216
- if (addrs.some((a) => isPrivateIp(a))) return true;
24217
- } catch {
24218
- return true;
24219
- }
24220
- return false;
24221
- }
24222
- async confirm(url) {
24223
- let parsed;
24224
- try {
24225
- parsed = new URL(url);
24226
- } catch {
24227
- return false;
24228
- }
24229
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
24230
- if (await this.isBlockedHost(parsed.hostname, parsed.port)) return false;
24231
- return this.approval.request({
24232
- kind: "url_fetch",
24233
- subject: url,
24234
- scope: `${parsed.origin}/*`,
24235
- detail: `Planner research wants to fetch ${parsed.origin}`
24236
- });
24237
- }
24238
- async fetch(url) {
24239
- if (!await this.confirm(url)) {
24240
- return { success: false, output: `Fetch denied (blocked host or not approved): ${url}. Continue without it, or ask the user to approve this origin or add it to the planner allowlist.`, truncated: false };
24241
- }
24242
- return this.get(url, "Fetch");
24243
- }
24244
- async search(query) {
24245
- const url = SEARCH_ENDPOINT + encodeURIComponent(query);
24246
- if (!await this.confirm(url)) {
24247
- return { success: false, output: `Web search denied by user for query: ${query}`, truncated: false };
24248
- }
24249
- const page = await this.request(url);
24250
- if (!page.ok) return { success: false, output: `Web search failed: ${page.error}`, truncated: false };
24251
- const hits = parseSearchHits(page.body).slice(0, 10);
24252
- if (hits.length === 0) {
24253
- return {
24254
- success: false,
24255
- output: `No results could be parsed for "${query}". Search is best-effort; if you already know a documentation URL, use the fetch tool directly.`,
24256
- truncated: false
24257
- };
24258
- }
24259
- const output = hits.map((hit, i) => `${i + 1}. ${hit.title}
24260
- ${hit.url}${hit.snippet ? `
24261
- ${hit.snippet}` : ""}`).join("\n\n");
24262
- return { success: true, output, truncated: false };
24263
- }
24264
- async get(url, label) {
24265
- const page = await this.request(url);
24266
- if (!page.ok) return { success: false, output: `${label} failed: ${page.error}`, truncated: false };
24267
- const text2 = page.isHtml ? htmlToText(page.body) : page.body;
24268
- if (page.truncated || text2.length > this.maxChars) {
24269
- const sliced = text2.slice(0, this.maxChars);
24270
- return {
24271
- success: true,
24272
- output: `${sliced}
24273
-
24274
- [... truncated to ${this.maxChars} chars, total ${text2.length}${page.truncated ? "+, body was byte-capped at the source" : ""}]`,
24275
- truncated: true
24276
- };
24277
- }
24278
- return { success: true, output: text2, truncated: false };
24279
- }
24280
- async request(url) {
24281
- let current = url;
24282
- for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
24283
- const controller = new AbortController();
24284
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
24285
- try {
24286
- const res = await this.fetchImpl(current, {
24287
- signal: controller.signal,
24288
- headers: { "user-agent": USER_AGENT, accept: "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8" },
24289
- redirect: "manual"
24290
- });
24291
- if (res.status >= 300 && res.status < 400) {
24292
- const location = res.headers.get("location");
24293
- if (!location) return { ok: false, error: `redirect (${res.status}) with no Location` };
24294
- const next = new URL(location, current).href;
24295
- const nextParsed = new URL(next);
24296
- if (nextParsed.protocol !== "https:" && nextParsed.protocol !== "http:") {
24297
- return { ok: false, error: "redirect to a non-HTTP scheme" };
24298
- }
24299
- if (await this.isBlockedHost(nextParsed.hostname, nextParsed.port)) {
24300
- return { ok: false, error: "redirect to a blocked host" };
24301
- }
24302
- current = next;
24303
- continue;
24304
- }
24305
- if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
24306
- const contentType = res.headers.get("content-type") ?? "";
24307
- const { body, truncated } = await readBounded(res, MAX_BODY_BYTES);
24308
- return { ok: true, body, isHtml: /html|xml/i.test(contentType), truncated };
24309
- } catch (err) {
24310
- return { ok: false, error: sanitizeError(err) };
24311
- } finally {
24312
- clearTimeout(timer);
24313
- }
24314
- }
24315
- return { ok: false, error: `too many redirects (>${MAX_REDIRECTS})` };
24316
- }
24317
- };
24318
- async function readBounded(res, maxBytes) {
24319
- const reader = res.body?.[Symbol.asyncIterator] ? readFromIterable(res.body, maxBytes) : readFromText(res, maxBytes);
24320
- return reader;
24321
- }
24322
- async function readFromIterable(source, maxBytes) {
24323
- const chunks = [];
24324
- let total = 0;
24325
- let truncated = false;
24326
- for await (const chunk of source) {
24327
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
24328
- if (total + buf.length > maxBytes) {
24329
- chunks.push(buf.subarray(0, maxBytes - total));
24330
- total = maxBytes;
24331
- truncated = true;
24332
- break;
24333
- }
24334
- chunks.push(buf);
24335
- total += buf.length;
24336
- }
24337
- return { body: Buffer.concat(chunks).toString("utf8"), truncated };
24338
- }
24339
- async function readFromText(res, maxBytes) {
24340
- const text2 = await res.text();
24341
- if (Buffer.byteLength(text2, "utf8") <= maxBytes) return { body: text2, truncated: false };
24342
- return { body: Buffer.from(text2, "utf8").subarray(0, maxBytes).toString("utf8"), truncated: true };
24343
- }
24344
-
24345
26189
  // src/services/SessionMessage.ts
24346
26190
  function serializeTask(t) {
24347
26191
  return {
@@ -24390,7 +26234,7 @@ function surfaceQueued(queued) {
24390
26234
  function surfaceTask({ attemptSkills, subtasks, runnerSessionId: _runnerSessionId, ...task }) {
24391
26235
  return {
24392
26236
  ...task,
24393
- ...attemptSkills ? { attemptSkills: attemptSkills.map(({ name, source, path: path27 }) => ({ name, source, path: path27 })) } : {},
26237
+ ...attemptSkills ? { attemptSkills: attemptSkills.map(({ name, source, path: path32 }) => ({ name, source, path: path32 })) } : {},
24394
26238
  subtasks: (subtasks ?? []).map(surfaceTask)
24395
26239
  };
24396
26240
  }
@@ -24646,24 +26490,24 @@ var SessionEventRelay = class {
24646
26490
 
24647
26491
  // src/utils/sessionStore.ts
24648
26492
  var fs12 = __toESM(require("fs"));
24649
- var path22 = __toESM(require("path"));
26493
+ var path27 = __toESM(require("path"));
24650
26494
 
24651
26495
  // src/utils/sessionId.ts
24652
- var import_crypto10 = require("crypto");
26496
+ var import_crypto11 = require("crypto");
24653
26497
  function mintSessionId() {
24654
- return `session-${(0, import_crypto10.randomBytes)(16).toString("hex")}`;
26498
+ return `session-${(0, import_crypto11.randomBytes)(16).toString("hex")}`;
24655
26499
  }
24656
26500
 
24657
26501
  // src/utils/sessionStore.ts
24658
26502
  var SESSIONS_SUBDIR = "sessions";
24659
26503
  function getSessionsDir(baseDir) {
24660
- return path22.join(getStateDir(baseDir), SESSIONS_SUBDIR);
26504
+ return path27.join(getStateDir(baseDir), SESSIONS_SUBDIR);
24661
26505
  }
24662
26506
  function idSegment(id) {
24663
26507
  return /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$/.test(id) ? id : `~${Buffer.from(id, "utf-8").toString("hex")}`;
24664
26508
  }
24665
26509
  function sessionDataDir(sessionId, baseDir) {
24666
- return path22.join(getSessionsDir(baseDir), idSegment(sessionId));
26510
+ return path27.join(getSessionsDir(baseDir), idSegment(sessionId));
24667
26511
  }
24668
26512
  var REJECTION_MESSAGE = "This session was created with an older version of Ordewell. Please re-plan.";
24669
26513
  function hasScalarRunner(parsed) {
@@ -24685,8 +26529,8 @@ function legacySessionFilename(session) {
24685
26529
  return `${filenameStem(session)}_${session.id.slice(0, 8)}.json`;
24686
26530
  }
24687
26531
  function dropLegacyFile(sessionsDir, meta) {
24688
- const legacy = path22.join(sessionsDir, legacySessionFilename(meta));
24689
- if (legacy === path22.join(sessionsDir, sessionFilename(meta)) || !fs12.existsSync(legacy)) return;
26532
+ const legacy = path27.join(sessionsDir, legacySessionFilename(meta));
26533
+ if (legacy === path27.join(sessionsDir, sessionFilename(meta)) || !fs12.existsSync(legacy)) return;
24690
26534
  try {
24691
26535
  const held = JSON.parse(fs12.readFileSync(legacy, "utf-8")).meta?.id;
24692
26536
  if (held === meta.id) fs12.unlinkSync(legacy);
@@ -24712,7 +26556,7 @@ function saveSession(plan, goal, baseDir, id) {
24712
26556
  plan: JSON.parse(JSON.stringify(plan))
24713
26557
  };
24714
26558
  const filename = sessionFilename(meta);
24715
- const filePath = path22.join(sessionsDir, filename);
26559
+ const filePath = path27.join(sessionsDir, filename);
24716
26560
  fs12.writeFileSync(filePath, JSON.stringify(session, null, 2));
24717
26561
  dropLegacyFile(sessionsDir, meta);
24718
26562
  return meta;
@@ -24723,7 +26567,7 @@ function listSessions(baseDir, logger = defaultLogger) {
24723
26567
  const files = fs12.readdirSync(sessionsDir).filter((f) => f.endsWith(".json"));
24724
26568
  return files.map((f) => {
24725
26569
  try {
24726
- const raw = fs12.readFileSync(path22.join(sessionsDir, f), "utf-8");
26570
+ const raw = fs12.readFileSync(path27.join(sessionsDir, f), "utf-8");
24727
26571
  const parsed = JSON.parse(raw);
24728
26572
  if (hasScalarRunner(parsed)) {
24729
26573
  logger.warn("sessionStore", `session file ${f}: ${REJECTION_MESSAGE}`);
@@ -24741,7 +26585,7 @@ function findSessionFile(sessionsDir, sessionId, logger) {
24741
26585
  const files = fs12.readdirSync(sessionsDir).filter((f) => f.endsWith(".json"));
24742
26586
  for (const f of files) {
24743
26587
  try {
24744
- const raw = fs12.readFileSync(path22.join(sessionsDir, f), "utf-8");
26588
+ const raw = fs12.readFileSync(path27.join(sessionsDir, f), "utf-8");
24745
26589
  const session = JSON.parse(raw);
24746
26590
  if (session.meta.id === sessionId) return f;
24747
26591
  } catch (err) {
@@ -24756,7 +26600,7 @@ function loadSession(sessionId, baseDir, logger = defaultLogger) {
24756
26600
  try {
24757
26601
  const file = findSessionFile(sessionsDir, sessionId, logger);
24758
26602
  if (file) {
24759
- const raw = fs12.readFileSync(path22.join(sessionsDir, file), "utf-8");
26603
+ const raw = fs12.readFileSync(path27.join(sessionsDir, file), "utf-8");
24760
26604
  const parsed = JSON.parse(raw);
24761
26605
  if (hasScalarRunner(parsed)) {
24762
26606
  logger.warn("sessionStore", `session file ${file}: ${REJECTION_MESSAGE}`);
@@ -24797,7 +26641,7 @@ function deleteSession(sessionId, baseDir, logger = defaultLogger) {
24797
26641
  try {
24798
26642
  const file = findSessionFile(sessionsDir, sessionId, logger);
24799
26643
  if (file) {
24800
- fs12.unlinkSync(path22.join(sessionsDir, file));
26644
+ fs12.unlinkSync(path27.join(sessionsDir, file));
24801
26645
  return true;
24802
26646
  }
24803
26647
  } catch (err) {
@@ -24808,10 +26652,10 @@ function deleteSession(sessionId, baseDir, logger = defaultLogger) {
24808
26652
 
24809
26653
  // src/utils/taskLogStore.ts
24810
26654
  var fs13 = __toESM(require("fs"));
24811
- var path23 = __toESM(require("path"));
26655
+ var path28 = __toESM(require("path"));
24812
26656
  var EXTENSION = ".jsonl";
24813
26657
  function taskDir({ baseDir, sessionId }, taskId) {
24814
- return path23.join(sessionDataDir(sessionId, baseDir), "tasks", idSegment(taskId));
26658
+ return path28.join(sessionDataDir(sessionId, baseDir), "tasks", idSegment(taskId));
24815
26659
  }
24816
26660
  function listTaskLogAttempts(location, taskId) {
24817
26661
  let names;
@@ -24828,7 +26672,7 @@ function openTaskLog(location, taskId) {
24828
26672
  fs13.mkdirSync(dir, { recursive: true });
24829
26673
  let attempt = (listTaskLogAttempts(location, taskId).pop() ?? 0) + 1;
24830
26674
  for (; ; ) {
24831
- const file = path23.join(dir, `${attempt}${EXTENSION}`);
26675
+ const file = path28.join(dir, `${attempt}${EXTENSION}`);
24832
26676
  try {
24833
26677
  fs13.writeFileSync(file, "", { flag: "wx" });
24834
26678
  } catch (err) {
@@ -24855,7 +26699,7 @@ function readTaskLog(location, taskId, attempt) {
24855
26699
  if (!Number.isInteger(attempt) || attempt < 1) return [];
24856
26700
  let raw;
24857
26701
  try {
24858
- raw = fs13.readFileSync(path23.join(taskDir(location, taskId), `${attempt}${EXTENSION}`), "utf-8");
26702
+ raw = fs13.readFileSync(path28.join(taskDir(location, taskId), `${attempt}${EXTENSION}`), "utf-8");
24859
26703
  } catch {
24860
26704
  return [];
24861
26705
  }
@@ -25283,7 +27127,8 @@ function isPlannerApproval(request) {
25283
27127
  }
25284
27128
  function createSession(deps) {
25285
27129
  const pinnedAiService = deps.aiService;
25286
- const aiService = pinnedAiService ? () => pinnedAiService : liveAiService(deps.config, deps.workspaceRoot, deps.mcpServer ?? sharedMcpServer());
27130
+ const mcpServer = deps.mcpServer ?? sharedMcpServer();
27131
+ const aiService = pinnedAiService ? () => pinnedAiService : liveAiService(deps.config, deps.workspaceRoot, mcpServer);
25287
27132
  const store = new PlanStore();
25288
27133
  const taskLogs = new TaskLogRecorder({
25289
27134
  broadcast: deps.broadcast,
@@ -25339,8 +27184,9 @@ function createSession(deps) {
25339
27184
  checkpointQuestion: (taskId) => orchestrator.getCheckpointQuestion(taskId)
25340
27185
  });
25341
27186
  const approvalPolicy = new ApprovalPolicy({
27187
+ autonomous: () => deps.config.autonomousMode,
25342
27188
  // Read per request, so a change of autonomy level or allowlist applies to the next call.
25343
- mode: () => effectiveApprovalMode(deps.config.approvalMode, deps.config.autonomousMode),
27189
+ mode: (kind) => effectiveApprovalMode(deps.config.approvalMode, deps.config.autonomousMode, kind),
25344
27190
  preApproved: () => [...deps.config.approvalDefaults ? DEFAULT_PLANNER_ALLOWLIST : [], ...deps.config.approvalPreApproved],
25345
27191
  ask: (req) => approvals.ask(req),
25346
27192
  // The interactive path (`asked`) already broadcasts approval_request +
@@ -25360,6 +27206,14 @@ function createSession(deps) {
25360
27206
  }
25361
27207
  });
25362
27208
  deps.fsAdapter.setApproval?.(approvalPolicy);
27209
+ const outboundSecrets = () => [...configSecrets(deps.config), ...mcpServer.heldTokens(), ...aiService().heldSecrets?.() ?? []];
27210
+ deps.fsAdapter.setOutboundSecrets?.(outboundSecrets);
27211
+ const webReads = new WebReadPolicy({
27212
+ approval: approvalPolicy,
27213
+ secrets: outboundSecrets,
27214
+ budget: () => deps.config.webReadBudget,
27215
+ turn: () => conversation?.currentTurnId
27216
+ });
25363
27217
  const catalog = new SessionCatalog({
25364
27218
  config: deps.config,
25365
27219
  registry: deps.registry,
@@ -25388,15 +27242,28 @@ function createSession(deps) {
25388
27242
  usage,
25389
27243
  approvals,
25390
27244
  approvalPolicy,
25391
- // `fetch`/`web_search` route through the same approval channel as paths and
25392
- // commands — one decision surface for everything that leaves the workspace.
25393
- fetcher: new HttpWebFetcher({ approval: approvalPolicy }),
27245
+ // Every planner's web reads, carried here or natively, are decided by the
27246
+ // one policy, which asks through the same channel as paths and commands.
27247
+ // The response cache and search provider are process-wide, so parallel
27248
+ // sessions and their subagents share bytes and the search cooldown.
27249
+ fetcher: new HttpWebFetcher({ policy: webReads, cache: sharedWebResponseCache, searchProvider: sharedSearchProvider }),
25394
27250
  skillsService: deps.skillsService ?? new SkillsService(deps.workspaceRoot()),
25395
27251
  saveSession: deps.saveSession ?? saveSession,
25396
27252
  conversation: (host) => conversation = new PlannerConversation(host)
25397
27253
  });
25398
27254
  return session;
25399
27255
  }
27256
+ function configSecrets(config) {
27257
+ const providers = Object.keys(ALL_PROVIDERS);
27258
+ return [
27259
+ config.apiKey,
27260
+ config.openAiApiKey,
27261
+ config.openrouterKey,
27262
+ config.geminiKey,
27263
+ config.openaiCompatibleApiKey,
27264
+ ...providers.map((provider) => config.getProviderApiKey(provider))
27265
+ ];
27266
+ }
25400
27267
  var Session = class {
25401
27268
  aiService;
25402
27269
  usage;
@@ -25445,7 +27312,9 @@ var Session = class {
25445
27312
  liveOutput: (taskId, opts) => this.orchestrator.getLiveOutput(taskId, opts),
25446
27313
  lastAttempt: (taskId) => lastAttemptDigest(this.taskLogLocation, taskId),
25447
27314
  taskSkills: () => this.workspaceSkills(),
25448
- runCommand: (command, signal) => this.fsAdapter.bash(command, signal)
27315
+ runCommand: (command, signal) => this.fsAdapter.bash(command, signal),
27316
+ fetchUrl: (url) => this.fetcher.fetch(url),
27317
+ webSearch: async (query) => this.fetcher.search?.(query)
25449
27318
  });
25450
27319
  constructor(parts) {
25451
27320
  this.config = parts.config;
@@ -27097,10 +28966,10 @@ var StructuredSession = class extends AbstractRunnerSession {
27097
28966
  if (second) throw new McpAttachError(`Could not start ${this.launch.runner} with Ordewell's tools, so the task was not sent to it. First spawn: ${first}. Respawn: ${second}.`);
27098
28967
  }
27099
28968
  async startWithTools(opts) {
27100
- const { server, scope } = this.launch.tools;
28969
+ const { server: server4, scope } = this.launch.tools;
27101
28970
  let credential;
27102
28971
  try {
27103
- credential = await server.issueTaskToken(scope, {
28972
+ credential = await server4.issueTaskToken(scope, {
27104
28973
  taskComplete: async (report) => {
27105
28974
  this.structuredEmitter.emit("taskComplete", report);
27106
28975
  return { text: "Recorded. End your turn now." };
@@ -27114,7 +28983,7 @@ var StructuredSession = class extends AbstractRunnerSession {
27114
28983
  return `Ordewell's MCP server could not issue a token (${err instanceof Error ? err.message : String(err)})`;
27115
28984
  }
27116
28985
  if (this.exited) {
27117
- server.revoke(credential.token);
28986
+ server4.revoke(credential.token);
27118
28987
  return null;
27119
28988
  }
27120
28989
  this.toolToken = credential.token;
@@ -27285,7 +29154,7 @@ var StructuredSession = class extends AbstractRunnerSession {
27285
29154
  }
27286
29155
  async interruptTurn(turn, adapter) {
27287
29156
  const grace = this.launch.interruptGraceMs;
27288
- const ended = new Promise((resolve6) => this.structuredEmitter.once("turnEnd", () => resolve6()));
29157
+ const ended = new Promise((resolve9) => this.structuredEmitter.once("turnEnd", () => resolve9()));
27289
29158
  if (await adapter.interrupt(grace)) {
27290
29159
  const settled = await settleWithin(ended.then(() => true), { timeoutMs: grace, onTimeout: () => false });
27291
29160
  if (settled) return;
@@ -27826,7 +29695,7 @@ var RunnerRegistry = class {
27826
29695
 
27827
29696
  // src/plugins/removedPluginNotice.ts
27828
29697
  var fs14 = __toESM(require("fs"));
27829
- var path24 = __toESM(require("path"));
29698
+ var path29 = __toESM(require("path"));
27830
29699
  function isManifestFile(file) {
27831
29700
  try {
27832
29701
  return fs14.statSync(file).isFile();
@@ -27835,10 +29704,10 @@ function isManifestFile(file) {
27835
29704
  }
27836
29705
  }
27837
29706
  function removedPluginNotice(dataDir = globalDataDir()) {
27838
- const directory = path24.join(dataDir, "plugins");
29707
+ const directory = path29.join(dataDir, "plugins");
27839
29708
  try {
27840
- const hasManifest = isManifestFile(path24.join(directory, "manifest.json")) || fs14.readdirSync(directory, { withFileTypes: true }).some(
27841
- (entry) => (entry.isDirectory() || entry.isSymbolicLink()) && isManifestFile(path24.join(directory, entry.name, "manifest.json"))
29709
+ const hasManifest = isManifestFile(path29.join(directory, "manifest.json")) || fs14.readdirSync(directory, { withFileTypes: true }).some(
29710
+ (entry) => (entry.isDirectory() || entry.isSymbolicLink()) && isManifestFile(path29.join(directory, entry.name, "manifest.json"))
27842
29711
  );
27843
29712
  if (hasManifest) return "Plugin runners were removed; manifests in ~/.ordewell/plugins/ are ignored. See the CHANGELOG.";
27844
29713
  } catch {
@@ -27849,14 +29718,14 @@ function removedPluginNotice(dataDir = globalDataDir()) {
27849
29718
 
27850
29719
  // src/utils/stateStore.ts
27851
29720
  var fs15 = __toESM(require("fs"));
27852
- var path25 = __toESM(require("path"));
29721
+ var path30 = __toESM(require("path"));
27853
29722
  var STATE_FILE = "state.json";
27854
29723
  function getStatePath(baseDir) {
27855
- return path25.join(getStateDir(baseDir), STATE_FILE);
29724
+ return path30.join(getStateDir(baseDir), STATE_FILE);
27856
29725
  }
27857
29726
  function saveState(plan, baseDir) {
27858
29727
  const statePath = getStatePath(baseDir ?? process.cwd());
27859
- ensureDir(path25.dirname(statePath));
29728
+ ensureDir(path30.dirname(statePath));
27860
29729
  plan.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
27861
29730
  fs15.writeFileSync(statePath, JSON.stringify(plan, null, 2));
27862
29731
  }
@@ -27892,11 +29761,11 @@ function stateExists(baseDir) {
27892
29761
 
27893
29762
  // src/services/SettingsService.ts
27894
29763
  var fs16 = __toESM(require("fs"));
27895
- var path26 = __toESM(require("path"));
29764
+ var path31 = __toESM(require("path"));
27896
29765
  function getSettingsPath() {
27897
29766
  const override = process.env.ORDEWELL_SETTINGS_PATH;
27898
29767
  if (override && override.trim()) return override.trim();
27899
- return path26.join(globalDataDir(), "settings.json");
29768
+ return path31.join(globalDataDir(), "settings.json");
27900
29769
  }
27901
29770
  function readPlannerModels(raw) {
27902
29771
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
@@ -28005,7 +29874,7 @@ var SettingsService = class {
28005
29874
  return {};
28006
29875
  }
28007
29876
  persist() {
28008
- const dir = path26.dirname(this.filePath);
29877
+ const dir = path31.dirname(this.filePath);
28009
29878
  if (!fs16.existsSync(dir)) {
28010
29879
  fs16.mkdirSync(dir, { recursive: true });
28011
29880
  }
@@ -28079,7 +29948,9 @@ var PlannerModelMemory = class {
28079
29948
  DEFAULT_MAX_PARALLEL,
28080
29949
  DEFAULT_PLANNER_ALLOWLIST,
28081
29950
  DEFAULT_RUNNERS,
29951
+ DEFAULT_WEB_READ_BUDGET,
28082
29952
  DENY_ALL,
29953
+ DuckDuckGoSearchProvider,
28083
29954
  EMPTY_CONVERSATION,
28084
29955
  EMPTY_HOLD,
28085
29956
  EMPTY_TASK_LOG,
@@ -28091,6 +29962,7 @@ var PlannerModelMemory = class {
28091
29962
  GREP_PCRE_PROBE_ARGS,
28092
29963
  GeminiService,
28093
29964
  HARNESS_PLANNER_REASON,
29965
+ HttpWebFetcher,
28094
29966
  JSON_REPAIR_INSTRUCTION,
28095
29967
  LineBuffer,
28096
29968
  ModelCatalog,
@@ -28157,6 +30029,7 @@ var PlannerModelMemory = class {
28157
30029
  TaskOrchestrator,
28158
30030
  VerdictEngine,
28159
30031
  WINDOWS_MAX_COMMAND_LINE,
30032
+ WebResponseCache,
28160
30033
  WorkspaceNotAProjectError,
28161
30034
  WorkspaceNotFoundError,
28162
30035
  abbreviateHome,
@@ -28298,6 +30171,7 @@ var PlannerModelMemory = class {
28298
30171
  loadSessionPlanState,
28299
30172
  loadState,
28300
30173
  loadedSkillTokens,
30174
+ looksLikeChallenge,
28301
30175
  looksLikePlanAttempt,
28302
30176
  mapAgentTool,
28303
30177
  markAction,
@@ -28330,6 +30204,7 @@ var PlannerModelMemory = class {
28330
30204
  parseMaxParallel,
28331
30205
  parsePartialPlan,
28332
30206
  parsePlanJson,
30207
+ parseSearchHits,
28333
30208
  parseTaskOpsJson,
28334
30209
  parseTaskQueryJson,
28335
30210
  partedPromptUsage,
@@ -28390,6 +30265,8 @@ var PlannerModelMemory = class {
28390
30265
  sessionDataDir,
28391
30266
  sessionRuntimeSettings,
28392
30267
  sharedMcpServer,
30268
+ sharedSearchProvider,
30269
+ sharedWebResponseCache,
28393
30270
  skillNames,
28394
30271
  skillTokens,
28395
30272
  stateExists,