@nexrall/code-core 1.4.62 → 1.4.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/agent/agentRegistry.d.ts +14 -3
  2. package/dist/agent/agentRegistry.d.ts.map +1 -1
  3. package/dist/agent/agentRegistry.js +136 -6
  4. package/dist/agent/agentTypes.d.ts +51 -1
  5. package/dist/agent/agentTypes.d.ts.map +1 -1
  6. package/dist/agent/agentTypes.js +194 -11
  7. package/dist/agent/backgroundAgents.d.ts +66 -0
  8. package/dist/agent/backgroundAgents.d.ts.map +1 -0
  9. package/dist/agent/backgroundAgents.js +145 -0
  10. package/dist/agent/loop.d.ts +43 -6
  11. package/dist/agent/loop.d.ts.map +1 -1
  12. package/dist/agent/loop.js +952 -270
  13. package/dist/agent/modelCatalogue.d.ts +15 -0
  14. package/dist/agent/modelCatalogue.d.ts.map +1 -1
  15. package/dist/agent/modelCatalogue.js +46 -0
  16. package/dist/agent/peerTransport.d.ts.map +1 -1
  17. package/dist/agent/peerTransport.js +19 -12
  18. package/dist/agent/readDedupe.d.ts +15 -0
  19. package/dist/agent/readDedupe.d.ts.map +1 -0
  20. package/dist/agent/readDedupe.js +146 -0
  21. package/dist/agent/toolPrefetch.d.ts +44 -0
  22. package/dist/agent/toolPrefetch.d.ts.map +1 -0
  23. package/dist/agent/toolPrefetch.js +101 -0
  24. package/dist/agent/trust.d.ts +0 -5
  25. package/dist/agent/trust.d.ts.map +1 -1
  26. package/dist/agent/trust.js +41 -0
  27. package/dist/api/client.d.ts +38 -0
  28. package/dist/api/client.d.ts.map +1 -1
  29. package/dist/api/client.js +132 -9
  30. package/dist/auth/index.d.ts +26 -18
  31. package/dist/auth/index.d.ts.map +1 -1
  32. package/dist/auth/index.js +45 -44
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -0
  36. package/dist/permissions/modePolicy.d.ts +10 -0
  37. package/dist/permissions/modePolicy.d.ts.map +1 -1
  38. package/dist/permissions/modePolicy.js +11 -0
  39. package/dist/tools/executor.d.ts +36 -0
  40. package/dist/tools/executor.d.ts.map +1 -1
  41. package/dist/tools/executor.js +416 -89
  42. package/dist/tools/tsLangService.d.ts.map +1 -1
  43. package/dist/tools/tsLangService.js +107 -21
  44. package/dist/types.d.ts +99 -21
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/types.js +12 -1
  47. package/dist/util/miniYaml.d.ts +10 -0
  48. package/dist/util/miniYaml.d.ts.map +1 -0
  49. package/dist/util/miniYaml.js +149 -0
  50. package/package.json +8 -17
@@ -33,6 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.capExternalOutput = capExternalOutput;
37
+ exports.runCapture = runCapture;
38
+ exports.globToRegex = globToRegex;
36
39
  exports.executeTool = executeTool;
37
40
  const fs = __importStar(require("fs"));
38
41
  const path = __importStar(require("path"));
@@ -43,13 +46,17 @@ const dns = __importStar(require("dns"));
43
46
  const child_process_1 = require("child_process");
44
47
  const sandbox_1 = require("./sandbox");
45
48
  const auth_1 = require("../auth");
49
+ // billingOverrideFields is the ONE place the wire's teamId rule lives (absent =
50
+ // follow the account's saved choice, null = explicit personal) — shared with the
51
+ // chat path so a fixed-price call cannot drift from it.
52
+ const client_1 = require("../api/client");
46
53
  const editCompleteness_1 = require("../agent/editCompleteness");
47
54
  const securityLint_1 = require("../agent/securityLint");
48
55
  const memory_1 = require("../agent/memory");
49
56
  const skills_1 = require("../agent/skills");
50
57
  const testIntegrity_1 = require("../agent/testIntegrity");
51
58
  const crossFile_1 = require("../agent/crossFile");
52
- const client_1 = require("../api/client");
59
+ const client_2 = require("../api/client");
53
60
  const symbols_1 = require("./symbols");
54
61
  const tsLangService_1 = require("./tsLangService");
55
62
  const safeSlice_1 = require("../util/safeSlice");
@@ -60,7 +67,15 @@ const peerTransport_1 = require("../agent/peerTransport");
60
67
  const DEFAULT_TIMEOUT_MS = 60000;
61
68
  const MAX_FETCH_BYTES = 200 * 1024; // 200 KB
62
69
  const MAX_READ_BYTES = 500 * 1024; // 500 KB — still used by notebook_read (JSON.parse needs it whole)
63
- const MAX_OUTPUT_CHARS = 100000; // bash / grep output cap (~100 KB)
70
+ // bash / grep / list output cap. 30K chars matches Claude Code's Bash default; it was 100K,
71
+ // i.e. ~28K tokens re-sent on every later request for one noisy command. Full bash output
72
+ // still spills to a temp file (see GAP B below), so nothing is lost — only inlined less.
73
+ const MAX_OUTPUT_CHARS = 30000;
74
+ // fetch_url: text handed to the model AFTER html stripping. The download cap above stays
75
+ // larger because raw HTML is mostly markup that stripHtml discards.
76
+ const MAX_FETCH_OUTPUT_CHARS = 40000;
77
+ // glob / search_files(files) list cap, most-recent first — same as Claude Code's Glob.
78
+ const MAX_GLOB_RESULTS = 100;
64
79
  // read_file has NO byte-size gate (unlike the old 500KB cutoff) — like Claude
65
80
  // Code, a file of any size can be read; it's always streamed line-by-line so
66
81
  // memory is bounded regardless of file size. Instead there are two independent
@@ -77,6 +92,10 @@ const MAX_READ_LINES = 2000;
77
92
  const MAX_READ_TOKENS = 25000;
78
93
  const READ_CHARS_PER_TOKEN = 4; // rough, conservative — consistent with loop.ts's resume estimator
79
94
  const MAX_READ_CHARS = MAX_READ_TOKENS * READ_CHARS_PER_TOKEN; // 100,000 chars
95
+ // Per-line cap (Claude Code: 2000). Without it a line longer than the whole token budget
96
+ // (minified bundle, one-line JSON) could never be kept: the read returned "lines 1-0 (more
97
+ // lines follow — pass offset:0 to continue)" forever and the model fell back to `cat`.
98
+ const MAX_READ_LINE_CHARS = 2000;
80
99
  // GAP B — output spillover. The inline bash result keeps only HEAD+TAIL (~100 KB),
81
100
  // which loses the MIDDLE of a large log — often exactly where a stack trace's root
82
101
  // cause or a failing assertion lives. To make the full log recoverable WITHOUT
@@ -266,12 +285,16 @@ async function readFileWindowed(resolved, offset, requestedLimit) {
266
285
  let keptChars = 0;
267
286
  let lineNo = 0; // 0-based index of the NEXT line to be completed
268
287
  let carry = ''; // partial line spanning chunk boundaries
288
+ let carryOverflow = false; // carry already holds MAX_READ_LINE_CHARS+ of a longer line
269
289
  let stopped = false;
270
290
  let hitTokenCap = false;
271
291
  let sawEof = false;
272
292
  const stream = fs.createReadStream(resolved, { encoding: 'utf-8', highWaterMark: 256 * 1024 });
273
293
  // Returns false if adding this line would exceed the token budget (line NOT kept).
274
- const tryPushLine = (line) => {
294
+ const tryPushLine = (raw) => {
295
+ const line = raw.length > MAX_READ_LINE_CHARS
296
+ ? `${raw.slice(0, MAX_READ_LINE_CHARS)}… [line truncated at ${MAX_READ_LINE_CHARS} chars]`
297
+ : raw;
275
298
  const formatted = formatNumberedLine(offset, kept.length, line);
276
299
  const added = formatted.length + 1; // +1 for the join newline
277
300
  if (keptChars + added > MAX_READ_CHARS) {
@@ -304,9 +327,23 @@ async function readFileWindowed(resolved, offset, requestedLimit) {
304
327
  resolve({ output: `[File: ${resolved} — lines ${first}-${last}${totalNote}${note}]\n${numbered}` });
305
328
  };
306
329
  stream.on('data', (chunk) => {
307
- const text = carry + (typeof chunk === 'string' ? chunk : chunk.toString('utf-8'));
330
+ let str = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
331
+ if (carryOverflow) {
332
+ // Still inside an over-long line whose kept prefix is already in `carry`:
333
+ // discard until its newline so memory stays bounded for one-line files.
334
+ const nl = str.indexOf('\n');
335
+ if (nl < 0)
336
+ return;
337
+ str = str.slice(nl);
338
+ carryOverflow = false;
339
+ }
340
+ const text = carry + str;
308
341
  const lines = text.split('\n');
309
342
  carry = lines.pop() ?? ''; // last element is an incomplete line (or '')
343
+ if (carry.length > MAX_READ_LINE_CHARS) {
344
+ carry = carry.slice(0, MAX_READ_LINE_CHARS + 1); // +1 so tryPushLine still marks it truncated
345
+ carryOverflow = true;
346
+ }
310
347
  for (const line of lines) {
311
348
  if (lineNo >= offset) {
312
349
  if (lineNo >= hardEndLine) {
@@ -430,7 +467,7 @@ async function writeFile(input, workDir) {
430
467
  // A brand-new file has no "removed" side — every line is an addition.
431
468
  return { output: `Created ${resolved} (${lines} lines, ${bytes} bytes)${sec}`, linesAdded: lines, linesRemoved: 0 };
432
469
  }
433
- const xfile = crossFileBreakageWarning(resolved, normalizeLF(priorContent), normalizeLF(content), workDir ?? process.cwd());
470
+ const xfile = await crossFileBreakageWarning(resolved, normalizeLF(priorContent), normalizeLF(content), workDir ?? process.cwd());
434
471
  // Reward-hacking guard: a write_file that OVERWRITES an existing test file can
435
472
  // silently drop assertions / test cases. The loop layer can't see the prior
436
473
  // content — but we can (we just read it). Run the full old→new analysis and
@@ -1107,22 +1144,116 @@ async function bash(input, abortSignal, sandbox, workDir, onStream) {
1107
1144
  child.on('close', () => clearInterval(pollAbort));
1108
1145
  });
1109
1146
  }
1147
+ /**
1148
+ * Cap output from a tool we do not control (MCP servers). Built-in tools all cap their
1149
+ * own output, but an MCP result went into the conversation verbatim: one call to a
1150
+ * DB/browser/log server could inline megabytes, which is then re-sent (and re-billed as
1151
+ * cache writes) on every later round until compaction. Keep HEAD+TAIL inline and spill
1152
+ * the full text to a temp file the model can read_file with offset/limit — the same
1153
+ * contract as bash's GAP B spill.
1154
+ */
1155
+ function capExternalOutput(output, label, maxChars = MAX_OUTPUT_CHARS) {
1156
+ if (output.length <= maxChars)
1157
+ return output;
1158
+ let spillNote = '';
1159
+ try {
1160
+ fs.mkdirSync(SPILL_DIR, { recursive: true });
1161
+ const safe = label.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 60);
1162
+ const spillPath = path.join(SPILL_DIR, `${safe}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.log`);
1163
+ fs.writeFileSync(spillPath, (0, safeSlice_1.sliceSafeEnd)(output, MAX_SPILL_BYTES));
1164
+ spillNote = ` Full output (${output.length} chars) saved to ${spillPath} — use read_file with offset/limit to inspect any section.`;
1165
+ }
1166
+ catch { /* spill is best-effort */ }
1167
+ const headLen = Math.floor(maxChars * 0.7);
1168
+ const tailLen = maxChars - headLen;
1169
+ const omitted = output.length - headLen - tailLen;
1170
+ return `${(0, safeSlice_1.sliceSafeEnd)(output, headLen)}\n\n[… ${omitted} chars omitted.${spillNote}]\n\n${(0, safeSlice_1.sliceSafeStart)(output, output.length - tailLen)}`;
1171
+ }
1172
+ function runCapture(cmd, args, opts) {
1173
+ return new Promise((resolve) => {
1174
+ let stdout = '';
1175
+ let stderr = '';
1176
+ let bytes = 0;
1177
+ let error;
1178
+ let settled = false;
1179
+ let child;
1180
+ try {
1181
+ child = (0, child_process_1.spawn)(cmd, args, { cwd: opts.cwd, stdio: ['ignore', 'pipe', 'pipe'] });
1182
+ }
1183
+ catch (e) {
1184
+ resolve({ stdout: '', stderr: '', status: null, signal: null, error: e });
1185
+ return;
1186
+ }
1187
+ const finish = (status, signal) => {
1188
+ if (settled)
1189
+ return;
1190
+ settled = true;
1191
+ clearTimeout(timer);
1192
+ resolve({ stdout, stderr, status, signal, ...(error ? { error } : {}) });
1193
+ };
1194
+ const kill = (code) => {
1195
+ if (!error)
1196
+ error = Object.assign(new Error(`${cmd} ${code}`), { code });
1197
+ try {
1198
+ child.kill('SIGTERM');
1199
+ }
1200
+ catch { /* already gone */ }
1201
+ };
1202
+ const timer = setTimeout(() => kill('ETIMEDOUT'), opts.timeout);
1203
+ child.stdout.setEncoding('utf-8');
1204
+ child.stderr.setEncoding('utf-8');
1205
+ child.stdout.on('data', (chunk) => {
1206
+ if (error)
1207
+ return;
1208
+ bytes += Buffer.byteLength(chunk);
1209
+ if (bytes > opts.maxBuffer) {
1210
+ // Keep what fits, like spawnSync's truncated stdout on ENOBUFS.
1211
+ stdout += chunk.slice(0, Math.max(0, chunk.length - (bytes - opts.maxBuffer)));
1212
+ kill('ENOBUFS');
1213
+ return;
1214
+ }
1215
+ stdout += chunk;
1216
+ });
1217
+ child.stderr.on('data', (chunk) => { if (stderr.length < 64 * 1024)
1218
+ stderr += chunk; });
1219
+ child.on('error', (e) => { if (!error)
1220
+ error = e; finish(null, null); });
1221
+ child.on('close', (code, signal) => finish(code, signal));
1222
+ });
1223
+ }
1110
1224
  // Detect ripgrep once per process — preferred over grep (faster, respects .gitignore).
1225
+ //
1226
+ // Binary resolution: NEXRALL_RG_PATH first (the VS Code extension points it at the rg
1227
+ // that ships inside every VS Code / Cursor install, so users without a system rg still
1228
+ // get the fast, .gitignore-aware path), then `rg` on PATH. Before this, a machine with
1229
+ // no rg installed silently fell back to grep and a full, ignore-unaware directory walk.
1111
1230
  let _rgChecked = false;
1112
1231
  let _rgAvailable = false;
1232
+ let _rgBin = 'rg';
1113
1233
  function ripgrepAvailable() {
1114
1234
  if (!_rgChecked) {
1115
- const probe = (0, child_process_1.spawnSync)('rg', ['--version'], { encoding: 'utf-8', timeout: 3000 });
1116
- _rgAvailable = probe.status === 0;
1117
1235
  _rgChecked = true;
1236
+ const candidates = [process.env.NEXRALL_RG_PATH, 'rg'].filter((c) => !!c);
1237
+ for (const bin of candidates) {
1238
+ const probe = (0, child_process_1.spawnSync)(bin, ['--version'], { encoding: 'utf-8', timeout: 3000 });
1239
+ if (probe.status === 0) {
1240
+ _rgBin = bin;
1241
+ _rgAvailable = true;
1242
+ break;
1243
+ }
1244
+ }
1118
1245
  }
1119
1246
  return _rgAvailable;
1120
1247
  }
1248
+ /** The ripgrep binary to spawn. Only meaningful after ripgrepAvailable() returned true. */
1249
+ function rgBin() {
1250
+ return _rgBin;
1251
+ }
1121
1252
  // ── Cross-file breakage warning ──────────────────────────────────────────────
1122
1253
  // After an edit removes/renames an exported symbol, scan the rest of the repo for
1123
1254
  // surviving references. Returns a short warning string (or '' when clean). Best-
1124
1255
  // effort, time-boxed, and never throws — a scan failure must not fail the edit.
1125
- function crossFileBreakageWarning(editedAbsPath, oldContent, newContent, workDir) {
1256
+ async function crossFileBreakageWarning(editedAbsPath, oldContent, newContent, workDir) {
1126
1257
  if (process.env.NEXRALL_CROSSFILE_CHECK === '0')
1127
1258
  return '';
1128
1259
  let removed;
@@ -1143,8 +1274,9 @@ function crossFileBreakageWarning(editedAbsPath, oldContent, newContent, workDir
1143
1274
  } })();
1144
1275
  const hits = [];
1145
1276
  const MAX_SYMBOLS = 8;
1146
- for (const sym of removed.slice(0, MAX_SYMBOLS)) {
1147
- const refs = scanReferences(sym.name, workDir, editedReal);
1277
+ // Scans run concurrently (they used to be up to 8 sequential blocking rg calls).
1278
+ const scanned = await Promise.all(removed.slice(0, MAX_SYMBOLS).map(async (sym) => ({ sym, refs: await scanReferences(sym.name, workDir, editedReal) })));
1279
+ for (const { sym, refs } of scanned) {
1148
1280
  if (refs.length) {
1149
1281
  const shown = refs.slice(0, 3).map((r) => ` ${r}`).join('\n');
1150
1282
  const more = refs.length > 3 ? `\n … and ${refs.length - 3} more` : '';
@@ -1159,31 +1291,20 @@ function crossFileBreakageWarning(editedAbsPath, oldContent, newContent, workDir
1159
1291
  }
1160
1292
  // Find files (other than the edited one) that reference `name` as a whole word.
1161
1293
  // Uses ripgrep when available (fast, .gitignore-aware), else a bounded grep -r.
1162
- function scanReferences(name, workDir, excludeRealPath) {
1294
+ async function scanReferences(name, workDir, excludeRealPath) {
1163
1295
  const pattern = `\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`;
1164
1296
  const files = new Set();
1165
1297
  try {
1166
- if (ripgrepAvailable()) {
1167
- const r = (0, child_process_1.spawnSync)('rg', ['-l', '--no-messages', '-e', pattern, '.'], {
1168
- cwd: workDir, encoding: 'utf-8', timeout: 8000, maxBuffer: 8 * 1024 * 1024,
1169
- });
1170
- if (r.stdout)
1171
- for (const line of r.stdout.split('\n')) {
1172
- if (line.trim())
1173
- files.add(line.trim());
1174
- }
1175
- }
1176
- else {
1177
- const r = (0, child_process_1.spawnSync)('grep', ['-rlI', '--exclude-dir=node_modules', '--exclude-dir=.git',
1178
- '--exclude-dir=dist', '--exclude-dir=.next', '-E', pattern, '.'], {
1179
- cwd: workDir, encoding: 'utf-8', timeout: 8000, maxBuffer: 8 * 1024 * 1024,
1180
- });
1181
- if (r.stdout)
1182
- for (const line of r.stdout.split('\n')) {
1183
- if (line.trim())
1184
- files.add(line.trim());
1185
- }
1186
- }
1298
+ const opts = { cwd: workDir, timeout: 8000, maxBuffer: 8 * 1024 * 1024 };
1299
+ const r = ripgrepAvailable()
1300
+ ? await runCapture(rgBin(), ['-l', '--no-messages', '-e', pattern, '.'], opts)
1301
+ : await runCapture('grep', ['-rlI', '--exclude-dir=node_modules', '--exclude-dir=.git',
1302
+ '--exclude-dir=dist', '--exclude-dir=.next', '-E', pattern, '.'], opts);
1303
+ if (r.stdout)
1304
+ for (const line of r.stdout.split('\n')) {
1305
+ if (line.trim())
1306
+ files.add(line.trim());
1307
+ }
1187
1308
  }
1188
1309
  catch {
1189
1310
  return [];
@@ -1205,9 +1326,13 @@ function scanReferences(name, workDir, excludeRealPath) {
1205
1326
  async function searchFiles(input, workDir) {
1206
1327
  const pattern = typeof input.pattern === 'string' ? input.pattern : '';
1207
1328
  const searchPath = typeof input.path === 'string' ? input.path : '.';
1208
- const searchType = typeof input.type === 'string' ? input.type : 'content';
1209
1329
  const ignoreCase = input.case_insensitive === true;
1210
1330
  const contextLines = typeof input.context_lines === 'number' ? Math.min(10, Math.max(0, input.context_lines)) : 0;
1331
+ // Default is "files" (paths of matching files only), like Claude Code's Grep
1332
+ // files_with_matches: a content dump of a common pattern was ~50× larger. Asking for
1333
+ // context_lines implies the caller wants content.
1334
+ const searchType = typeof input.type === 'string' ? input.type : (contextLines > 0 ? 'content' : 'files');
1335
+ const filesOnly = searchType === 'files' || searchType === 'files_with_matches';
1211
1336
  const include = typeof input.include === 'string' ? input.include : ''; // e.g. "*.ts"
1212
1337
  if (!pattern)
1213
1338
  return { error: 'Missing required parameter: pattern' };
@@ -1216,7 +1341,8 @@ async function searchFiles(input, workDir) {
1216
1341
  if (searchType === 'filename') {
1217
1342
  const matches = [];
1218
1343
  const patternLower = pattern.toLowerCase();
1219
- walkDir(resolved, (filePath) => {
1344
+ const listing = await listFiles(resolved);
1345
+ listing.files.forEach((filePath) => {
1220
1346
  const name = path.basename(filePath);
1221
1347
  const nameLower = name.toLowerCase();
1222
1348
  // Also match against the path relative to the search root — a pattern
@@ -1234,9 +1360,15 @@ async function searchFiles(input, workDir) {
1234
1360
  });
1235
1361
  if (matches.length === 0)
1236
1362
  return { output: 'No matching files found.' };
1237
- let out = matches.join('\n');
1363
+ const relBase = workDir ? path.resolve(workDir) : process.cwd();
1364
+ let out = matches.map((f) => {
1365
+ const r = path.relative(relBase, f);
1366
+ return r.startsWith('..') || path.isAbsolute(r) ? f : r.replace(/\\/g, '/');
1367
+ }).join('\n');
1238
1368
  if (out.length > MAX_OUTPUT_CHARS)
1239
1369
  out = (0, safeSlice_1.sliceSafeEnd)(out, MAX_OUTPUT_CHARS) + '\n[Truncated]';
1370
+ if (listing.truncated)
1371
+ out += `\n[Scan stopped after ${WALK_MAX_FILES.toLocaleString('en-US')} files — narrow the path for complete results.]`;
1240
1372
  return { output: out };
1241
1373
  }
1242
1374
  else {
@@ -1246,20 +1378,31 @@ async function searchFiles(input, workDir) {
1246
1378
  // pattern can emit tens of MB and the default silently truncates + sets
1247
1379
  // status:null (ENOBUFS), which used to surface as an empty "search failed".
1248
1380
  const SEARCH_MAX_BUFFER = 64 * 1024 * 1024; // 64 MB
1249
- const spawnOpts = { encoding: 'utf-8', timeout: DEFAULT_TIMEOUT_MS, maxBuffer: SEARCH_MAX_BUFFER };
1381
+ // Run from workDir with a RELATIVE target so every output line is workDir-relative
1382
+ // (what read_file resolves against). Absolute prefixes were ~25% of the tokens.
1383
+ // A target outside workDir keeps absolute paths.
1384
+ const base = workDir ? path.resolve(workDir) : process.cwd();
1385
+ const relTarget = path.relative(base, resolved);
1386
+ const inside = !relTarget.startsWith('..') && !path.isAbsolute(relTarget);
1387
+ const target = inside ? (relTarget || '.') : resolved;
1388
+ const spawnOpts = { timeout: DEFAULT_TIMEOUT_MS, maxBuffer: SEARCH_MAX_BUFFER, cwd: base };
1250
1389
  let result;
1251
1390
  if (ripgrepAvailable()) {
1252
- const args = ['--line-number', '--no-heading', '--color=never', '--hidden', '--glob', '!.git'];
1391
+ const args = filesOnly
1392
+ ? ['--files-with-matches', '--color=never', '--hidden', '--glob', '!.git']
1393
+ : ['--line-number', '--no-heading', '--color=never', '--hidden', '--glob', '!.git',
1394
+ // One minified-bundle hit could otherwise fill the whole output cap.
1395
+ '--max-columns', '500', '--max-columns-preview'];
1253
1396
  if (ignoreCase)
1254
1397
  args.push('-i');
1255
- if (contextLines > 0)
1398
+ if (contextLines > 0 && !filesOnly)
1256
1399
  args.push(`-C${contextLines}`);
1257
1400
  if (include)
1258
1401
  args.push('--glob', include);
1259
1402
  // -m is PER-FILE in rg/grep; the real global cap is applied on the
1260
1403
  // output below. Keep a generous per-file cap so no single file floods.
1261
- args.push('-m', '200', '--regexp', pattern, resolved);
1262
- result = (0, child_process_1.spawnSync)('rg', args, spawnOpts);
1404
+ args.push('-m', '200', '--regexp', pattern, target);
1405
+ result = await runCapture(rgBin(), args, spawnOpts);
1263
1406
  }
1264
1407
  else {
1265
1408
  // -E (ERE) matters: without it, grep defaults to BRE, where a bare
@@ -1272,23 +1415,26 @@ async function searchFiles(input, workDir) {
1272
1415
  // fallback path whenever ripgrep isn't installed on the host. ERE
1273
1416
  // matches rg's semantics (bare parens group, \( \) literal) so the
1274
1417
  // SAME pattern behaves identically whether or not rg is present.
1275
- const args = ['-rnE', '--binary-files=without-match', '--color=never'];
1418
+ const args = [filesOnly ? '-rlE' : '-rnE', '--binary-files=without-match', '--color=never'];
1276
1419
  if (ignoreCase)
1277
1420
  args.push('-i');
1278
- if (contextLines > 0)
1421
+ if (contextLines > 0 && !filesOnly)
1279
1422
  args.push(`-C${contextLines}`);
1280
1423
  if (include)
1281
1424
  args.push(`--include=${include}`);
1282
- args.push('--exclude-dir=.git', '--exclude-dir=node_modules', '--exclude-dir=dist', '--exclude-dir=.next', '--exclude-dir=__pycache__', '--exclude-dir=.turbo', '--exclude-dir=coverage', '--exclude-dir=.cache', '-m', '200', pattern, resolved);
1283
- result = (0, child_process_1.spawnSync)('grep', args, spawnOpts);
1425
+ args.push('--exclude-dir=.git', '--exclude-dir=node_modules', '--exclude-dir=dist', '--exclude-dir=.next', '--exclude-dir=__pycache__', '--exclude-dir=.turbo', '--exclude-dir=coverage', '--exclude-dir=.cache', '--exclude-dir=.venv', '--exclude-dir=venv', '-m', '200', pattern, target);
1426
+ result = await runCapture('grep', args, spawnOpts);
1284
1427
  }
1285
- let output = result.stdout ?? '';
1428
+ // rg/grep print "./x" when the target is "." — drop the noise prefix.
1429
+ let output = (result.stdout ?? '').replace(/^\.\//gm, '');
1286
1430
  const stderr = result.stderr ?? '';
1287
- // Distinguish real failure modes. spawnSync sets `.error` (not `.status`)
1288
- // for timeout (ETIMEDOUT), buffer overflow (ENOBUFS) and spawn failures.
1431
+ // Distinguish real failure modes. runCapture (like spawnSync) sets `.error`
1432
+ // (not `.status`) for timeout (ETIMEDOUT), buffer overflow (ENOBUFS) and spawn failures.
1289
1433
  const spawnErr = result.error;
1290
1434
  if (spawnErr) {
1291
- if (spawnErr.code === 'ETIMEDOUT' || result.signal === 'SIGTERM') {
1435
+ // ENOBUFS first: the child is SIGTERM'd on overflow too, so testing the signal
1436
+ // first misreported a too-large result as a timeout.
1437
+ if (spawnErr.code === 'ETIMEDOUT' || (result.signal === 'SIGTERM' && spawnErr.code !== 'ENOBUFS')) {
1292
1438
  const partial = output ? `\n\nPartial results before timeout:\n${globalCapMatches(output)}` : '';
1293
1439
  return { error: `Search timed out after ${Math.round(DEFAULT_TIMEOUT_MS / 1000)}s — narrow the path or pattern (or add an "include" filter).${partial}` };
1294
1440
  }
@@ -1308,11 +1454,23 @@ async function searchFiles(input, workDir) {
1308
1454
  // otherwise surfaces as raw, cryptic engine stderr (e.g. "parentheses
1309
1455
  // not balanced", "Unmatched ( or \("). Give an actionable hint instead
1310
1456
  // of just relaying the tool's internal error message verbatim.
1311
- const hint = /parenthes|bracket|brace|Unmatched|repetition-operator|invalid regex/i.test(stderr)
1457
+ const hint = /parenthes|bracket|brace|Unmatched|unclosed|repetition-operator|invalid regex/i.test(stderr)
1312
1458
  ? ' — the pattern has invalid/unbalanced regex syntax. If you meant to match literal parentheses/brackets, escape them (e.g. "\\(", "\\)"), or simplify the pattern.'
1313
1459
  : '';
1314
1460
  return { error: (stderr || 'search failed').trim() + hint };
1315
1461
  }
1462
+ if (filesOnly) {
1463
+ const files = output.split('\n').filter(Boolean);
1464
+ if (!files.length)
1465
+ return { output: 'No matches found.' };
1466
+ const shown = files.slice(0, MAX_GLOB_RESULTS);
1467
+ const more = files.length > shown.length
1468
+ ? `\n[${files.length - shown.length} more files not shown — narrow the pattern/path]` : '';
1469
+ return {
1470
+ output: `${files.length} file(s) contain matches (type="content" for the matching lines):\n` +
1471
+ shown.join('\n') + more,
1472
+ };
1473
+ }
1316
1474
  output = globalCapMatches(output);
1317
1475
  return { output: output || 'No matches found.' };
1318
1476
  }
@@ -1340,7 +1498,7 @@ function globalCapMatches(output) {
1340
1498
  }
1341
1499
  if (capped.length > MAX_OUTPUT_CHARS) {
1342
1500
  capped = (0, safeSlice_1.sliceSafeEnd)(capped, MAX_OUTPUT_CHARS);
1343
- note = `\n[Output truncated at ${MAX_OUTPUT_CHARS / 1024}KB.]`;
1501
+ note = `\n[Output truncated at ${MAX_OUTPUT_CHARS.toLocaleString('en-US')} chars.]`;
1344
1502
  }
1345
1503
  return capped + note;
1346
1504
  }
@@ -1367,13 +1525,68 @@ function matchesPattern(name, pattern) {
1367
1525
  return false;
1368
1526
  }
1369
1527
  }
1528
+ // Noise directories never worth walking. .venv/venv matter most: a single Python virtualenv
1529
+ // is hundreds of MB of third-party files that drowned glob / filename-search results.
1530
+ const WALK_SKIP_DIRS = new Set([
1531
+ '.git', 'node_modules', 'dist', '.next', '__pycache__', '.turbo', 'coverage',
1532
+ '.venv', 'venv', '.tox', '.mypy_cache', '.pytest_cache', '.ruff_cache', '.gradle',
1533
+ '.cache', '.parcel-cache', '.svelte-kit', '.nuxt', 'target', '.idea', '.DS_Store',
1534
+ ]);
1535
+ /** Hard ceiling on files visited by the fallback directory walk (no ripgrep). */
1536
+ const WALK_MAX_FILES = 50000;
1537
+ /**
1538
+ * List candidate files under `root` for glob / filename search.
1539
+ *
1540
+ * With ripgrep: `rg --files`, which honours .gitignore / .ignore (and git's global
1541
+ * excludes) — so build output, vendored deps and generated files that the project
1542
+ * itself ignores no longer drown results. Measured on this repo: the old walk visited
1543
+ * 2,972 files vs 948 tracked. `--hidden` keeps dotfiles like .github/ (still minus
1544
+ * anything ignored), and the WALK_SKIP_DIRS noise list is applied on top so a repo
1545
+ * with no .gitignore still skips node_modules etc.
1546
+ *
1547
+ * Without ripgrep: the old walk, but capped at WALK_MAX_FILES so a 100k-file monorepo
1548
+ * cannot stall the process; `truncated` tells the caller to say so.
1549
+ */
1550
+ async function listFiles(root) {
1551
+ if (ripgrepAvailable()) {
1552
+ // --follow: the walkDir this replaced followed symlinks (pnpm / linked monorepo
1553
+ // packages); without it rg silently drops every symlinked dir AND file. rg detects
1554
+ // symlink loops itself.
1555
+ const args = ['--files', '--hidden', '--follow', '--no-messages', '--color=never'];
1556
+ for (const d of WALK_SKIP_DIRS)
1557
+ args.push('--glob', `!**/${d}/**`);
1558
+ args.push('.');
1559
+ const r = await runCapture(rgBin(), args, { cwd: root, timeout: DEFAULT_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 });
1560
+ // rg exits 1 when it finds no files at all — a real (empty) answer, not a failure.
1561
+ // Exit 2 = "some path errored" (a symlink loop, an unreadable dir) — the list of
1562
+ // everything else is still complete, so keep it rather than re-walking the tree.
1563
+ if (!r.error && (r.status === 0 || r.status === 1 || (r.status === 2 && r.stdout))) {
1564
+ const files = r.stdout.split('\n').filter(Boolean).map((f) => path.join(root, f.replace(/^\.\//, '')));
1565
+ return { files, truncated: false };
1566
+ }
1567
+ // Anything else (timeout, overflow, spawn failure): fall through to the walk.
1568
+ }
1569
+ const files = [];
1570
+ let truncated = false;
1571
+ walkDir(root, (f) => {
1572
+ if (files.length >= WALK_MAX_FILES) {
1573
+ truncated = true;
1574
+ return false;
1575
+ }
1576
+ files.push(f);
1577
+ return true;
1578
+ });
1579
+ return { files, truncated };
1580
+ }
1581
+ // callback returning `false` STOPS the walk (a cap that only stopped collecting would
1582
+ // still traverse the whole tree, so it would not bound the time at all).
1370
1583
  function walkDir(dirPath, callback, _visited = new Set()) {
1371
1584
  try {
1372
1585
  // Resolve symlinks to detect cycles — a symlink pointing to a parent dir
1373
1586
  // would cause infinite recursion without this guard.
1374
1587
  const real = fs.realpathSync(dirPath);
1375
1588
  if (_visited.has(real))
1376
- return;
1589
+ return true;
1377
1590
  _visited.add(real);
1378
1591
  const entries = fs.readdirSync(dirPath, { withFileTypes: true });
1379
1592
  for (const entry of entries) {
@@ -1390,18 +1603,20 @@ function walkDir(dirPath, callback, _visited = new Set()) {
1390
1603
  })());
1391
1604
  if (isDir) {
1392
1605
  // Skip common noise directories
1393
- if (['.git', 'node_modules', 'dist', '.next', '__pycache__', '.turbo', 'coverage'].includes(entry.name))
1606
+ if (WALK_SKIP_DIRS.has(entry.name))
1394
1607
  continue;
1395
- walkDir(fullPath, callback, _visited);
1608
+ if (walkDir(fullPath, callback, _visited) === false)
1609
+ return false;
1396
1610
  }
1397
- else {
1398
- callback(fullPath);
1611
+ else if (callback(fullPath) === false) {
1612
+ return false;
1399
1613
  }
1400
1614
  }
1401
1615
  }
1402
1616
  catch {
1403
1617
  // Skip unreadable directories
1404
1618
  }
1619
+ return true;
1405
1620
  }
1406
1621
  async function createDirectory(input, workDir) {
1407
1622
  const dirPath = typeof input.path === 'string' ? input.path : '';
@@ -1633,7 +1848,7 @@ async function editFile(input, workDir) {
1633
1848
  const diff = buildDiff(filePath, oldNorm, newNorm, origNorm);
1634
1849
  const linesBefore = origNorm.split('\n').length;
1635
1850
  const linesAfter = updated.split('\n').length;
1636
- const xfile = crossFileBreakageWarning(resolved, origNorm, normalizeLF(updated), workDir ?? process.cwd());
1851
+ const xfile = await crossFileBreakageWarning(resolved, origNorm, normalizeLF(updated), workDir ?? process.cwd());
1637
1852
  // Security-lint only the NEWLY INSERTED text, not the whole file. Scanning the
1638
1853
  // full file would re-report pre-existing findings on every unrelated edit —
1639
1854
  // noise that has nothing to do with the change being made, and the fastest way
@@ -1841,7 +2056,49 @@ function fetchBlocklistCheck(parsedUrl) {
1841
2056
  }
1842
2057
  return null;
1843
2058
  }
1844
- async function fetchUrl(input, _workDir, _redirectCount = 0, abortSignal) {
2059
+ /**
2060
+ * fetch_url as the model sees it. With `prompt`, the page (fetched HERE, so localhost
2061
+ * and intranet docs work and the server is never an open proxy) is answered by a cheap
2062
+ * model server-side (/api/code/tools/web_fetch_extract — DeepSeek Flash by default) and
2063
+ * only that answer is returned: Claude Code's WebFetch. Any failure of the extraction
2064
+ * step falls back to the ordinary capped page text, so the call never gets worse.
2065
+ */
2066
+ async function fetchUrlTool(input, workDir, abortSignal) {
2067
+ const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
2068
+ if (!prompt)
2069
+ return fetchUrl(input, workDir, 0, abortSignal);
2070
+ const capture = {};
2071
+ const raw = await fetchUrl(input, workDir, 0, abortSignal, capture);
2072
+ if (raw.error !== undefined || !capture.text?.trim())
2073
+ return raw;
2074
+ const token = (0, auth_1.getToken)();
2075
+ if (!token)
2076
+ return raw;
2077
+ try {
2078
+ const r = await _httpsPost(`${client_2.API_BASE}/api/code/tools/web_fetch_extract`, JSON.stringify({ url: capture.url ?? input.url, content: capture.text, prompt, ...(0, client_1.billingOverrideFields)() }), { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, 90000, abortSignal);
2079
+ let json = {};
2080
+ try {
2081
+ json = JSON.parse(r.body);
2082
+ }
2083
+ catch { /* fall through */ }
2084
+ if (r.status !== 200 || !json.text) {
2085
+ return { ...raw, output: `${raw.output ?? ''}\n\n[prompt extraction unavailable (${json.error ?? `HTTP ${r.status}`}) — raw page text shown instead]` };
2086
+ }
2087
+ return {
2088
+ output: `[Answer extracted from ${capture.url ?? input.url} by ${json.model ?? 'a low-cost model'} for: "${prompt.slice(0, 200)}"]\n\n` +
2089
+ `<untrusted_web_content url="${capture.url ?? input.url}">\n${json.text}\n</untrusted_web_content>`,
2090
+ };
2091
+ }
2092
+ catch (err) {
2093
+ if (err.name === 'AbortError')
2094
+ return { error: 'fetch_url stopped by user', interrupted: true };
2095
+ return { ...raw, output: `${raw.output ?? ''}\n\n[prompt extraction failed (${err.message}) — raw page text shown instead]` };
2096
+ }
2097
+ }
2098
+ async function fetchUrl(input, _workDir, _redirectCount = 0, abortSignal,
2099
+ // Receives the FULL stripped page text (before the 40K model-facing cap), for the
2100
+ // prompt-extraction path in fetchUrlTool. Threaded through redirects.
2101
+ capture) {
1845
2102
  const url = typeof input.url === 'string' ? input.url : '';
1846
2103
  if (!url)
1847
2104
  return { error: 'Missing required parameter: url' };
@@ -1943,7 +2200,7 @@ async function fetchUrl(input, _workDir, _redirectCount = 0, abortSignal) {
1943
2200
  finish({ error: `Invalid redirect location: ${res.headers.location}` });
1944
2201
  return;
1945
2202
  }
1946
- fetchUrl({ url: nextUrl }, undefined, _redirectCount + 1, abortSignal).then(finish);
2203
+ fetchUrl({ url: nextUrl }, undefined, _redirectCount + 1, abortSignal, capture).then(finish);
1947
2204
  return;
1948
2205
  }
1949
2206
  const contentType = res.headers['content-type'] ?? '';
@@ -1957,7 +2214,15 @@ async function fetchUrl(input, _workDir, _redirectCount = 0, abortSignal) {
1957
2214
  let body = Buffer.concat(chunks).toString('utf-8');
1958
2215
  if (isHtml)
1959
2216
  body = stripHtml(body);
1960
- const truncNote = truncated ? `\n\n[Truncated at ${MAX_FETCH_BYTES / 1024}KB]` : '';
2217
+ if (capture) {
2218
+ capture.text = body;
2219
+ capture.url = url;
2220
+ }
2221
+ let truncNote = truncated ? `\n\n[Truncated at ${MAX_FETCH_BYTES / 1024}KB]` : '';
2222
+ if (body.length > MAX_FETCH_OUTPUT_CHARS) {
2223
+ body = (0, safeSlice_1.sliceSafeEnd)(body, MAX_FETCH_OUTPUT_CHARS);
2224
+ truncNote = `\n\n[Truncated at ${MAX_FETCH_OUTPUT_CHARS.toLocaleString('en-US')} chars of page text]`;
2225
+ }
1961
2226
  // Prompt-injection mitigation: fetched web content is fully attacker-controlled
1962
2227
  // (anyone can put "ignore previous instructions..." on a page). Wrap it in an
1963
2228
  // explicit untrusted-data delimiter so the model treats it as DATA to read, not
@@ -2079,7 +2344,7 @@ async function multiEdit(input, workDir) {
2079
2344
  // preserving the original mode bits (+x on scripts etc.).
2080
2345
  const finalContent = wasCRLF ? content.replace(/\n/g, '\r\n') : content;
2081
2346
  atomicWritePreservingMode(resolved, finalContent, pre.mode);
2082
- const xfile = crossFileBreakageWarning(resolved, originalNorm, content, workDir ?? process.cwd());
2347
+ const xfile = await crossFileBreakageWarning(resolved, originalNorm, content, workDir ?? process.cwd());
2083
2348
  // Lint only the newly inserted text (see the same reasoning in editFile).
2084
2349
  const sec = (0, securityLint_1.securityNoteText)((0, securityLint_1.checkSecurity)(insertedText.join('\n')));
2085
2350
  return {
@@ -2102,38 +2367,97 @@ async function glob(input, workDir) {
2102
2367
  try {
2103
2368
  const resolved = resolvePath(searchDir, workDir);
2104
2369
  const matches = [];
2105
- // Convert glob pattern to regex
2106
- const toRegex = (p) => {
2107
- const escaped = p.replace(/[.+^${}()|[\]\\]/g, '\\$&');
2108
- const re = escaped
2109
- .replace(/\*\*\//g, '(?:[^/]+/)*') // **/ = zero or more segments (fixes root-level: index.ts matches **/*.ts)
2110
- .replace(/\*\*/g, '.*') // ** matches anything
2111
- .replace(/\*/g, '[^/]*') // * matches within one segment
2112
- .replace(/\?/g, '[^/]'); // ? matches single non-slash char
2113
- return new RegExp(`^${re}$`);
2114
- };
2115
- const regex = toRegex(pattern);
2116
- walkDir(resolved, (filePath) => {
2370
+ const regex = globToRegex(pattern);
2371
+ // Only fall back to basename test for patterns without a path separator
2372
+ // (i.e. single-segment patterns like "*.ts"). Patterns with "/" (e.g.
2373
+ // "src/*.ts") must match the full relative path to avoid false positives.
2374
+ const useBasename = !pattern.includes('/') && !pattern.includes('**');
2375
+ const listing = await listFiles(resolved);
2376
+ for (const filePath of listing.files) {
2117
2377
  const rel = path.relative(resolved, filePath).replace(/\\/g, '/');
2118
- // Only fall back to basename test for patterns without a path separator
2119
- // (i.e. single-segment patterns like "*.ts"). Patterns with "/" (e.g.
2120
- // "src/*.ts") must match the full relative path to avoid false positives.
2121
- const useBasename = !pattern.includes('/') && !pattern.includes('**');
2122
2378
  if (regex.test(rel) || (useBasename && regex.test(path.basename(filePath)))) {
2123
2379
  matches.push(filePath);
2124
2380
  }
2125
- });
2381
+ }
2382
+ const scanNote = listing.truncated
2383
+ ? `\n[Scan stopped after ${WALK_MAX_FILES.toLocaleString('en-US')} files — narrow the path for complete results.]`
2384
+ : '';
2126
2385
  if (!matches.length)
2127
- return { output: `No files matched pattern: ${pattern}` };
2128
- let out = matches.join('\n');
2129
- if (out.length > MAX_OUTPUT_CHARS)
2130
- out = (0, safeSlice_1.sliceSafeEnd)(out, MAX_OUTPUT_CHARS) + '\n[Truncated]';
2131
- return { output: `${matches.length} file(s) matched "${pattern}":\n${out}` };
2386
+ return { output: `No files matched pattern: ${pattern}${scanNote}` };
2387
+ // Most recently modified first, capped — same shape as Claude Code's Glob. Paths are
2388
+ // relative to the search dir: absolute prefixes were ~25% of the output's tokens.
2389
+ const mtime = (f) => { try {
2390
+ return fs.statSync(f).mtimeMs;
2391
+ }
2392
+ catch {
2393
+ return 0;
2394
+ } };
2395
+ const sorted = matches.map((f) => ({ f, t: mtime(f) })).sort((a, b) => b.t - a.t).map((x) => x.f);
2396
+ const shown = sorted.slice(0, MAX_GLOB_RESULTS).map((f) => path.relative(resolved, f).replace(/\\/g, '/'));
2397
+ const more = sorted.length > shown.length
2398
+ ? `\n[${sorted.length - shown.length} more not shown — narrow the pattern or path]`
2399
+ : '';
2400
+ return { output: `${matches.length} file(s) matched "${pattern}" under ${resolved}:\n${shown.join('\n')}${more}${scanNote}` };
2132
2401
  }
2133
2402
  catch (err) {
2134
2403
  return { error: err.message };
2135
2404
  }
2136
2405
  }
2406
+ /**
2407
+ * Glob → anchored RegExp. Scans the pattern once, so the `*` / `?` rules can never
2408
+ * rewrite the regex emitted for `**` (the old chained .replace() calls turned
2409
+ * `**\/` into `([^/]:[^/]+/)` — every `**` pattern silently matched nothing).
2410
+ * Supports `**`, `*`, `?`, `{a,b}` and `[...]` classes.
2411
+ */
2412
+ function globToRegex(pattern) {
2413
+ let re = '';
2414
+ let inBrace = 0;
2415
+ for (let i = 0; i < pattern.length; i++) {
2416
+ const c = pattern[i];
2417
+ if (c === '*') {
2418
+ if (pattern[i + 1] === '*') {
2419
+ const slash = pattern[i + 2] === '/';
2420
+ re += slash ? '(?:[^/]*/)*' : '.*';
2421
+ i += slash ? 2 : 1;
2422
+ }
2423
+ else {
2424
+ re += '[^/]*';
2425
+ }
2426
+ }
2427
+ else if (c === '?') {
2428
+ re += '[^/]';
2429
+ }
2430
+ else if (c === '{') {
2431
+ inBrace++;
2432
+ re += '(?:';
2433
+ }
2434
+ else if (c === '}' && inBrace > 0) {
2435
+ inBrace--;
2436
+ re += ')';
2437
+ }
2438
+ else if (c === ',' && inBrace > 0) {
2439
+ re += '|';
2440
+ }
2441
+ else if (c === '[') {
2442
+ const end = pattern.indexOf(']', i + 1);
2443
+ if (end > i) {
2444
+ let cls = pattern.slice(i + 1, end).replace(/\\/g, '\\\\');
2445
+ if (cls.startsWith('!'))
2446
+ cls = '^' + cls.slice(1);
2447
+ re += `[${cls}]`;
2448
+ i = end;
2449
+ }
2450
+ else {
2451
+ re += '\\[';
2452
+ }
2453
+ }
2454
+ else {
2455
+ re += c.replace(/[.+^$()|[\]\\{}]/g, '\\$&');
2456
+ }
2457
+ }
2458
+ re += ')'.repeat(inBrace); // an unclosed `{` must not throw "Invalid regular expression"
2459
+ return new RegExp(`^${re}$`);
2460
+ }
2137
2461
  const _todoScopes = new Map();
2138
2462
  function todoScope(key) {
2139
2463
  const k = key || 'default';
@@ -2392,7 +2716,7 @@ async function notebookRead(input, workDir) {
2392
2716
  });
2393
2717
  let output = lines.join('\n');
2394
2718
  if (output.length > MAX_OUTPUT_CHARS) {
2395
- output = (0, safeSlice_1.sliceSafeEnd)(output, MAX_OUTPUT_CHARS) + '\n\n[Notebook output truncated at 100KB]';
2719
+ output = (0, safeSlice_1.sliceSafeEnd)(output, MAX_OUTPUT_CHARS) + `\n\n[Notebook output truncated at ${MAX_OUTPUT_CHARS.toLocaleString('en-US')} chars]`;
2396
2720
  }
2397
2721
  return { output };
2398
2722
  }
@@ -2489,7 +2813,10 @@ function _httpsPost(url, body, headers, timeoutMs = 30000, abortSignal) {
2489
2813
  const done = (fn) => { if (settled)
2490
2814
  return; settled = true; if (abortPoll)
2491
2815
  clearInterval(abortPoll); fn(); };
2492
- const req = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST', headers: { ...headers, 'Content-Length': Buffer.byteLength(body) }, timeout: timeoutMs }, (res) => {
2816
+ // http for an http:// API base (local dev / tests) — https.request would reject it —
2817
+ // and the explicit port, which was silently dropped (so :3000 went to :443).
2818
+ const mod = u.protocol === 'http:' ? http : https;
2819
+ const req = mod.request({ hostname: u.hostname, ...(u.port ? { port: Number(u.port) } : {}), path: u.pathname + u.search, method: 'POST', headers: { ...headers, 'Content-Length': Buffer.byteLength(body) }, timeout: timeoutMs }, (res) => {
2493
2820
  res.on('data', (c) => chunks.push(c));
2494
2821
  res.on('end', () => done(() => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') })));
2495
2822
  });
@@ -2566,7 +2893,7 @@ async function generateImage(input, workDir, abortSignal) {
2566
2893
  let cost;
2567
2894
  let balance;
2568
2895
  try {
2569
- const r = await _httpsPost(`${client_1.API_BASE}/api/code/assets/generate`, JSON.stringify({ prompt, aspect_ratio: aspectRatio, teamId: (0, auth_1.getBillingTeamId)() }), { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, 170000, abortSignal);
2896
+ const r = await _httpsPost(`${client_2.API_BASE}/api/code/assets/generate`, JSON.stringify({ prompt, aspect_ratio: aspectRatio, ...(0, client_1.billingOverrideFields)() }), { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, 170000, abortSignal);
2570
2897
  let json = {};
2571
2898
  try {
2572
2899
  json = JSON.parse(r.body);
@@ -2625,7 +2952,7 @@ async function stockPhoto(input, workDir, abortSignal) {
2625
2952
  // 1. Search via the backend proxy (Unsplash key lives on the server).
2626
2953
  let photos;
2627
2954
  try {
2628
- const u = new URL(`${client_1.API_BASE}/api/code/assets/stock`);
2955
+ const u = new URL(`${client_2.API_BASE}/api/code/assets/stock`);
2629
2956
  u.searchParams.set('query', query);
2630
2957
  u.searchParams.set('count', String(count));
2631
2958
  if (orientation)
@@ -2701,7 +3028,7 @@ async function webSearch(input, _workDir, abortSignal) {
2701
3028
  if (!token)
2702
3029
  return { error: 'Not authenticated. Run `nexrall-code login` first.' };
2703
3030
  try {
2704
- const r = await _httpsPost(`${client_1.API_BASE}/api/code/tools/web_search`, JSON.stringify({ query, teamId: (0, auth_1.getBillingTeamId)() }), { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, 30000, abortSignal);
3031
+ const r = await _httpsPost(`${client_2.API_BASE}/api/code/tools/web_search`, JSON.stringify({ query, ...(0, client_1.billingOverrideFields)() }), { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, 30000, abortSignal);
2705
3032
  let json = {};
2706
3033
  try {
2707
3034
  json = JSON.parse(r.body);
@@ -2835,7 +3162,7 @@ const TOOL_MAP = {
2835
3162
  copy_file: copyFile,
2836
3163
  move_file: moveFile,
2837
3164
  delete_file: deleteFile,
2838
- fetch_url: fetchUrl,
3165
+ fetch_url: (input) => fetchUrlTool(input),
2839
3166
  web_search: webSearch,
2840
3167
  generate_image: generateImage,
2841
3168
  stock_photo: stockPhoto,
@@ -2917,7 +3244,7 @@ selfPeer) {
2917
3244
  // silently making the user wait out their own timeout (up to 170s for
2918
3245
  // generate_image) instead of returning control immediately.
2919
3246
  if (name === 'fetch_url')
2920
- return await fetchUrl(input, workDir, 0, abortSignal);
3247
+ return await fetchUrlTool(input, workDir, abortSignal);
2921
3248
  if (name === 'web_search')
2922
3249
  return await webSearch(input, workDir, abortSignal);
2923
3250
  if (name === 'generate_image')