@wrongstack/tools 0.236.0 → 0.255.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/audit.js +591 -48
  2. package/dist/audit.js.map +1 -1
  3. package/dist/background-indexer-CJ5JiV5i.d.ts +365 -0
  4. package/dist/bash.js +135 -20
  5. package/dist/bash.js.map +1 -1
  6. package/dist/builtin.js +1840 -1109
  7. package/dist/builtin.js.map +1 -1
  8. package/dist/codebase-index/index.d.ts +53 -2
  9. package/dist/codebase-index/index.js +870 -364
  10. package/dist/codebase-index/index.js.map +1 -1
  11. package/dist/codebase-index/worker.d.ts +2 -0
  12. package/dist/codebase-index/worker.js +2326 -0
  13. package/dist/codebase-index/worker.js.map +1 -0
  14. package/dist/diff.js +2 -1
  15. package/dist/diff.js.map +1 -1
  16. package/dist/exec.js +116 -5
  17. package/dist/exec.js.map +1 -1
  18. package/dist/format.js +591 -48
  19. package/dist/format.js.map +1 -1
  20. package/dist/git.js +2 -1
  21. package/dist/git.js.map +1 -1
  22. package/dist/grep.js +2 -2
  23. package/dist/grep.js.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +1189 -496
  26. package/dist/index.js.map +1 -1
  27. package/dist/install.js +591 -48
  28. package/dist/install.js.map +1 -1
  29. package/dist/lint.js +590 -47
  30. package/dist/lint.js.map +1 -1
  31. package/dist/logs.js +1 -1
  32. package/dist/logs.js.map +1 -1
  33. package/dist/outdated.js +1 -1
  34. package/dist/outdated.js.map +1 -1
  35. package/dist/pack.js +1840 -1109
  36. package/dist/pack.js.map +1 -1
  37. package/dist/patch.js +1 -1
  38. package/dist/patch.js.map +1 -1
  39. package/dist/replace.js +3 -2
  40. package/dist/replace.js.map +1 -1
  41. package/dist/test.d.ts +1 -0
  42. package/dist/test.js +605 -55
  43. package/dist/test.js.map +1 -1
  44. package/dist/typecheck.js +591 -48
  45. package/dist/typecheck.js.map +1 -1
  46. package/package.json +3 -3
  47. package/dist/background-indexer-CtbgPExj.d.ts +0 -228
package/dist/index.js CHANGED
@@ -2,28 +2,30 @@ import * as fs4 from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import { resolve, sep, dirname, join } from 'node:path';
4
4
  import * as Core from '@wrongstack/core';
5
- import { atomicWrite, unifiedDiff, detectNewlineStyle, normalizeToLf, toStyle, compileGlob, expectDefined, buildChildEnv, loadPlan, setPlanItemStatus, savePlan, loadTasks, saveTasks, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, emptyTaskFile, formatTaskList, formatPlan, recordPackageAction, detectPackageEcosystem, mutateTasks, emptyPlan, computeTaskItemProgress, resolveWstackPaths } from '@wrongstack/core';
5
+ import { atomicWrite, unifiedDiff, detectNewlineStyle, normalizeToLf, toStyle, compileGlob, expectDefined, buildChildEnv, loadPlan, setPlanItemStatus, savePlan, loadTasks, saveTasks, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, emptyTaskFile, formatTaskList, formatPlan, recordPackageAction, detectPackageEcosystem, mutateTasks, emptyPlan, computeTaskItemProgress, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
6
6
  import { spawn, execFileSync, spawnSync } from 'node:child_process';
7
7
  import * as os from 'node:os';
8
8
  import * as fs7 from 'node:fs';
9
- import { statSync, writeFileSync, mkdirSync } from 'node:fs';
9
+ import { statSync, mkdirSync, createWriteStream, writeFileSync } from 'node:fs';
10
10
  import * as dns from 'node:dns/promises';
11
11
  import * as net from 'node:net';
12
12
  import { Agent } from 'undici';
13
13
  import { createRequire } from 'node:module';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { Worker } from 'node:worker_threads';
14
16
  import * as ts from 'typescript';
15
17
  import { randomUUID } from 'node:crypto';
16
18
 
17
19
  // src/read.ts
18
20
  async function detectPackageManager(cwd) {
19
- const { stat: stat10 } = await import('node:fs/promises');
21
+ const { stat: stat11 } = await import('node:fs/promises');
20
22
  try {
21
- await stat10(`${cwd}/pnpm-lock.yaml`);
23
+ await stat11(`${cwd}/pnpm-lock.yaml`);
22
24
  return "pnpm";
23
25
  } catch {
24
26
  }
25
27
  try {
26
- await stat10(`${cwd}/yarn.lock`);
28
+ await stat11(`${cwd}/yarn.lock`);
27
29
  return "yarn";
28
30
  } catch {
29
31
  }
@@ -192,9 +194,9 @@ var readTool = {
192
194
  async execute(input, ctx) {
193
195
  if (!input?.path) throw new Error("read: path is required");
194
196
  const absPath = await safeResolveReal(input.path, ctx);
195
- let stat10;
197
+ let stat11;
196
198
  try {
197
- stat10 = await fs4.stat(absPath);
199
+ stat11 = await fs4.stat(absPath);
198
200
  } catch (err) {
199
201
  const code = err.code;
200
202
  if (code === "ENOENT") throw new Error(`read: file not found "${input.path}"`);
@@ -202,9 +204,9 @@ var readTool = {
202
204
  `read: failed to stat "${input.path}": ${err instanceof Error ? err.message : String(err)}`
203
205
  );
204
206
  }
205
- if (!stat10.isFile()) throw new Error(`read: "${input.path}" is not a regular file`);
206
- if (stat10.size > MAX_BYTES) {
207
- throw new Error(`read: file too large (${stat10.size} bytes, limit ${MAX_BYTES})`);
207
+ if (!stat11.isFile()) throw new Error(`read: "${input.path}" is not a regular file`);
208
+ if (stat11.size > MAX_BYTES) {
209
+ throw new Error(`read: file too large (${stat11.size} bytes, limit ${MAX_BYTES})`);
208
210
  }
209
211
  const buf = await fs4.readFile(absPath);
210
212
  if (isBinaryBuffer(buf)) {
@@ -216,14 +218,14 @@ var readTool = {
216
218
  const offset = Math.max(1, input.offset ?? 1);
217
219
  const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
218
220
  if (limit === 0) {
219
- ctx.recordRead(absPath, stat10.mtimeMs);
221
+ ctx.recordRead(absPath, stat11.mtimeMs);
220
222
  return { text: "", total_lines: total, encoding: "utf8", truncated: total > 0 };
221
223
  }
222
224
  const slice = allLines.slice(offset - 1, offset - 1 + limit);
223
225
  const truncated = offset - 1 + slice.length < total;
224
226
  const width = String(offset + slice.length - 1).length;
225
227
  const numbered = slice.map((line, i) => `${String(offset + i).padStart(width, " ")}\u2192${line}`).join("\n");
226
- ctx.recordRead(absPath, stat10.mtimeMs);
228
+ ctx.recordRead(absPath, stat11.mtimeMs);
227
229
  return {
228
230
  text: numbered,
229
231
  total_lines: total,
@@ -262,12 +264,12 @@ var writeTool = {
262
264
  let existed = false;
263
265
  let prev = "";
264
266
  try {
265
- const stat11 = await fs4.stat(absPath);
266
- existed = stat11.isFile();
267
+ const stat12 = await fs4.stat(absPath);
268
+ existed = stat12.isFile();
267
269
  if (existed) {
268
270
  if (!ctx.hasRead(absPath)) {
269
271
  prev = await fs4.readFile(absPath, "utf8");
270
- ctx.recordRead(absPath, stat11.mtimeMs);
272
+ ctx.recordRead(absPath, stat12.mtimeMs);
271
273
  } else {
272
274
  prev = await fs4.readFile(absPath, "utf8");
273
275
  }
@@ -280,8 +282,8 @@ var writeTool = {
280
282
  await atomicWrite(absPath, input.content);
281
283
  const diff = existed ? unifiedDiff(prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
282
284
  + (new file, ${input.content.split("\n").length} lines)`;
283
- const stat10 = await fs4.stat(absPath);
284
- ctx.recordRead(absPath, stat10.mtimeMs);
285
+ const stat11 = await fs4.stat(absPath);
286
+ ctx.recordRead(absPath, stat11.mtimeMs);
285
287
  ctx.session.recordFileChange({
286
288
  path: absPath,
287
289
  action: existed ? "modified" : "created",
@@ -321,13 +323,13 @@ var editTool = {
321
323
  if (input.new_string === void 0) throw new Error("edit: new_string is required");
322
324
  if (input.old_string === "") throw new Error("edit: old_string cannot be empty");
323
325
  const absPath = await safeResolveReal(input.path, ctx);
324
- const stat10 = await fs4.stat(absPath).catch((err) => {
326
+ const stat11 = await fs4.stat(absPath).catch((err) => {
325
327
  if (err.code === "ENOENT") {
326
328
  throw new Error(`edit: file "${input.path}" does not exist. Use \`write\` instead.`);
327
329
  }
328
330
  throw err;
329
331
  });
330
- if (!stat10.isFile()) throw new Error(`edit: "${input.path}" is not a regular file`);
332
+ if (!stat11.isFile()) throw new Error(`edit: "${input.path}" is not a regular file`);
331
333
  if (!ctx.hasRead(absPath)) {
332
334
  throw new Error(`edit: file "${input.path}" was not read in this session. Read it first.`);
333
335
  }
@@ -522,8 +524,8 @@ var replaceTool = {
522
524
  }
523
525
  const rel = path.relative(realRoot, realPath);
524
526
  if (rel.startsWith("..") || path.isAbsolute(rel)) continue;
525
- const stat10 = await fs4.stat(realPath).catch(() => null);
526
- if (!stat10 || !stat10.isFile()) continue;
527
+ const stat11 = await fs4.stat(realPath).catch(() => null);
528
+ if (!stat11 || !stat11.isFile()) continue;
527
529
  let content;
528
530
  try {
529
531
  const buf = await fs4.readFile(realPath);
@@ -548,7 +550,7 @@ var replaceTool = {
548
550
  totalReplacements += count;
549
551
  if (!dryRun) {
550
552
  const newContent = toStyle(newContentLf, style);
551
- await atomicWrite(realPath, newContent, { mode: stat10.mode & 511 });
553
+ await atomicWrite(realPath, newContent, { mode: stat11.mode & 511 });
552
554
  }
553
555
  const diff = dryRun || matches.length > 0 ? unifiedDiff(content, toStyle(newContentLf, style), {
554
556
  fromFile: absPath,
@@ -578,8 +580,8 @@ async function resolveFiles(filesInput, ctx, extraGlob) {
578
580
  const resolved = [];
579
581
  for (const p of parts) {
580
582
  const absPath = safeResolve(p, ctx);
581
- const stat10 = await fs4.stat(absPath).catch(() => null);
582
- if (stat10?.isFile()) {
583
+ const stat11 = await fs4.stat(absPath).catch(() => null);
584
+ if (stat11?.isFile()) {
583
585
  resolved.push(absPath);
584
586
  }
585
587
  }
@@ -599,7 +601,7 @@ async function globFiles(pattern, base, extraGlob) {
599
601
  function checkRg() {
600
602
  return new Promise((resolve7) => {
601
603
  try {
602
- const p = spawn("rg", ["--version"], { env: buildChildEnv(), stdio: "ignore" });
604
+ const p = spawn("rg", ["--version"], { env: buildChildEnv(), stdio: "ignore", windowsHide: true });
603
605
  p.on("error", () => resolve7(false));
604
606
  p.on("close", (code) => resolve7(code === 0));
605
607
  } catch {
@@ -612,7 +614,8 @@ function spawnRgFind(pattern, base) {
612
614
  const child = spawn("rg", args, {
613
615
  signal: AbortSignal.timeout(3e4),
614
616
  env: buildChildEnv(),
615
- stdio: ["ignore", "pipe", "pipe"]
617
+ stdio: ["ignore", "pipe", "pipe"],
618
+ windowsHide: true
616
619
  });
617
620
  let buf = "";
618
621
  child.stdout?.on("data", (chunk) => {
@@ -641,8 +644,8 @@ async function globNative(pattern, base, extraGlob) {
641
644
  if (DEFAULT_IGNORE.includes(e.name)) continue;
642
645
  const full = path.join(dir, e.name);
643
646
  try {
644
- const stat10 = await fs4.lstat(full);
645
- if (stat10.isSymbolicLink()) continue;
647
+ const stat11 = await fs4.lstat(full);
648
+ if (stat11.isSymbolicLink()) continue;
646
649
  } catch {
647
650
  continue;
648
651
  }
@@ -828,7 +831,7 @@ var grepTool = {
828
831
  async function detectRg(signal) {
829
832
  return new Promise((resolve7) => {
830
833
  try {
831
- const p = spawn("rg", ["--version"], { env: buildChildEnv(), stdio: "ignore", signal });
834
+ const p = spawn("rg", ["--version"], { env: buildChildEnv(), stdio: "ignore", signal, windowsHide: true });
832
835
  p.on("error", () => resolve7(false));
833
836
  p.on("close", (code) => resolve7(code === 0));
834
837
  } catch {
@@ -858,7 +861,7 @@ async function* runRgStream(input, base, mode, limit, signal) {
858
861
  const FLUSH_AT = 16;
859
862
  const MAX_BUF_BYTES = 1e6;
860
863
  let bufOverflow = false;
861
- const child = spawn("rg", args, { signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"] });
864
+ const child = spawn("rg", args, { signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
862
865
  const queue = [];
863
866
  let waiter;
864
867
  const wake = () => {
@@ -993,8 +996,8 @@ async function runNative(input, base, mode, limit, signal) {
993
996
  if (globRe && !globRe.test(e.name) && !globRe.test(full)) continue;
994
997
  if (globRe) globRe.lastIndex = 0;
995
998
  try {
996
- const stat10 = await fs4.stat(full);
997
- if (stat10.size > 1e6) continue;
999
+ const stat11 = await fs4.stat(full);
1000
+ if (stat11.size > 1e6) continue;
998
1001
  const head = await fs4.readFile(full);
999
1002
  if (isBinaryBuffer(head)) continue;
1000
1003
  const text = head.toString("utf8");
@@ -1034,6 +1037,107 @@ async function runNative(input, base, mode, limit, signal) {
1034
1037
  used: "native"
1035
1038
  };
1036
1039
  }
1040
+ var SPOOL_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
1041
+ var SPOOL_WRITE_HWM_BYTES = 4 * 1024 * 1024;
1042
+ var sweepStarted = false;
1043
+ function toolOutputDir() {
1044
+ return path.join(wstackGlobalRoot(), "tool-output");
1045
+ }
1046
+ function sweepOldSpoolFiles(dir) {
1047
+ if (sweepStarted) return;
1048
+ sweepStarted = true;
1049
+ void (async () => {
1050
+ try {
1051
+ const now = Date.now();
1052
+ for (const name of await fs4.readdir(dir)) {
1053
+ if (!name.endsWith(".log")) continue;
1054
+ const p = path.join(dir, name);
1055
+ try {
1056
+ const st = await fs4.stat(p);
1057
+ if (now - st.mtimeMs > SPOOL_RETENTION_MS) await fs4.unlink(p);
1058
+ } catch {
1059
+ }
1060
+ }
1061
+ } catch {
1062
+ }
1063
+ })();
1064
+ }
1065
+ function spoolNote(info) {
1066
+ const dropped = info.droppedBytes > 0 ? `, ~${info.droppedBytes} bytes dropped under backpressure` : "";
1067
+ return `
1068
+ [output truncated \u2014 full ${info.bytes} bytes at ${info.path}${dropped}; read/grep that file selectively instead of re-running with more output]`;
1069
+ }
1070
+ function createOutputSpool(opts) {
1071
+ const threshold = opts.thresholdBytes ?? 32768;
1072
+ const safeTool = opts.tool.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 40) || "tool";
1073
+ let head = "";
1074
+ let headBytes = 0;
1075
+ let totalBytes = 0;
1076
+ let droppedBytes = 0;
1077
+ let stream = null;
1078
+ let filePath = null;
1079
+ let failed = false;
1080
+ let finalized = false;
1081
+ const open = () => {
1082
+ if (stream || failed) return;
1083
+ try {
1084
+ const dir = toolOutputDir();
1085
+ mkdirSync(dir, { recursive: true });
1086
+ sweepOldSpoolFiles(dir);
1087
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1088
+ const rand = Math.random().toString(36).slice(2, 6);
1089
+ filePath = path.join(dir, `${stamp}-${safeTool}-${rand}.log`);
1090
+ stream = createWriteStream(filePath, { flags: "w", encoding: "utf8" });
1091
+ stream.on("error", () => {
1092
+ failed = true;
1093
+ stream = null;
1094
+ filePath = null;
1095
+ });
1096
+ stream.write(head);
1097
+ } catch {
1098
+ failed = true;
1099
+ stream = null;
1100
+ filePath = null;
1101
+ }
1102
+ };
1103
+ return {
1104
+ write(text) {
1105
+ if (finalized || !text) return;
1106
+ totalBytes += Buffer.byteLength(text, "utf8");
1107
+ if (!stream && !failed) {
1108
+ if (headBytes + text.length <= threshold) {
1109
+ head += text;
1110
+ headBytes += text.length;
1111
+ return;
1112
+ }
1113
+ head += text;
1114
+ open();
1115
+ head = "";
1116
+ return;
1117
+ }
1118
+ if (stream) {
1119
+ if (stream.writableLength > SPOOL_WRITE_HWM_BYTES) {
1120
+ droppedBytes += Buffer.byteLength(text, "utf8");
1121
+ return;
1122
+ }
1123
+ stream.write(text);
1124
+ }
1125
+ },
1126
+ finalize() {
1127
+ if (finalized) {
1128
+ return filePath ? { path: filePath, bytes: totalBytes, droppedBytes } : null;
1129
+ }
1130
+ finalized = true;
1131
+ head = "";
1132
+ if (!stream || !filePath) return null;
1133
+ try {
1134
+ stream.end();
1135
+ } catch {
1136
+ }
1137
+ return { path: filePath, bytes: totalBytes, droppedBytes };
1138
+ }
1139
+ };
1140
+ }
1037
1141
 
1038
1142
  // src/circuit-breaker.ts
1039
1143
  var DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
@@ -1513,7 +1617,7 @@ var bashTool = {
1513
1617
  })();
1514
1618
  const args = isWin ? ["/c", input.command] : ["-c", input.command];
1515
1619
  const env = buildChildEnv(ctx.session?.id);
1516
- const detached = isWin ? !!input.background : true;
1620
+ const detached = !isWin;
1517
1621
  const startedAt = Date.now();
1518
1622
  if (input.background) {
1519
1623
  let buf2 = "";
@@ -1522,7 +1626,15 @@ var bashTool = {
1522
1626
  cwd: ctx.projectRoot,
1523
1627
  env,
1524
1628
  stdio: ["ignore", "pipe", "pipe"],
1525
- detached: true,
1629
+ // win32: CreateProcess IGNORES CREATE_NO_WINDOW (windowsHide) when
1630
+ // DETACHED_PROCESS (detached: true) is set, so the console-less
1631
+ // cmd.exe's grandchildren (node, dev servers) each allocate a fresh
1632
+ // VISIBLE console window. detached: false lets CREATE_NO_WINDOW
1633
+ // apply: the child gets a hidden console that grandchildren inherit.
1634
+ // Windows children survive parent exit either way. POSIX keeps
1635
+ // detached for the process-group kill semantics.
1636
+ detached: !isWin,
1637
+ windowsHide: true,
1526
1638
  signal: opts.signal
1527
1639
  });
1528
1640
  const pid2 = child2.pid;
@@ -1537,24 +1649,22 @@ var bashTool = {
1537
1649
  });
1538
1650
  child2.on("close", () => registry.unregister(pid2));
1539
1651
  }
1540
- child2.stdout?.on("data", (chunk) => {
1541
- if (!truncated) {
1542
- const remain = MAX_OUTPUT - buf2.length;
1543
- if (remain > 0) {
1544
- buf2 += chunk.toString().slice(0, remain);
1545
- }
1546
- if (buf2.length >= MAX_OUTPUT) truncated = true;
1652
+ const onBgData = (chunk) => {
1653
+ if (truncated) return;
1654
+ const remain = MAX_OUTPUT - buf2.length;
1655
+ if (remain > 0) {
1656
+ buf2 += chunk.toString().slice(0, remain);
1547
1657
  }
1548
- });
1549
- child2.stderr?.on("data", (chunk) => {
1550
- if (!truncated) {
1551
- const remain = MAX_OUTPUT - buf2.length;
1552
- if (remain > 0) {
1553
- buf2 += chunk.toString().slice(0, remain);
1554
- }
1555
- if (buf2.length >= MAX_OUTPUT) truncated = true;
1658
+ if (buf2.length >= MAX_OUTPUT) {
1659
+ truncated = true;
1660
+ child2.stdout?.off("data", onBgData);
1661
+ child2.stderr?.off("data", onBgData);
1556
1662
  }
1557
- });
1663
+ };
1664
+ child2.stdout?.on("data", onBgData);
1665
+ child2.stderr?.on("data", onBgData);
1666
+ child2.stdout?.unref?.();
1667
+ child2.stderr?.unref?.();
1558
1668
  child2.on("close", () => {
1559
1669
  registry.afterCall(Date.now() - startedAt, false, bypassBreaker);
1560
1670
  });
@@ -1575,6 +1685,7 @@ var bashTool = {
1575
1685
  env,
1576
1686
  stdio: ["ignore", "pipe", "pipe"],
1577
1687
  detached,
1688
+ windowsHide: true,
1578
1689
  ...isWin ? {} : { signal: opts.signal }
1579
1690
  });
1580
1691
  const pid = child.pid;
@@ -1589,9 +1700,10 @@ var bashTool = {
1589
1700
  });
1590
1701
  }
1591
1702
  let buf = "";
1592
- let pending = "";
1703
+ let pending2 = "";
1593
1704
  let timedOut = false;
1594
1705
  const timers = [];
1706
+ const spool = createOutputSpool({ tool: "bash", thresholdBytes: MAX_OUTPUT });
1595
1707
  function killWithTimeout(child2, timeoutMs2) {
1596
1708
  if (isWin) {
1597
1709
  if (typeof child2.pid === "number" && child2.exitCode === null && killWin32Tree(child2.pid)) {
@@ -1671,9 +1783,9 @@ var bashTool = {
1671
1783
  });
1672
1784
  let lastFlush = Date.now();
1673
1785
  const flush = () => {
1674
- if (pending.length === 0) return null;
1675
- const text = pending;
1676
- pending = "";
1786
+ if (pending2.length === 0) return null;
1787
+ const text = pending2;
1788
+ pending2 = "";
1677
1789
  lastFlush = Date.now();
1678
1790
  return text;
1679
1791
  };
@@ -1697,7 +1809,8 @@ var bashTool = {
1697
1809
  if (buf.length < MAX_OUTPUT) {
1698
1810
  buf += text.slice(0, MAX_OUTPUT - buf.length);
1699
1811
  }
1700
- pending += text;
1812
+ spool.write(text);
1813
+ pending2 += text;
1701
1814
  push({ kind: "data", text });
1702
1815
  pauseIfFlooded();
1703
1816
  };
@@ -1724,10 +1837,11 @@ var bashTool = {
1724
1837
  if (remainder !== null) {
1725
1838
  yield { type: "partial_output", text: remainder };
1726
1839
  }
1840
+ const spooled = spool.finalize();
1727
1841
  yield {
1728
1842
  type: "final",
1729
1843
  output: {
1730
- output: normalizeCommandOutput(buf),
1844
+ output: normalizeCommandOutput(buf) + (spooled ? spoolNote(spooled) : ""),
1731
1845
  exit_code: c.code,
1732
1846
  timed_out: timedOut
1733
1847
  }
@@ -1735,13 +1849,14 @@ var bashTool = {
1735
1849
  return;
1736
1850
  }
1737
1851
  const now = Date.now();
1738
- if (pending.length >= STREAM_FLUSH_BYTES || now - lastFlush >= STREAM_FLUSH_INTERVAL_MS) {
1852
+ if (pending2.length >= STREAM_FLUSH_BYTES || now - lastFlush >= STREAM_FLUSH_INTERVAL_MS) {
1739
1853
  const text = flush();
1740
1854
  if (text) yield { type: "partial_output", text };
1741
1855
  }
1742
1856
  }
1743
1857
  } finally {
1744
1858
  for (const t of timers) clearTimeout(t);
1859
+ spool.finalize();
1745
1860
  if (isWin) opts.signal.removeEventListener("abort", onAbort);
1746
1861
  child.stdout?.off("data", onData);
1747
1862
  child.stderr?.off("data", onData);
@@ -1982,6 +2097,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
1982
2097
  let stderr = "";
1983
2098
  let killed = false;
1984
2099
  const startedAt = Date.now();
2100
+ const spool = createOutputSpool({ tool: `exec-${cmd}`, thresholdBytes: MAX_OUTPUT2 });
1985
2101
  const resolved = resolveWin32Command(cmd);
1986
2102
  const isWin = process.platform === "win32";
1987
2103
  const needsShell = isWin && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
@@ -1989,6 +2105,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
1989
2105
  cwd,
1990
2106
  env: buildChildEnv(sessionId),
1991
2107
  stdio: ["ignore", "pipe", "pipe"],
2108
+ windowsHide: true,
1992
2109
  ...isWin ? {} : { signal },
1993
2110
  ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {}
1994
2111
  });
@@ -2013,10 +2130,14 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
2013
2130
  else signal.addEventListener("abort", onAbort, { once: true });
2014
2131
  }
2015
2132
  child.stdout?.on("data", (chunk) => {
2016
- if (stdout.length < MAX_OUTPUT2) stdout += chunk.toString();
2133
+ const text = chunk.toString();
2134
+ if (stdout.length < MAX_OUTPUT2) stdout += text;
2135
+ spool.write(text);
2017
2136
  });
2018
2137
  child.stderr?.on("data", (chunk) => {
2019
- if (stderr.length < MAX_OUTPUT2) stderr += chunk.toString();
2138
+ const text = chunk.toString();
2139
+ if (stderr.length < MAX_OUTPUT2) stderr += text;
2140
+ spool.write(text);
2020
2141
  });
2021
2142
  child.on("close", (code) => {
2022
2143
  clearTimeout(timer);
@@ -2025,10 +2146,11 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
2025
2146
  const durationMs = Date.now() - startedAt;
2026
2147
  const exitCode = killed ? 124 : code ?? 1;
2027
2148
  registry.afterCall(durationMs, exitCode !== 0);
2149
+ const spooled = spool.finalize();
2028
2150
  resolve7({
2029
2151
  command: cmd,
2030
2152
  args,
2031
- stdout: normalizeCommandOutput(stdout),
2153
+ stdout: normalizeCommandOutput(stdout) + (spooled ? spoolNote(spooled) : ""),
2032
2154
  stderr: normalizeCommandOutput(stderr),
2033
2155
  exitCode,
2034
2156
  truncated: Buffer.byteLength(stdout, "utf8") > COMMAND_OUTPUT_MAX_BYTES || Buffer.byteLength(stderr, "utf8") > COMMAND_OUTPUT_MAX_BYTES,
@@ -2040,6 +2162,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
2040
2162
  if (isWin) signal.removeEventListener("abort", onAbort);
2041
2163
  if (typeof pid === "number") registry.unregister(pid);
2042
2164
  registry.afterCall(Date.now() - startedAt, true);
2165
+ spool.finalize();
2043
2166
  resolve7({
2044
2167
  command: cmd,
2045
2168
  args,
@@ -3062,8 +3185,8 @@ function findGitDir(cwd, projectRoot) {
3062
3185
  let dir = cwd;
3063
3186
  for (let i = 0; i < 20; i++) {
3064
3187
  try {
3065
- const stat10 = statSync(`${dir}/.git`);
3066
- if (stat10.isDirectory() || stat10.isFile()) return dir;
3188
+ const stat11 = statSync(`${dir}/.git`);
3189
+ if (stat11.isDirectory() || stat11.isFile()) return dir;
3067
3190
  } catch {
3068
3191
  }
3069
3192
  if (dir === root) break;
@@ -3151,7 +3274,8 @@ function runGit(args, cwd, signal) {
3151
3274
  cwd,
3152
3275
  signal,
3153
3276
  env: buildChildEnv(),
3154
- stdio: ["ignore", "pipe", "pipe"]
3277
+ stdio: ["ignore", "pipe", "pipe"],
3278
+ windowsHide: true
3155
3279
  });
3156
3280
  child.stdout?.on("data", (chunk) => {
3157
3281
  if (stdout.length < MAX_OUTPUT3) {
@@ -3274,7 +3398,7 @@ function runPatch(args, cwd, signal) {
3274
3398
  let stdout = "";
3275
3399
  let stderr = "";
3276
3400
  const env = { ...buildChildEnv(), LANG: "C", LC_ALL: "C" };
3277
- const child = spawn("patch", args, { cwd, signal, env, stdio: ["pipe", "pipe", "pipe"] });
3401
+ const child = spawn("patch", args, { cwd, signal, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
3278
3402
  child.stdout?.on("data", (c) => {
3279
3403
  stdout += c.toString();
3280
3404
  });
@@ -3365,8 +3489,8 @@ var jsonTool = {
3365
3489
  };
3366
3490
  }
3367
3491
  };
3368
- function query(data, path20) {
3369
- const parts = path20.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
3492
+ function query(data, path21) {
3493
+ const parts = path21.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
3370
3494
  let current = data;
3371
3495
  for (const part of parts) {
3372
3496
  if (current === null || current === void 0) return void 0;
@@ -3495,8 +3619,8 @@ function findGitDir2(cwd) {
3495
3619
  let dir = cwd;
3496
3620
  for (let i = 0; i < 20; i++) {
3497
3621
  try {
3498
- const stat10 = statSync(path.join(dir, ".git"));
3499
- if (stat10.isDirectory()) return dir;
3622
+ const stat11 = statSync(path.join(dir, ".git"));
3623
+ if (stat11.isDirectory()) return dir;
3500
3624
  } catch {
3501
3625
  }
3502
3626
  const parent = path.dirname(dir);
@@ -3513,7 +3637,8 @@ function runGit2(args, cwd, signal) {
3513
3637
  cwd,
3514
3638
  signal,
3515
3639
  env: buildChildEnv(),
3516
- stdio: ["ignore", "pipe", "pipe"]
3640
+ stdio: ["ignore", "pipe", "pipe"],
3641
+ windowsHide: true
3517
3642
  });
3518
3643
  child.stdout?.on("data", (c) => {
3519
3644
  stdout += c.toString();
@@ -3539,8 +3664,8 @@ async function fileDiff(input, ctx, _signal) {
3539
3664
  const results = [];
3540
3665
  for (const file of files) {
3541
3666
  const absPath = safeResolve(file, ctx);
3542
- const stat10 = await fs4.stat(absPath).catch(() => null);
3543
- if (!stat10?.isFile()) continue;
3667
+ const stat11 = await fs4.stat(absPath).catch(() => null);
3668
+ if (!stat11?.isFile()) continue;
3544
3669
  const content = await fs4.readFile(absPath, "utf8");
3545
3670
  const lines = content.split(/\r?\n/);
3546
3671
  results.push(formatWithLineNumbers(file, lines));
@@ -3741,17 +3866,31 @@ async function* spawnStream(opts) {
3741
3866
  const maxQueue = opts.maxQueueSize ?? 500;
3742
3867
  let stdout = "";
3743
3868
  let stderr = "";
3744
- let pending = "";
3869
+ let pending2 = "";
3745
3870
  let error;
3871
+ const spool = createOutputSpool({ tool: opts.cmd, thresholdBytes: max });
3746
3872
  const cmd = resolveWin32Command(opts.cmd);
3747
- const needsShell = process.platform === "win32" && (cmd.endsWith(".cmd") || cmd.endsWith(".bat"));
3873
+ const isWin = process.platform === "win32";
3874
+ const needsShell = isWin && (cmd.endsWith(".cmd") || cmd.endsWith(".bat"));
3748
3875
  const child = spawn(cmd, opts.args, {
3749
3876
  cwd: opts.cwd,
3750
- signal: opts.signal,
3751
3877
  env: buildChildEnv(),
3752
3878
  stdio: ["ignore", "pipe", "pipe"],
3879
+ windowsHide: true,
3880
+ ...isWin ? {} : { signal: opts.signal },
3753
3881
  ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {}
3754
3882
  });
3883
+ const registry = getProcessRegistry();
3884
+ const pid = child.pid;
3885
+ if (typeof pid === "number") {
3886
+ registry.register({
3887
+ pid,
3888
+ name: opts.cmd,
3889
+ command: redactCommand(`${opts.cmd} ${opts.args.join(" ")}`),
3890
+ startedAt: Date.now(),
3891
+ child
3892
+ });
3893
+ }
3755
3894
  const queue = [];
3756
3895
  let waiter;
3757
3896
  let paused = false;
@@ -3769,9 +3908,10 @@ async function* spawnStream(opts) {
3769
3908
  child.stderr?.resume();
3770
3909
  }
3771
3910
  };
3772
- child.stdout?.on("data", (c) => {
3911
+ const onOut = (c) => {
3773
3912
  const s = c.toString();
3774
3913
  if (stdout.length < max) stdout += s;
3914
+ spool.write(s);
3775
3915
  queue.push({ kind: "out", data: s });
3776
3916
  wake();
3777
3917
  if (!paused && queue.length >= maxQueue) {
@@ -3779,10 +3919,11 @@ async function* spawnStream(opts) {
3779
3919
  child.stdout?.pause();
3780
3920
  child.stderr?.pause();
3781
3921
  }
3782
- });
3783
- child.stderr?.on("data", (c) => {
3922
+ };
3923
+ const onErr = (c) => {
3784
3924
  const s = c.toString();
3785
3925
  if (stderr.length < max) stderr += s;
3926
+ spool.write(s);
3786
3927
  queue.push({ kind: "err", data: s });
3787
3928
  wake();
3788
3929
  if (!paused && queue.length >= maxQueue) {
@@ -3790,51 +3931,92 @@ async function* spawnStream(opts) {
3790
3931
  child.stdout?.pause();
3791
3932
  child.stderr?.pause();
3792
3933
  }
3793
- });
3934
+ };
3935
+ child.stdout?.on("data", onOut);
3936
+ child.stderr?.on("data", onErr);
3794
3937
  child.on("error", (e) => {
3795
3938
  error = e.message;
3796
3939
  queue.push({ kind: "error", data: e.message });
3797
3940
  wake();
3798
3941
  });
3799
3942
  child.on("close", (code) => {
3943
+ if (typeof pid === "number") registry.unregister(pid);
3800
3944
  queue.push({ kind: "close", data: "", code: code ?? 0 });
3801
3945
  wake();
3802
3946
  });
3947
+ const onAbort = () => {
3948
+ if (typeof pid === "number") {
3949
+ registry.kill(pid, { force: true });
3950
+ } else {
3951
+ try {
3952
+ child.kill("SIGKILL");
3953
+ } catch {
3954
+ }
3955
+ }
3956
+ queue.push({ kind: "close", data: "", code: 124 });
3957
+ wake();
3958
+ };
3959
+ if (opts.signal.aborted) onAbort();
3960
+ else opts.signal.addEventListener("abort", onAbort, { once: true });
3803
3961
  let exitCode = 0;
3804
3962
  let spawnFailed = false;
3805
- for (; ; ) {
3806
- while (queue.length === 0) {
3807
- await new Promise((resolve7) => {
3808
- waiter = resolve7;
3809
- });
3810
- }
3811
- const chunk = queue.shift();
3812
- resume();
3813
- if (chunk.kind === "close") {
3814
- if (!spawnFailed) exitCode = chunk.code ?? 0;
3815
- break;
3963
+ try {
3964
+ for (; ; ) {
3965
+ while (queue.length === 0) {
3966
+ await new Promise((resolve7) => {
3967
+ waiter = resolve7;
3968
+ });
3969
+ }
3970
+ const chunk = queue.shift();
3971
+ resume();
3972
+ if (chunk.kind === "close") {
3973
+ if (!spawnFailed) exitCode = chunk.code ?? 0;
3974
+ break;
3975
+ }
3976
+ if (chunk.kind === "error") {
3977
+ spawnFailed = true;
3978
+ exitCode = 1;
3979
+ continue;
3980
+ }
3981
+ pending2 += chunk.data;
3982
+ if (pending2.length >= flushAt) {
3983
+ yield { type: "partial_output", text: pending2 };
3984
+ pending2 = "";
3985
+ }
3816
3986
  }
3817
- if (chunk.kind === "error") {
3818
- spawnFailed = true;
3819
- exitCode = 1;
3820
- continue;
3987
+ if (pending2.length > 0) {
3988
+ yield { type: "partial_output", text: pending2 };
3821
3989
  }
3822
- pending += chunk.data;
3823
- if (pending.length >= flushAt) {
3824
- yield { type: "partial_output", text: pending };
3825
- pending = "";
3990
+ const spooled = spool.finalize();
3991
+ return {
3992
+ // The marker rides on stdout's tail so every consumer's head+tail
3993
+ // normalization keeps it without per-tool changes.
3994
+ stdout: spooled ? stdout + spoolNote(spooled) : stdout,
3995
+ stderr,
3996
+ exitCode,
3997
+ truncated: stdout.length >= max || stderr.length >= max,
3998
+ error,
3999
+ spoolPath: spooled?.path,
4000
+ spoolBytes: spooled?.bytes
4001
+ };
4002
+ } finally {
4003
+ spool.finalize();
4004
+ opts.signal.removeEventListener("abort", onAbort);
4005
+ child.stdout?.off("data", onOut);
4006
+ child.stderr?.off("data", onErr);
4007
+ child.stdout?.destroy();
4008
+ child.stderr?.destroy();
4009
+ if (child.exitCode === null && !child.killed) {
4010
+ if (typeof pid === "number") {
4011
+ registry.kill(pid, { force: true });
4012
+ } else {
4013
+ try {
4014
+ child.kill("SIGKILL");
4015
+ } catch {
4016
+ }
4017
+ }
3826
4018
  }
3827
4019
  }
3828
- if (pending.length > 0) {
3829
- yield { type: "partial_output", text: pending };
3830
- }
3831
- return {
3832
- stdout,
3833
- stderr,
3834
- exitCode,
3835
- truncated: stdout.length >= max || stderr.length >= max,
3836
- error
3837
- };
3838
4020
  }
3839
4021
 
3840
4022
  // src/lint.ts
@@ -3917,11 +4099,11 @@ var lintTool = {
3917
4099
  }
3918
4100
  };
3919
4101
  async function detectLinter(cwd) {
3920
- const { stat: stat10 } = await import('node:fs/promises');
4102
+ const { stat: stat11 } = await import('node:fs/promises');
3921
4103
  const checks = ["biome.json", ".eslintrc.json", "tslint.json", ".eslintrc.js", "tsconfig.json"];
3922
4104
  for (const f of checks) {
3923
4105
  try {
3924
- await stat10(`${cwd}/${f}`);
4106
+ await stat11(`${cwd}/${f}`);
3925
4107
  if (f.includes("biome")) return "biome";
3926
4108
  if (f.includes("eslint")) return "eslint";
3927
4109
  if (f.includes("tslint")) return "tslint";
@@ -4019,13 +4201,13 @@ var formatTool = {
4019
4201
  }
4020
4202
  };
4021
4203
  async function detectFixer(cwd) {
4022
- const { stat: stat10 } = await import('node:fs/promises');
4204
+ const { stat: stat11 } = await import('node:fs/promises');
4023
4205
  try {
4024
- await stat10(`${cwd}/biome.json`);
4206
+ await stat11(`${cwd}/biome.json`);
4025
4207
  return "biome";
4026
4208
  } catch {
4027
4209
  try {
4028
- await stat10(`${cwd}/.prettierrc`);
4210
+ await stat11(`${cwd}/.prettierrc`);
4029
4211
  return "prettier";
4030
4212
  } catch {
4031
4213
  return "biome";
@@ -4103,11 +4285,11 @@ var typecheckTool = {
4103
4285
  }
4104
4286
  };
4105
4287
  async function findTsConfig(cwd) {
4106
- const { stat: stat10 } = await import('node:fs/promises');
4288
+ const { stat: stat11 } = await import('node:fs/promises');
4107
4289
  const candidates = ["tsconfig.json", "tsconfig.base.json"];
4108
4290
  for (const f of candidates) {
4109
4291
  try {
4110
- const s = await stat10(path.join(cwd, f));
4292
+ const s = await stat11(path.join(cwd, f));
4111
4293
  if (s.isFile()) return path.join(cwd, f);
4112
4294
  } catch {
4113
4295
  }
@@ -4138,7 +4320,11 @@ var testTool = {
4138
4320
  coverage: { type: "boolean", description: "Generate coverage report (default: false)" },
4139
4321
  cwd: { type: "string", description: "Working directory (default: cwd)" },
4140
4322
  grep: { type: "string", description: "Filter tests by name pattern (default: none)" },
4141
- timeout: { type: "integer", description: "Test timeout in ms (default: 30000)" }
4323
+ timeout: { type: "integer", description: "Test timeout in ms (default: 30000)" },
4324
+ verbose: {
4325
+ type: "boolean",
4326
+ description: "Per-test verbose reporter output (default: false \u2014 the summary reporter is used; full output is always saved to a log file referenced in the result)"
4327
+ }
4142
4328
  }
4143
4329
  },
4144
4330
  async execute(input, ctx, opts) {
@@ -4186,11 +4372,11 @@ var testTool = {
4186
4372
  }
4187
4373
  };
4188
4374
  async function detectRunner(cwd) {
4189
- const { stat: stat10 } = await import('node:fs/promises');
4375
+ const { stat: stat11 } = await import('node:fs/promises');
4190
4376
  const candidates = ["vitest.config.ts", "jest.config.js", ".mocharc.json"];
4191
4377
  for (const f of candidates) {
4192
4378
  try {
4193
- await stat10(path.join(cwd, f));
4379
+ await stat11(path.join(cwd, f));
4194
4380
  if (f.includes("vitest")) return "vitest";
4195
4381
  if (f.includes("jest")) return "jest";
4196
4382
  if (f.includes("mocha")) return "mocha";
@@ -4204,17 +4390,14 @@ function buildArgs2(runner, input) {
4204
4390
  const timeout = input.timeout ?? 3e4;
4205
4391
  switch (runner) {
4206
4392
  case "vitest":
4207
- args.push("run", "--reporter=verbose");
4208
- if (input.watch) {
4209
- args[1] = "";
4210
- args.push("watch");
4211
- }
4393
+ args.push(input.watch ? "watch" : "run");
4394
+ if (input.verbose) args.push("--reporter=verbose");
4212
4395
  if (input.coverage) args.push("--coverage");
4213
4396
  if (input.grep) args.push("--testNamePattern", input.grep);
4214
4397
  args.push("--testTimeout", String(timeout));
4215
4398
  break;
4216
4399
  case "jest":
4217
- args.push("--verbose");
4400
+ if (input.verbose) args.push("--verbose");
4218
4401
  if (input.watch) args.push("--watch");
4219
4402
  if (input.coverage) args.push("--coverage");
4220
4403
  if (input.grep) args.push("--testPathPattern", input.grep);
@@ -4258,7 +4441,13 @@ function parseResult(runner, result, duration) {
4258
4441
  passed,
4259
4442
  failed,
4260
4443
  duration_ms: duration,
4261
- output: normalizeCommandOutput(result.stdout || result.error || ""),
4444
+ // A passing run only needs the tail summary in chat history — counts are
4445
+ // already parsed above and the FULL log is on disk (spool marker rides
4446
+ // the stdout tail). Failures keep the standard command-output cap so
4447
+ // the agent sees the failure details inline.
4448
+ output: normalizeCommandOutput(result.stdout || result.error || "", {
4449
+ maxBytes: result.exitCode === 0 ? 4096 : void 0
4450
+ }),
4262
4451
  truncated: result.truncated
4263
4452
  };
4264
4453
  }
@@ -4539,7 +4728,7 @@ function runOutdated(manager, args, cwd, signal) {
4539
4728
  const MAX = 1e5;
4540
4729
  const resolved = resolveWin32Command(manager);
4541
4730
  const needsShell = process.platform === "win32" && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
4542
- const child = spawn(resolved, args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {} });
4731
+ const child = spawn(resolved, args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true, ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {} });
4543
4732
  child.stdout?.on("data", (c) => {
4544
4733
  if (stdout.length < MAX) stdout += c.toString();
4545
4734
  });
@@ -4693,7 +4882,7 @@ async function dockerLogs(service, lines, filterRe, cwd, signal, since) {
4693
4882
  clearTimeout(timer);
4694
4883
  resolve7(result);
4695
4884
  };
4696
- const child = spawn("docker", args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"] });
4885
+ const child = spawn("docker", args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
4697
4886
  const timer = setTimeout(() => {
4698
4887
  child.kill("SIGTERM");
4699
4888
  finish(empty());
@@ -4724,7 +4913,7 @@ async function dockerLogs(service, lines, filterRe, cwd, signal, since) {
4724
4913
  }
4725
4914
  var DOCKER_LOGS_TIMEOUT_MS = 3e3;
4726
4915
  var MAX_TAIL_LINES = 1e5;
4727
- async function fileLogs(path20, lines, filterRe, stream) {
4916
+ async function fileLogs(path21, lines, filterRe, stream) {
4728
4917
  const { createInterface } = await import('node:readline');
4729
4918
  const { createReadStream } = await import('node:fs');
4730
4919
  const entries = [];
@@ -4733,7 +4922,7 @@ async function fileLogs(path20, lines, filterRe, stream) {
4733
4922
  let writeIdx = 0;
4734
4923
  let totalLines = 0;
4735
4924
  const rl = createInterface({
4736
- input: createReadStream(path20),
4925
+ input: createReadStream(path21),
4737
4926
  crlfDelay: Number.POSITIVE_INFINITY
4738
4927
  });
4739
4928
  for await (const line of rl) {
@@ -4754,7 +4943,7 @@ async function fileLogs(path20, lines, filterRe, stream) {
4754
4943
  if (parsed) entries.push(parsed);
4755
4944
  }
4756
4945
  return {
4757
- source: path20,
4946
+ source: path21,
4758
4947
  entries,
4759
4948
  total: entries.length,
4760
4949
  truncated: totalLines > effLines,
@@ -4877,8 +5066,8 @@ async function resolveFiles2(filesInput, cwd) {
4877
5066
  for (const f of files) {
4878
5067
  const absPath = f.trim().startsWith("/") ? f.trim() : `${cwd}/${f.trim()}`;
4879
5068
  try {
4880
- const stat10 = await fs4.stat(absPath);
4881
- if (stat10.isFile()) resolved.push(absPath);
5069
+ const stat11 = await fs4.stat(absPath);
5070
+ if (stat11.isFile()) resolved.push(absPath);
4882
5071
  } catch {
4883
5072
  }
4884
5073
  }
@@ -5754,8 +5943,91 @@ ${mode.description}`
5754
5943
  };
5755
5944
  }
5756
5945
 
5946
+ // src/codebase-index/circuit-breaker.ts
5947
+ var CircuitOpenError = class extends Error {
5948
+ name = "CircuitOpenError";
5949
+ };
5950
+ var IndexTimeoutError = class extends Error {
5951
+ name = "IndexTimeoutError";
5952
+ };
5953
+ var LockError = class extends Error {
5954
+ name = "LockError";
5955
+ };
5956
+ var IndexCircuitBreaker = class {
5957
+ failureThreshold;
5958
+ cooldownMs;
5959
+ now;
5960
+ state = "closed";
5961
+ consecutiveFailures = 0;
5962
+ openedAt = 0;
5963
+ lastFailure = null;
5964
+ probeInFlight = false;
5965
+ constructor(opts = {}) {
5966
+ this.failureThreshold = opts.failureThreshold ?? 3;
5967
+ this.cooldownMs = opts.cooldownMs ?? 6e4;
5968
+ this.now = opts.now ?? Date.now;
5969
+ }
5970
+ /**
5971
+ * True when a run may proceed. An open circuit transitions to half-open once
5972
+ * the cooldown has elapsed, admitting exactly one probe; further requests
5973
+ * are rejected until that probe settles via recordSuccess/recordFailure.
5974
+ */
5975
+ allowRequest() {
5976
+ if (this.state === "closed") return true;
5977
+ if (this.state === "open") {
5978
+ if (this.now() - this.openedAt < this.cooldownMs) return false;
5979
+ this.state = "half-open";
5980
+ this.probeInFlight = true;
5981
+ return true;
5982
+ }
5983
+ if (this.probeInFlight) return false;
5984
+ this.probeInFlight = true;
5985
+ return true;
5986
+ }
5987
+ recordSuccess() {
5988
+ this.state = "closed";
5989
+ this.consecutiveFailures = 0;
5990
+ this.lastFailure = null;
5991
+ this.probeInFlight = false;
5992
+ }
5993
+ recordFailure(err) {
5994
+ if (err instanceof LockError) {
5995
+ this.lastFailure = `[transient/lock] ${err.message}`;
5996
+ this.probeInFlight = false;
5997
+ return;
5998
+ }
5999
+ this.lastFailure = err instanceof Error ? err.message : String(err);
6000
+ this.probeInFlight = false;
6001
+ this.consecutiveFailures++;
6002
+ if (this.state === "half-open" || this.consecutiveFailures >= this.failureThreshold) {
6003
+ this.state = "open";
6004
+ this.openedAt = this.now();
6005
+ }
6006
+ }
6007
+ /** Force-close the circuit (manual recovery: `/codebase-reindex`). */
6008
+ reset() {
6009
+ this.state = "closed";
6010
+ this.consecutiveFailures = 0;
6011
+ this.lastFailure = null;
6012
+ this.probeInFlight = false;
6013
+ this.openedAt = 0;
6014
+ }
6015
+ snapshot() {
6016
+ return {
6017
+ state: this.state,
6018
+ consecutiveFailures: this.consecutiveFailures,
6019
+ lastFailure: this.lastFailure,
6020
+ cooldownRemainingMs: this.state === "open" ? Math.max(0, this.cooldownMs - (this.now() - this.openedAt)) : 0
6021
+ };
6022
+ }
6023
+ };
6024
+ var indexCircuitBreaker = new IndexCircuitBreaker();
6025
+ function resetIndexCircuitBreaker() {
6026
+ indexCircuitBreaker.reset();
6027
+ }
6028
+
5757
6029
  // src/codebase-index/schema.ts
5758
- var SCHEMA_VERSION = 1;
6030
+ var SCHEMA_VERSION = 2;
5759
6031
 
5760
6032
  // src/codebase-index/lsp-kind.ts
5761
6033
  function lspKindToInternalKind(k) {
@@ -5790,6 +6062,94 @@ function lspKindToInternalKind(k) {
5790
6062
  }
5791
6063
  }
5792
6064
 
6065
+ // src/codebase-index/bm25.ts
6066
+ var K1 = 1.5;
6067
+ var B = 0.75;
6068
+ function tokenise(text) {
6069
+ const sanitised = text.replace(/[^\p{L}\p{N}$'_]/gu, " ").replace(/_/g, " ");
6070
+ return sanitised.toLowerCase().split(" ").filter(Boolean);
6071
+ }
6072
+ function splitName(name) {
6073
+ return name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
6074
+ }
6075
+ function buildIndexableText(name, signature, docComment) {
6076
+ return [splitName(name), name, signature, docComment].filter(Boolean).join(" ");
6077
+ }
6078
+ function buildBm25Index(docs) {
6079
+ const documents = docs.map((d) => {
6080
+ const tokens = tokenise(d.text);
6081
+ return { id: d.id, tokens, raw: d.text, len: tokens.length };
6082
+ });
6083
+ const df = {};
6084
+ for (const doc of documents) {
6085
+ const seen = /* @__PURE__ */ new Set();
6086
+ for (const t of doc.tokens) {
6087
+ if (!seen.has(t)) {
6088
+ df[t] = (df[t] ?? 0) + 1;
6089
+ seen.add(t);
6090
+ }
6091
+ }
6092
+ }
6093
+ const N = documents.length;
6094
+ const totalLen = documents.reduce((sum, d) => sum + d.len, 0);
6095
+ const avgLen = N === 0 ? 0 : totalLen / N;
6096
+ return new Bm25Index(documents, df, N, avgLen);
6097
+ }
6098
+ var Bm25Index = class {
6099
+ constructor(documents, df, N, avgLen) {
6100
+ this.documents = documents;
6101
+ this.df = df;
6102
+ this.N = N;
6103
+ this.safeAvgLen = avgLen === 0 ? 1 : avgLen;
6104
+ }
6105
+ documents;
6106
+ df;
6107
+ N;
6108
+ safeAvgLen;
6109
+ score(query2, filter) {
6110
+ const qTokens = tokenise(query2);
6111
+ if (qTokens.length === 0) return [];
6112
+ const results = [];
6113
+ for (const doc of this.documents) {
6114
+ if (filter && !filter(doc.id)) continue;
6115
+ let docScore = 0;
6116
+ for (const qTerm of qTokens) {
6117
+ let tf = 0;
6118
+ for (const t of doc.tokens) {
6119
+ if (t === qTerm) tf++;
6120
+ }
6121
+ if (tf === 0) continue;
6122
+ const dfVal = this.df[qTerm] ?? 0;
6123
+ if (dfVal === 0) continue;
6124
+ const idf = Math.log((this.N - dfVal + 0.5) / (dfVal + 0.5) + 1);
6125
+ const lenRatio = B * (doc.len / this.safeAvgLen);
6126
+ const tfComponent = tf * (K1 + 1) / (tf + K1 * (1 - B + lenRatio));
6127
+ docScore += idf * tfComponent;
6128
+ }
6129
+ if (docScore > 0) results.push({ id: doc.id, score: docScore });
6130
+ }
6131
+ return results;
6132
+ }
6133
+ getDoc(id) {
6134
+ return this.documents.find((d) => d.id === id);
6135
+ }
6136
+ extractSnippet(docId, queryTokens, radius = 40) {
6137
+ const doc = this.getDoc(docId);
6138
+ if (!doc) return "";
6139
+ for (const tok of queryTokens) {
6140
+ const idx = doc.raw.toLowerCase().indexOf(tok);
6141
+ if (idx !== -1) {
6142
+ const start = Math.max(0, idx - radius);
6143
+ const end = Math.min(doc.raw.length, idx + tok.length + radius);
6144
+ const excerpt = doc.raw.slice(start, end);
6145
+ const ellipsis = "\u2026";
6146
+ return (start > 0 ? ellipsis : "") + excerpt + (end < doc.raw.length ? ellipsis : "");
6147
+ }
6148
+ }
6149
+ return doc.raw.slice(0, radius * 2) + (doc.raw.length > radius * 2 ? "\u2026" : "");
6150
+ }
6151
+ };
6152
+
5793
6153
  // src/codebase-index/writer.ts
5794
6154
  var DB_FILE = "index.db";
5795
6155
  function resolveIndexDir(projectRoot, override) {
@@ -5825,15 +6185,79 @@ function loadDatabaseSync() {
5825
6185
  }
5826
6186
  return DatabaseSyncCtor;
5827
6187
  }
6188
+ var MAX_LOCK_RETRIES = 3;
6189
+ var LOCK_RETRY_BASE_DELAY_MS = 50;
6190
+ var LOCK_RETRY_MAX_DELAY_MS = 500;
6191
+ function isLockError(err) {
6192
+ if (!(err instanceof Error)) return false;
6193
+ const e = err;
6194
+ const code = e.code ?? e.sqliteCode;
6195
+ if (typeof code === "string" && /SQLITE_(BUSY|LOCKED)/.test(code)) return true;
6196
+ if (typeof code === "number" && (code === 5 || code === 6)) return true;
6197
+ if (/SQLITE_(BUSY|LOCKED)/.test(err.message)) return true;
6198
+ return false;
6199
+ }
6200
+ function sleepSync(ms) {
6201
+ try {
6202
+ const sab = new SharedArrayBuffer(4);
6203
+ const view = new Int32Array(sab);
6204
+ Atomics.wait(view, 0, 0, ms);
6205
+ } catch {
6206
+ }
6207
+ }
5828
6208
  var IndexStore = class {
5829
6209
  db;
5830
6210
  /** Absolute path to this project's index directory. */
5831
6211
  indexDir;
6212
+ /**
6213
+ * True when the SQLite build provides FTS5 (Node's bundled SQLite does).
6214
+ * When false, ranked search falls back to the LIKE + in-process BM25 path.
6215
+ */
6216
+ ftsAvailable = false;
6217
+ /**
6218
+ * Execute a SQLite write operation with automatic retry on lock conflicts.
6219
+ *
6220
+ * When another wstack process is holding the write lock the statement first
6221
+ * waits up to `busy_timeout` ms, then throws SQLITE_BUSY. This wrapper catches
6222
+ * that error and retries (up to MAX_LOCK_RETRIES) with exponential backoff,
6223
+ * giving the competing writer time to finish and release the lock.
6224
+ *
6225
+ * @param fn The write operation to execute. Can return a value which is
6226
+ * returned to the caller on success.
6227
+ * @throws {@link LockError} when all retries are exhausted on a lock conflict
6228
+ * (non-lock errors always propagate on the first attempt).
6229
+ */
6230
+ runWithRetry(fn) {
6231
+ let lastError;
6232
+ for (let attempt = 0; attempt <= MAX_LOCK_RETRIES; attempt++) {
6233
+ try {
6234
+ return fn();
6235
+ } catch (err) {
6236
+ lastError = err;
6237
+ if (!isLockError(err)) throw err;
6238
+ if (attempt === MAX_LOCK_RETRIES) {
6239
+ const msg = lastError instanceof Error ? lastError.message : String(lastError);
6240
+ throw new LockError(`SQLite lock conflict after ${MAX_LOCK_RETRIES} retries: ${msg}`);
6241
+ }
6242
+ const delay = Math.min(
6243
+ LOCK_RETRY_BASE_DELAY_MS * Math.pow(2, attempt),
6244
+ LOCK_RETRY_MAX_DELAY_MS
6245
+ );
6246
+ sleepSync(delay);
6247
+ }
6248
+ }
6249
+ throw lastError;
6250
+ }
5832
6251
  constructor(projectRoot, opts = {}) {
5833
6252
  this.indexDir = resolveIndexDir(projectRoot, opts.indexDir);
5834
6253
  fs7.mkdirSync(this.indexDir, { recursive: true });
5835
6254
  const Database = loadDatabaseSync();
5836
6255
  this.db = new Database(path.join(this.indexDir, DB_FILE));
6256
+ try {
6257
+ this.db.exec("PRAGMA journal_mode = WAL");
6258
+ this.db.exec("PRAGMA busy_timeout = 5000");
6259
+ } catch {
6260
+ }
5837
6261
  this.initSchema();
5838
6262
  }
5839
6263
  initSchema() {
@@ -5842,6 +6266,21 @@ var IndexStore = class {
5842
6266
  key TEXT PRIMARY KEY,
5843
6267
  value TEXT NOT NULL
5844
6268
  );
6269
+ `);
6270
+ const storedRows = this.db.prepare("SELECT value FROM metadata WHERE key = ?").all("version");
6271
+ const storedVersion = storedRows.length ? Number(storedRows[0]?.value) : null;
6272
+ if (storedVersion !== null && storedVersion !== SCHEMA_VERSION) {
6273
+ this.db.exec(`
6274
+ DROP TABLE IF EXISTS symbols;
6275
+ DROP TABLE IF EXISTS files;
6276
+ DROP TABLE IF EXISTS refs;
6277
+ `);
6278
+ this.db.exec("DROP TABLE IF EXISTS symbols_fts");
6279
+ this.db.prepare("UPDATE metadata SET value = ? WHERE key = ?").run(String(SCHEMA_VERSION), "version");
6280
+ } else if (storedVersion === null) {
6281
+ this.db.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)").run("version", String(SCHEMA_VERSION));
6282
+ }
6283
+ this.db.exec(`
5845
6284
  CREATE TABLE IF NOT EXISTS files (
5846
6285
  file TEXT PRIMARY KEY,
5847
6286
  lang TEXT NOT NULL,
@@ -5882,53 +6321,76 @@ var IndexStore = class {
5882
6321
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_r_to_id ON refs(to_id)");
5883
6322
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_r_to_name ON refs(to_name)");
5884
6323
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_r_call_type ON refs(call_type)");
5885
- const versionRows = this.db.prepare("SELECT value FROM metadata WHERE key = ?").all("version");
5886
- if (!versionRows.length) {
5887
- this.db.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)").run("version", String(SCHEMA_VERSION));
6324
+ try {
6325
+ this.db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(text, tokenize = 'unicode61')");
6326
+ this.ftsAvailable = true;
6327
+ } catch {
6328
+ this.ftsAvailable = false;
5888
6329
  }
5889
6330
  }
5890
6331
  // ─── Symbol CRUD ─────────────────────────────────────────────────────────────
5891
6332
  insertSymbols(symbols, nextId) {
5892
- const stmt = this.db.prepare(
5893
- `INSERT INTO symbols(id, lang, kind, name, file, line, col, signature, doc_comment, scope, text, file_fk)
5894
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
5895
- );
5896
- let id = nextId;
5897
- for (const s of symbols) {
5898
- stmt.run(
5899
- id++,
5900
- s.lang,
5901
- s.kind,
5902
- s.name,
5903
- s.file,
5904
- s.line,
5905
- s.col,
5906
- s.signature,
5907
- s.docComment,
5908
- s.scope,
5909
- s.text,
5910
- s.file
6333
+ return this.runWithRetry(() => {
6334
+ const stmt = this.db.prepare(
6335
+ `INSERT INTO symbols(id, lang, kind, name, file, line, col, signature, doc_comment, scope, text, file_fk)
6336
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
5911
6337
  );
5912
- }
5913
- return id;
6338
+ const ftsStmt = this.ftsAvailable ? this.db.prepare("INSERT INTO symbols_fts(rowid, text) VALUES (?, ?)") : null;
6339
+ let id = nextId;
6340
+ for (const s of symbols) {
6341
+ stmt.run(
6342
+ id,
6343
+ s.lang,
6344
+ s.kind,
6345
+ s.name,
6346
+ s.file,
6347
+ s.line,
6348
+ s.col,
6349
+ s.signature,
6350
+ s.docComment,
6351
+ s.scope,
6352
+ s.text,
6353
+ s.file
6354
+ );
6355
+ ftsStmt?.run(id, buildIndexableText(s.name, s.signature, s.docComment));
6356
+ id++;
6357
+ }
6358
+ return id;
6359
+ });
5914
6360
  }
5915
6361
  deleteSymbolsForFile(file) {
5916
- this.db.prepare("DELETE FROM symbols WHERE file_fk = ?").run(file);
6362
+ this.runWithRetry(() => {
6363
+ if (this.ftsAvailable) {
6364
+ this.db.prepare("DELETE FROM symbols_fts WHERE rowid IN (SELECT id FROM symbols WHERE file_fk = ?)").run(file);
6365
+ }
6366
+ this.db.prepare("DELETE FROM symbols WHERE file_fk = ?").run(file);
6367
+ });
5917
6368
  }
6369
+ /**
6370
+ * Remove every trace of a file (refs, symbols, FTS rows, file meta). Used
6371
+ * when a source file disappears between index runs — previously this only
6372
+ * dropped the `files` row, leaving its symbols orphaned but still searchable.
6373
+ */
5918
6374
  deleteFile(file) {
5919
- this.db.prepare("DELETE FROM files WHERE file = ?").run(file);
6375
+ this.runWithRetry(() => {
6376
+ this.deleteRefsForFile(file);
6377
+ this.deleteSymbolsForFile(file);
6378
+ this.db.prepare("DELETE FROM files WHERE file = ?").run(file);
6379
+ });
5920
6380
  }
5921
6381
  // ─── File metadata ──────────────────────────────────────────────────────────
5922
6382
  upsertFile(meta) {
5923
- this.db.prepare(
5924
- `INSERT INTO files(file, lang, mtime_ms, symbol_count, last_indexed)
5925
- VALUES (?, ?, ?, ?, ?)
5926
- ON CONFLICT(file) DO UPDATE SET
5927
- lang = excluded.lang,
5928
- mtime_ms = excluded.mtime_ms,
5929
- symbol_count = excluded.symbol_count,
5930
- last_indexed = excluded.last_indexed`
5931
- ).run(meta.file, meta.lang, meta.mtimeMs, meta.symbolCount, meta.lastIndexed);
6383
+ this.runWithRetry(() => {
6384
+ this.db.prepare(
6385
+ `INSERT INTO files(file, lang, mtime_ms, symbol_count, last_indexed)
6386
+ VALUES (?, ?, ?, ?, ?)
6387
+ ON CONFLICT(file) DO UPDATE SET
6388
+ lang = excluded.lang,
6389
+ mtime_ms = excluded.mtime_ms,
6390
+ symbol_count = excluded.symbol_count,
6391
+ last_indexed = excluded.last_indexed`
6392
+ ).run(meta.file, meta.lang, meta.mtimeMs, meta.symbolCount, meta.lastIndexed);
6393
+ });
5932
6394
  }
5933
6395
  getFileMeta(file) {
5934
6396
  const rows = this.db.prepare(
@@ -5995,15 +6457,103 @@ var IndexStore = class {
5995
6457
  lspKind: filter?.lspKind
5996
6458
  }));
5997
6459
  }
5998
- getAllIndexable() {
5999
- return this.db.prepare("SELECT id, text FROM symbols").all().map(
6000
- ({ id, text }) => ({ id, text })
6001
- );
6002
- }
6003
6460
  /**
6004
- * Largest symbol id currently in the table (0 when empty). New ids must be
6005
- * allocated from this, NOT from `COUNT(*)`: incremental reindexes delete a
6006
- * changed file's rows, so the row count drops below the max id and a
6461
+ * Ranked search the one-stop query the codebase-search tool and plug-lsp
6462
+ * use. With FTS5 this is a single indexed `MATCH` ranked by SQLite's native
6463
+ * `bm25()` with a built-in `snippet()`; without FTS5 it falls back to the
6464
+ * legacy LIKE scan + in-process BM25 (identical semantics, slower).
6465
+ *
6466
+ * Tokens are matched as prefixes (`"tok"*`), mirroring the old
6467
+ * `LIKE '%tok%'` recall for the common symbol-search shapes ("user" finds
6468
+ * "users", camelCase-split text makes "complex" find "complexOperation").
6469
+ */
6470
+ searchRanked(query2, filter, limit) {
6471
+ const tokens = tokenise(query2);
6472
+ if (tokens.length === 0 || !this.ftsAvailable) {
6473
+ return this.searchRankedFallback(query2, filter, limit);
6474
+ }
6475
+ let effectiveKind = filter?.kind;
6476
+ if (filter?.lspKind !== void 0) {
6477
+ const mapped = lspKindToInternalKind(filter.lspKind);
6478
+ if (mapped === null) return { results: [], total: 0 };
6479
+ effectiveKind = mapped;
6480
+ }
6481
+ const match = tokens.map((t) => `"${t.replaceAll('"', "")}"*`).join(" OR ");
6482
+ const conditions = ["symbols_fts MATCH ?"];
6483
+ const values = [match];
6484
+ if (effectiveKind) {
6485
+ conditions.push("s.kind = ?");
6486
+ values.push(effectiveKind);
6487
+ }
6488
+ if (filter?.lang) {
6489
+ conditions.push("s.lang = ?");
6490
+ values.push(filter.lang);
6491
+ }
6492
+ if (filter?.file) {
6493
+ conditions.push("s.file LIKE ?");
6494
+ values.push(`%${filter.file}%`);
6495
+ }
6496
+ const where = conditions.join(" AND ");
6497
+ const countRows = this.db.prepare(`SELECT COUNT(*) AS n FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid WHERE ${where}`).all(...values);
6498
+ const total = countRows[0] ? Number(countRows[0].n) : 0;
6499
+ if (total === 0) return { results: [], total: 0 };
6500
+ const rows = this.db.prepare(
6501
+ `SELECT s.id, s.lang, s.kind, s.name, s.file, s.line, s.col, s.signature, s.doc_comment,
6502
+ -bm25(symbols_fts) AS score,
6503
+ snippet(symbols_fts, 0, '', '', '\u2026', 12) AS snippet
6504
+ FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid
6505
+ WHERE ${where}
6506
+ ORDER BY bm25(symbols_fts)
6507
+ LIMIT ?`
6508
+ ).all(...values, limit);
6509
+ return {
6510
+ results: rows.map((r) => ({
6511
+ id: r.id,
6512
+ lang: r.lang,
6513
+ kind: r.kind,
6514
+ name: r.name,
6515
+ file: r.file,
6516
+ line: r.line,
6517
+ col: r.col,
6518
+ signature: r.signature,
6519
+ docComment: r.doc_comment,
6520
+ // bm25() is negative-is-better; negate so callers keep "higher is
6521
+ // better" and clamp so a match never reports a zero score.
6522
+ score: Math.max(1e-4, r.score),
6523
+ snippet: r.snippet,
6524
+ lspKind: filter?.lspKind
6525
+ })),
6526
+ total
6527
+ };
6528
+ }
6529
+ /** Legacy ranked path: LIKE candidates + in-process BM25 + JS snippets. */
6530
+ searchRankedFallback(query2, filter, limit) {
6531
+ const candidates = this.search(query2, filter);
6532
+ if (candidates.length === 0) return { results: [], total: 0 };
6533
+ if (!query2.trim()) {
6534
+ return { results: candidates.slice(0, limit), total: candidates.length };
6535
+ }
6536
+ const bm25 = buildBm25Index(
6537
+ candidates.map((c) => ({ id: c.id, text: buildIndexableText(c.name, c.signature, c.docComment) }))
6538
+ );
6539
+ const scored = bm25.score(query2, (id) => candidates.some((c) => c.id === id));
6540
+ scored.sort((a, b) => b.score - a.score);
6541
+ const qTokens = tokenise(query2);
6542
+ const results = scored.slice(0, limit).map(({ id, score }) => {
6543
+ const c = expectDefined(candidates.find((cand) => cand.id === id));
6544
+ return { ...c, score, snippet: bm25.extractSnippet(id, qTokens) };
6545
+ });
6546
+ return { results, total: candidates.length };
6547
+ }
6548
+ getAllIndexable() {
6549
+ return this.db.prepare("SELECT id, text FROM symbols").all().map(
6550
+ ({ id, text }) => ({ id, text })
6551
+ );
6552
+ }
6553
+ /**
6554
+ * Largest symbol id currently in the table (0 when empty). New ids must be
6555
+ * allocated from this, NOT from `COUNT(*)`: incremental reindexes delete a
6556
+ * changed file's rows, so the row count drops below the max id and a
6007
6557
  * count-based id would collide with a surviving row (UNIQUE constraint on
6008
6558
  * `symbols.id`). Ids may have gaps — that is fine.
6009
6559
  */
@@ -6044,14 +6594,19 @@ var IndexStore = class {
6044
6594
  };
6045
6595
  }
6046
6596
  setLastIndexed(ts2) {
6047
- this.db.prepare(
6048
- "INSERT OR REPLACE INTO metadata(key, value) VALUES('last_indexed', ?)"
6049
- ).run(String(ts2));
6597
+ this.runWithRetry(() => {
6598
+ this.db.prepare(
6599
+ "INSERT OR REPLACE INTO metadata(key, value) VALUES('last_indexed', ?)"
6600
+ ).run(String(ts2));
6601
+ });
6050
6602
  }
6051
6603
  clearAll() {
6052
- this.db.exec("DELETE FROM symbols");
6053
- this.db.exec("DELETE FROM files");
6054
- this.db.exec("DELETE FROM refs");
6604
+ this.runWithRetry(() => {
6605
+ this.db.exec("DELETE FROM symbols");
6606
+ this.db.exec("DELETE FROM files");
6607
+ this.db.exec("DELETE FROM refs");
6608
+ if (this.ftsAvailable) this.db.exec("DELETE FROM symbols_fts");
6609
+ });
6055
6610
  }
6056
6611
  // ─── Ref CRUD ────────────────────────────────────────────────────────────────
6057
6612
  /**
@@ -6059,46 +6614,52 @@ var IndexStore = class {
6059
6614
  * Replaces any existing refs from the same source (idempotent on re-index).
6060
6615
  */
6061
6616
  insertRefs(fromId, refs) {
6062
- this.db.prepare("DELETE FROM refs WHERE from_id = ?").run(fromId);
6063
- if (refs.length === 0) return;
6064
- const stmt = this.db.prepare(
6065
- `INSERT INTO refs(from_id, to_name, to_id, call_type, line)
6066
- VALUES (?, ?, ?, ?, ?)`
6067
- );
6068
- for (const ref of refs) {
6069
- stmt.run(fromId, ref.toName, ref.toId ?? null, ref.callType, ref.line);
6070
- }
6617
+ this.runWithRetry(() => {
6618
+ this.db.prepare("DELETE FROM refs WHERE from_id = ?").run(fromId);
6619
+ if (refs.length === 0) return;
6620
+ const stmt = this.db.prepare(
6621
+ `INSERT INTO refs(from_id, to_name, to_id, call_type, line)
6622
+ VALUES (?, ?, ?, ?, ?)`
6623
+ );
6624
+ for (const ref of refs) {
6625
+ stmt.run(fromId, ref.toName, ref.toId ?? null, ref.callType, ref.line);
6626
+ }
6627
+ });
6071
6628
  }
6072
6629
  /**
6073
6630
  * Delete all refs whose source symbols are in a given file.
6074
6631
  * Used when re-indexing a file to clear stale refs.
6075
6632
  */
6076
6633
  deleteRefsForFile(file) {
6077
- const ids = this.db.prepare(
6078
- "SELECT id FROM symbols WHERE file = ?"
6079
- ).all(file);
6080
- if (!ids.length) return;
6081
- const placeholders = ids.map(() => "?").join(",");
6082
- this.db.prepare(`DELETE FROM refs WHERE from_id IN (${placeholders})`).run(...ids.map((r) => r.id));
6634
+ this.runWithRetry(() => {
6635
+ const ids = this.db.prepare(
6636
+ "SELECT id FROM symbols WHERE file = ?"
6637
+ ).all(file);
6638
+ if (!ids.length) return;
6639
+ const placeholders = ids.map(() => "?").join(",");
6640
+ this.db.prepare(`DELETE FROM refs WHERE from_id IN (${placeholders})`).run(...ids.map((r) => r.id));
6641
+ });
6083
6642
  }
6084
6643
  /**
6085
6644
  * Resolve `to_name` → `to_id` for all refs that have a name but no id.
6086
6645
  * Call this after all symbols have been inserted to fill in cross-references.
6087
6646
  */
6088
6647
  resolveRefs() {
6089
- const unresolved = this.db.prepare(
6090
- "SELECT id, to_name FROM refs WHERE to_id IS NULL AND to_name IS NOT NULL"
6091
- ).all();
6092
- let resolved = 0;
6093
- for (const row of unresolved) {
6094
- const target = this.db.prepare("SELECT id FROM symbols WHERE name = ? LIMIT 1").all(row.to_name);
6095
- const first = target[0];
6096
- if (first) {
6097
- this.db.prepare("UPDATE refs SET to_id = ? WHERE id = ?").run(first.id, row.id);
6098
- resolved++;
6648
+ return this.runWithRetry(() => {
6649
+ const unresolved = this.db.prepare(
6650
+ "SELECT id, to_name FROM refs WHERE to_id IS NULL AND to_name IS NOT NULL"
6651
+ ).all();
6652
+ let resolved = 0;
6653
+ for (const row of unresolved) {
6654
+ const target = this.db.prepare("SELECT id FROM symbols WHERE name = ? LIMIT 1").all(row.to_name);
6655
+ const first = target[0];
6656
+ if (first) {
6657
+ this.db.prepare("UPDATE refs SET to_id = ? WHERE id = ?").run(first.id, row.id);
6658
+ resolved++;
6659
+ }
6099
6660
  }
6100
- }
6101
- return resolved;
6661
+ return resolved;
6662
+ });
6102
6663
  }
6103
6664
  /**
6104
6665
  * Find all references TO a given symbol (who calls / uses this symbol?).
@@ -6859,7 +7420,7 @@ function parseSymbols4(opts) {
6859
7420
  }
6860
7421
  function checkNativeParser() {
6861
7422
  try {
6862
- execFileSync("rustc", ["--version"], { stdio: "pipe" });
7423
+ execFileSync("rustc", ["--version"], { stdio: "pipe", windowsHide: true });
6863
7424
  const toolsDir = path.join(process.cwd(), "tools");
6864
7425
  try {
6865
7426
  execFileSync(
@@ -6872,7 +7433,7 @@ function checkNativeParser() {
6872
7433
  "--manifest-path",
6873
7434
  path.join(toolsDir, "Cargo.toml")
6874
7435
  ],
6875
- { stdio: "pipe" }
7436
+ { stdio: "pipe", windowsHide: true }
6876
7437
  );
6877
7438
  return true;
6878
7439
  } catch {
@@ -6895,7 +7456,8 @@ function tryNativeParse(file, content) {
6895
7456
  cwd: process.cwd(),
6896
7457
  encoding: "utf8",
6897
7458
  timeout: 15e3,
6898
- stdio: ["pipe", "pipe", "pipe"]
7459
+ stdio: ["pipe", "pipe", "pipe"],
7460
+ windowsHide: true
6899
7461
  }
6900
7462
  );
6901
7463
  if (result.status === 0 && result.stdout) {
@@ -7309,10 +7871,6 @@ function isScalar(value) {
7309
7871
  if (/^'[^']*'$/.test(value) || /^"[^"]*"$/.test(value)) return true;
7310
7872
  return false;
7311
7873
  }
7312
- function truncate(s, max) {
7313
- if (s.length <= max) return s;
7314
- return s.slice(0, max) + "...";
7315
- }
7316
7874
  function makeSymbol2(opts) {
7317
7875
  return {
7318
7876
  id: 0,
@@ -7379,126 +7937,6 @@ async function loadGitignoreMatcher(projectRoot) {
7379
7937
  return compileGitignore(lines);
7380
7938
  }
7381
7939
 
7382
- // src/codebase-index/background-indexer.ts
7383
- var _ready = false;
7384
- var _indexing = false;
7385
- var _currentFile = 0;
7386
- var _totalFiles = 0;
7387
- var _lastError = null;
7388
- function isIndexReady() {
7389
- return _ready;
7390
- }
7391
- function setIndexReady() {
7392
- _ready = true;
7393
- }
7394
- function isIndexing() {
7395
- return _indexing;
7396
- }
7397
- function getIndexState() {
7398
- return {
7399
- ready: _ready,
7400
- indexing: _indexing,
7401
- currentFile: _currentFile,
7402
- totalFiles: _totalFiles,
7403
- lastError: _lastError
7404
- };
7405
- }
7406
- var _listeners = [];
7407
- function onIndexStateChange(listener) {
7408
- _listeners.push(listener);
7409
- return () => {
7410
- _listeners = _listeners.filter((l) => l !== listener);
7411
- };
7412
- }
7413
- function emitState() {
7414
- const state = getIndexState();
7415
- for (const l of _listeners) l(state);
7416
- }
7417
- function _setIndexProgress(current, total) {
7418
- _currentFile = current;
7419
- _totalFiles = total;
7420
- emitState();
7421
- }
7422
- function stubCtx(projectRoot) {
7423
- return {
7424
- projectRoot,
7425
- cwd: projectRoot,
7426
- messages: [],
7427
- todos: [],
7428
- readFiles: /* @__PURE__ */ new Set(),
7429
- fileMtimes: /* @__PURE__ */ new Map()
7430
- };
7431
- }
7432
- var chain = Promise.resolve();
7433
- function withMutex(job) {
7434
- const run = chain.then(job, job);
7435
- chain = run.then(
7436
- () => void 0,
7437
- () => void 0
7438
- );
7439
- return run;
7440
- }
7441
- var DEFAULT_DEBOUNCE_MS = 400;
7442
- var debounceTimers = /* @__PURE__ */ new Map();
7443
- function debounceKey(indexDir, file) {
7444
- return `${indexDir ?? ""}|${file}`;
7445
- }
7446
- function isIndexableFile(filePath) {
7447
- return detectLang(filePath) !== null;
7448
- }
7449
- async function runStartupIndex(opts) {
7450
- _indexing = true;
7451
- emitState();
7452
- try {
7453
- const result = await withMutex(() => {
7454
- _currentFile = 0;
7455
- _totalFiles = 0;
7456
- _lastError = null;
7457
- return runIndexer(stubCtx(opts.projectRoot), {
7458
- projectRoot: opts.projectRoot,
7459
- indexDir: opts.indexDir,
7460
- force: opts.force,
7461
- signal: opts.signal
7462
- });
7463
- });
7464
- _ready = true;
7465
- return result;
7466
- } catch (err) {
7467
- _lastError = err instanceof Error ? err.message : String(err);
7468
- _ready = true;
7469
- throw err;
7470
- } finally {
7471
- _indexing = false;
7472
- emitState();
7473
- }
7474
- }
7475
- function enqueueReindex(opts) {
7476
- const files = opts.files.filter(isIndexableFile);
7477
- if (files.length === 0) return;
7478
- const ms = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
7479
- for (const file of files) {
7480
- const key = debounceKey(opts.indexDir, file);
7481
- const existing = debounceTimers.get(key);
7482
- if (existing) clearTimeout(existing);
7483
- const timer = setTimeout(() => {
7484
- debounceTimers.delete(key);
7485
- void withMutex(
7486
- () => runIndexer(stubCtx(opts.projectRoot), {
7487
- projectRoot: opts.projectRoot,
7488
- files: [file],
7489
- indexDir: opts.indexDir
7490
- })
7491
- ).catch((err) => opts.onError?.(err));
7492
- }, ms);
7493
- timer.unref?.();
7494
- debounceTimers.set(key, timer);
7495
- }
7496
- }
7497
- function cancelPendingReindexes() {
7498
- for (const t of debounceTimers.values()) clearTimeout(t);
7499
- debounceTimers.clear();
7500
- }
7501
-
7502
7940
  // src/codebase-index/indexer.ts
7503
7941
  var YIELD_EVERY_N = 50;
7504
7942
  function yieldEventLoop() {
@@ -7636,25 +8074,25 @@ async function runIndexerWithStore(store, opts) {
7636
8074
  }
7637
8075
  for (let fi = 0; fi < files.length; fi++) {
7638
8076
  const file = expectDefined(files[fi]);
7639
- _setIndexProgress(fi + 1, files.length);
8077
+ opts.onProgress?.(fi + 1, files.length);
7640
8078
  if (fi > 0 && fi % YIELD_EVERY_N === 0) {
7641
8079
  await yieldEventLoop();
7642
8080
  throwIfAborted(signal);
7643
8081
  }
7644
- let stat10;
8082
+ let stat11;
7645
8083
  try {
7646
8084
  const statOpts = signal ? { signal } : {};
7647
- stat10 = await fs4.stat(file, statOpts);
8085
+ stat11 = await fs4.stat(file, statOpts);
7648
8086
  } catch (e) {
7649
8087
  if (isAbortError(e)) throw e;
7650
8088
  store.deleteFile(file);
7651
8089
  continue;
7652
8090
  }
7653
- if (!stat10.isFile()) continue;
8091
+ if (!stat11.isFile()) continue;
7654
8092
  const lang = detectLang(file);
7655
8093
  if (!lang) continue;
7656
8094
  const meta = existingMeta.get(file);
7657
- if (!force && meta && meta.mtimeMs === Math.floor(stat10.mtimeMs)) {
8095
+ if (!force && meta && meta.mtimeMs === Math.floor(stat11.mtimeMs)) {
7658
8096
  langStats[lang] = (langStats[lang] ?? 0) + meta.symbolCount;
7659
8097
  symbolsIndexed += meta.symbolCount;
7660
8098
  filesIndexed++;
@@ -7681,7 +8119,7 @@ async function runIndexerWithStore(store, opts) {
7681
8119
  store.upsertFile({
7682
8120
  file,
7683
8121
  lang,
7684
- mtimeMs: Math.floor(stat10.mtimeMs),
8122
+ mtimeMs: Math.floor(stat11.mtimeMs),
7685
8123
  symbolCount: 0,
7686
8124
  lastIndexed: Date.now()
7687
8125
  });
@@ -7707,7 +8145,7 @@ async function runIndexerWithStore(store, opts) {
7707
8145
  store.upsertFile({
7708
8146
  file,
7709
8147
  lang,
7710
- mtimeMs: Math.floor(stat10.mtimeMs),
8148
+ mtimeMs: Math.floor(stat11.mtimeMs),
7711
8149
  symbolCount: count,
7712
8150
  lastIndexed: Date.now()
7713
8151
  });
@@ -7731,6 +8169,365 @@ async function runIndexerWithStore(store, opts) {
7731
8169
  };
7732
8170
  }
7733
8171
 
8172
+ // src/codebase-index/index-service.ts
8173
+ function stubCtx(projectRoot) {
8174
+ return {
8175
+ projectRoot,
8176
+ cwd: projectRoot,
8177
+ messages: [],
8178
+ todos: [],
8179
+ readFiles: /* @__PURE__ */ new Set(),
8180
+ fileMtimes: /* @__PURE__ */ new Map()
8181
+ };
8182
+ }
8183
+ async function indexService(args, hooks = {}) {
8184
+ return runIndexer(stubCtx(args.projectRoot), {
8185
+ projectRoot: args.projectRoot,
8186
+ indexDir: args.indexDir,
8187
+ files: args.files,
8188
+ force: args.force,
8189
+ langs: args.langs,
8190
+ ignore: args.ignore,
8191
+ signal: hooks.signal,
8192
+ onProgress: hooks.onProgress
8193
+ });
8194
+ }
8195
+ function searchService(args) {
8196
+ const store = new IndexStore(args.projectRoot, { indexDir: args.indexDir });
8197
+ try {
8198
+ return store.searchRanked(
8199
+ args.query,
8200
+ {
8201
+ kind: args.kind,
8202
+ lang: args.lang,
8203
+ file: args.file,
8204
+ lspKind: args.lspKind
8205
+ },
8206
+ args.limit
8207
+ );
8208
+ } finally {
8209
+ store.close();
8210
+ }
8211
+ }
8212
+ function statsService(args) {
8213
+ const store = new IndexStore(args.projectRoot, { indexDir: args.indexDir });
8214
+ try {
8215
+ return store.getStats();
8216
+ } finally {
8217
+ store.close();
8218
+ }
8219
+ }
8220
+
8221
+ // src/codebase-index/background-indexer.ts
8222
+ var DEFAULT_FULL_INDEX_TIMEOUT_MS = 12e4;
8223
+ var DEFAULT_INCREMENTAL_TIMEOUT_MS = 3e4;
8224
+ var DEFAULT_QUERY_TIMEOUT_MS = 8e3;
8225
+ var _ready = false;
8226
+ var _indexing = false;
8227
+ var _currentFile = 0;
8228
+ var _totalFiles = 0;
8229
+ var _lastError = null;
8230
+ function isIndexReady() {
8231
+ return _ready;
8232
+ }
8233
+ function isIndexing() {
8234
+ return _indexing;
8235
+ }
8236
+ function getIndexState() {
8237
+ return {
8238
+ ready: _ready,
8239
+ indexing: _indexing,
8240
+ currentFile: _currentFile,
8241
+ totalFiles: _totalFiles,
8242
+ lastError: _lastError,
8243
+ circuit: indexCircuitBreaker.snapshot()
8244
+ };
8245
+ }
8246
+ var _listeners = [];
8247
+ function onIndexStateChange(listener) {
8248
+ _listeners.push(listener);
8249
+ return () => {
8250
+ _listeners = _listeners.filter((l) => l !== listener);
8251
+ };
8252
+ }
8253
+ function emitState() {
8254
+ const state = getIndexState();
8255
+ for (const l of _listeners) l(state);
8256
+ }
8257
+ function setIndexProgress(current, total) {
8258
+ _currentFile = current;
8259
+ _totalFiles = total;
8260
+ emitState();
8261
+ }
8262
+ var worker = null;
8263
+ var workerUnavailable = false;
8264
+ var nextRpcId = 1;
8265
+ var pending = /* @__PURE__ */ new Map();
8266
+ function resolveWorkerUrl() {
8267
+ if (process.env["WRONGSTACK_INDEX_INLINE"]) return null;
8268
+ for (const rel of ["./worker.js", "./codebase-index/worker.js"]) {
8269
+ try {
8270
+ const url = new URL(rel, import.meta.url);
8271
+ if (url.protocol === "file:" && fs7.existsSync(fileURLToPath(url))) return url;
8272
+ } catch {
8273
+ }
8274
+ }
8275
+ return null;
8276
+ }
8277
+ function failAllPending(err) {
8278
+ const entries = [...pending.values()];
8279
+ pending.clear();
8280
+ for (const p of entries) p.reject(err);
8281
+ }
8282
+ function ensureWorker() {
8283
+ if (worker) return worker;
8284
+ if (workerUnavailable) return null;
8285
+ const url = resolveWorkerUrl();
8286
+ if (!url) {
8287
+ workerUnavailable = true;
8288
+ return null;
8289
+ }
8290
+ try {
8291
+ const w = new Worker(url, { name: "wstack-codebase-index" });
8292
+ w.unref();
8293
+ w.on("message", (msg) => {
8294
+ if (msg.type === "progress") {
8295
+ pending.get(msg.id)?.onProgress?.(msg.current, msg.total);
8296
+ return;
8297
+ }
8298
+ const entry = pending.get(msg.id);
8299
+ if (!entry) return;
8300
+ pending.delete(msg.id);
8301
+ if (msg.ok) entry.resolve(msg.result);
8302
+ else entry.reject(new Error(msg.error));
8303
+ });
8304
+ w.on("error", (err) => {
8305
+ worker = null;
8306
+ failAllPending(err);
8307
+ });
8308
+ w.on("exit", () => {
8309
+ if (worker === w) worker = null;
8310
+ failAllPending(new Error("codebase-index worker exited"));
8311
+ });
8312
+ worker = w;
8313
+ return w;
8314
+ } catch {
8315
+ workerUnavailable = true;
8316
+ return null;
8317
+ }
8318
+ }
8319
+ function terminateWorker(reason) {
8320
+ const w = worker;
8321
+ worker = null;
8322
+ failAllPending(reason);
8323
+ if (w) void w.terminate().catch(() => {
8324
+ });
8325
+ }
8326
+ function shutdownCodebaseIndexHost() {
8327
+ cancelPendingReindexes();
8328
+ terminateWorker(new Error("codebase-index host shut down"));
8329
+ workerUnavailable = false;
8330
+ }
8331
+ function callIndexOp(op, args, opts) {
8332
+ const w = ensureWorker();
8333
+ if (!w) return callInline(op, args, opts);
8334
+ return new Promise((resolve7, reject) => {
8335
+ const id = nextRpcId++;
8336
+ const timer = setTimeout(() => {
8337
+ pending.delete(id);
8338
+ const err = new IndexTimeoutError(
8339
+ `Index ${op} exceeded its ${opts.timeoutMs}ms watchdog timeout`
8340
+ );
8341
+ terminateWorker(err);
8342
+ reject(err);
8343
+ }, opts.timeoutMs);
8344
+ timer.unref?.();
8345
+ const onAbort = () => {
8346
+ w.postMessage({ type: "cancel", id });
8347
+ };
8348
+ if (opts.signal?.aborted) onAbort();
8349
+ else opts.signal?.addEventListener("abort", onAbort, { once: true });
8350
+ const cleanup = () => {
8351
+ clearTimeout(timer);
8352
+ opts.signal?.removeEventListener("abort", onAbort);
8353
+ };
8354
+ pending.set(id, {
8355
+ resolve: (v) => {
8356
+ cleanup();
8357
+ resolve7(v);
8358
+ },
8359
+ reject: (e) => {
8360
+ cleanup();
8361
+ reject(e);
8362
+ },
8363
+ onProgress: opts.onProgress
8364
+ });
8365
+ w.postMessage({ type: "request", id, op, args });
8366
+ });
8367
+ }
8368
+ async function callInline(op, args, opts) {
8369
+ const ac = new AbortController();
8370
+ const onOuterAbort = () => ac.abort(opts.signal?.reason ?? new Error("Indexing cancelled"));
8371
+ if (opts.signal?.aborted) onOuterAbort();
8372
+ else opts.signal?.addEventListener("abort", onOuterAbort, { once: true });
8373
+ let timer;
8374
+ const watchdog = new Promise((_, reject) => {
8375
+ timer = setTimeout(() => {
8376
+ const err = new IndexTimeoutError(
8377
+ `Index ${op} exceeded its ${opts.timeoutMs}ms watchdog timeout`
8378
+ );
8379
+ ac.abort(err);
8380
+ reject(err);
8381
+ }, opts.timeoutMs);
8382
+ timer.unref?.();
8383
+ });
8384
+ const job = async () => {
8385
+ switch (op) {
8386
+ case "index":
8387
+ return await indexService(args, {
8388
+ signal: ac.signal,
8389
+ onProgress: opts.onProgress
8390
+ });
8391
+ case "search":
8392
+ return searchService(args);
8393
+ case "stats":
8394
+ return statsService(args);
8395
+ default:
8396
+ throw new Error(`unknown index op: ${String(op)}`);
8397
+ }
8398
+ };
8399
+ try {
8400
+ return await Promise.race([job(), watchdog]);
8401
+ } finally {
8402
+ if (timer) clearTimeout(timer);
8403
+ opts.signal?.removeEventListener("abort", onOuterAbort);
8404
+ }
8405
+ }
8406
+ var chain = Promise.resolve();
8407
+ function withMutex(job) {
8408
+ const run = chain.then(job, job);
8409
+ chain = run.then(
8410
+ () => void 0,
8411
+ () => void 0
8412
+ );
8413
+ return run;
8414
+ }
8415
+ function circuitOpenError() {
8416
+ const c = indexCircuitBreaker.snapshot();
8417
+ return new CircuitOpenError(
8418
+ "Codebase indexing is temporarily paused after repeated failures" + (c.lastFailure ? ` (last: ${c.lastFailure})` : "") + (c.cooldownRemainingMs > 0 ? `; auto-retry in ${Math.ceil(c.cooldownRemainingMs / 1e3)}s` : "") + ". Use /codebase-reindex to retry now."
8419
+ );
8420
+ }
8421
+ var DEFAULT_DEBOUNCE_MS = 400;
8422
+ var debounceTimers = /* @__PURE__ */ new Map();
8423
+ function debounceKey(indexDir, file) {
8424
+ return `${indexDir ?? ""}|${file}`;
8425
+ }
8426
+ function isIndexableFile(filePath) {
8427
+ return detectLang(filePath) !== null;
8428
+ }
8429
+ function isUniqueConstraintError(err) {
8430
+ if (err instanceof Error) {
8431
+ const msg = err.message.toLowerCase();
8432
+ return msg.includes("unique constraint") || msg.includes("UNIQUE constraint");
8433
+ }
8434
+ return false;
8435
+ }
8436
+ async function runStartupIndex(opts) {
8437
+ if (!indexCircuitBreaker.allowRequest()) throw circuitOpenError();
8438
+ _indexing = true;
8439
+ emitState();
8440
+ try {
8441
+ const result = await withMutex(() => {
8442
+ _currentFile = 0;
8443
+ _totalFiles = 0;
8444
+ _lastError = null;
8445
+ return callIndexOp(
8446
+ "index",
8447
+ {
8448
+ projectRoot: opts.projectRoot,
8449
+ indexDir: opts.indexDir,
8450
+ force: opts.force,
8451
+ langs: opts.langs
8452
+ },
8453
+ {
8454
+ timeoutMs: opts.timeoutMs ?? DEFAULT_FULL_INDEX_TIMEOUT_MS,
8455
+ signal: opts.signal,
8456
+ onProgress: setIndexProgress
8457
+ }
8458
+ );
8459
+ });
8460
+ _ready = true;
8461
+ indexCircuitBreaker.recordSuccess();
8462
+ return result;
8463
+ } catch (err) {
8464
+ _lastError = err instanceof Error ? err.message : String(err);
8465
+ if (isUniqueConstraintError(err) && !opts.force) {
8466
+ _lastError = null;
8467
+ const rebuildResult = await runStartupIndex({
8468
+ ...opts,
8469
+ force: true
8470
+ });
8471
+ _ready = true;
8472
+ return rebuildResult;
8473
+ }
8474
+ _ready = true;
8475
+ if (!opts.signal?.aborted) indexCircuitBreaker.recordFailure(err);
8476
+ throw err;
8477
+ } finally {
8478
+ _indexing = false;
8479
+ emitState();
8480
+ }
8481
+ }
8482
+ function enqueueReindex(opts) {
8483
+ const files = opts.files.filter(isIndexableFile);
8484
+ if (files.length === 0) return;
8485
+ const ms = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
8486
+ for (const file of files) {
8487
+ const key = debounceKey(opts.indexDir, file);
8488
+ const existing = debounceTimers.get(key);
8489
+ if (existing) clearTimeout(existing);
8490
+ const timer = setTimeout(() => {
8491
+ debounceTimers.delete(key);
8492
+ if (!indexCircuitBreaker.allowRequest()) {
8493
+ opts.onError?.(circuitOpenError());
8494
+ return;
8495
+ }
8496
+ void withMutex(
8497
+ () => callIndexOp(
8498
+ "index",
8499
+ { projectRoot: opts.projectRoot, files: [file], indexDir: opts.indexDir },
8500
+ { timeoutMs: opts.timeoutMs ?? DEFAULT_INCREMENTAL_TIMEOUT_MS }
8501
+ )
8502
+ ).then(
8503
+ () => indexCircuitBreaker.recordSuccess(),
8504
+ (err) => {
8505
+ indexCircuitBreaker.recordFailure(err);
8506
+ opts.onError?.(err);
8507
+ }
8508
+ );
8509
+ }, ms);
8510
+ timer.unref?.();
8511
+ debounceTimers.set(key, timer);
8512
+ }
8513
+ }
8514
+ function cancelPendingReindexes() {
8515
+ for (const t of debounceTimers.values()) clearTimeout(t);
8516
+ debounceTimers.clear();
8517
+ }
8518
+ async function searchCodebaseIndex(args, opts = {}) {
8519
+ return callIndexOp("search", args, {
8520
+ timeoutMs: opts.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
8521
+ signal: opts.signal
8522
+ });
8523
+ }
8524
+ async function codebaseIndexStats(args, opts = {}) {
8525
+ return callIndexOp("stats", args, {
8526
+ timeoutMs: opts.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
8527
+ signal: opts.signal
8528
+ });
8529
+ }
8530
+
7734
8531
  // src/codebase-index/codebase-index-tool.ts
7735
8532
  var codebaseIndexTool = {
7736
8533
  name: "codebase-index",
@@ -7766,103 +8563,24 @@ var codebaseIndexTool = {
7766
8563
  note: "A full index is already in progress. Retry codebase-index after it completes (check codebase-stats)."
7767
8564
  };
7768
8565
  }
7769
- const result = await runIndexer(ctx, {
8566
+ const circuit = indexCircuitBreaker.snapshot();
8567
+ if (circuit.state === "open" && circuit.cooldownRemainingMs > 0) {
8568
+ return {
8569
+ filesIndexed: 0,
8570
+ symbolsIndexed: 0,
8571
+ langStats: {},
8572
+ durationMs: 0,
8573
+ errors: [],
8574
+ note: `Codebase indexing is paused after repeated failures (last: ${circuit.lastFailure ?? "unknown"}). Auto-retry possible in ${Math.ceil(circuit.cooldownRemainingMs / 1e3)}s; the user can run /codebase-reindex to retry immediately.`
8575
+ };
8576
+ }
8577
+ return await runStartupIndex({
7770
8578
  projectRoot: ctx.projectRoot,
7771
8579
  force: input.force ?? false,
7772
8580
  langs: input.langs,
7773
8581
  indexDir: codebaseIndexDirOverride(ctx),
7774
8582
  signal: execOpts?.signal
7775
8583
  });
7776
- setIndexReady();
7777
- return result;
7778
- }
7779
- };
7780
-
7781
- // src/codebase-index/bm25.ts
7782
- var K1 = 1.5;
7783
- var B = 0.75;
7784
- function tokenise(text) {
7785
- const sanitised = text.replace(/[^\p{L}\p{N}$'_]/gu, " ").replace(/_/g, " ");
7786
- return sanitised.toLowerCase().split(" ").filter(Boolean);
7787
- }
7788
- function splitName(name) {
7789
- return name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
7790
- }
7791
- function buildIndexableText(name, signature, docComment) {
7792
- return [splitName(name), name, signature, docComment].filter(Boolean).join(" ");
7793
- }
7794
- function buildBm25Index(docs) {
7795
- const documents = docs.map((d) => {
7796
- const tokens = tokenise(d.text);
7797
- return { id: d.id, tokens, raw: d.text, len: tokens.length };
7798
- });
7799
- const df = {};
7800
- for (const doc of documents) {
7801
- const seen = /* @__PURE__ */ new Set();
7802
- for (const t of doc.tokens) {
7803
- if (!seen.has(t)) {
7804
- df[t] = (df[t] ?? 0) + 1;
7805
- seen.add(t);
7806
- }
7807
- }
7808
- }
7809
- const N = documents.length;
7810
- const totalLen = documents.reduce((sum, d) => sum + d.len, 0);
7811
- const avgLen = N === 0 ? 0 : totalLen / N;
7812
- return new Bm25Index(documents, df, N, avgLen);
7813
- }
7814
- var Bm25Index = class {
7815
- constructor(documents, df, N, avgLen) {
7816
- this.documents = documents;
7817
- this.df = df;
7818
- this.N = N;
7819
- this.safeAvgLen = avgLen === 0 ? 1 : avgLen;
7820
- }
7821
- documents;
7822
- df;
7823
- N;
7824
- safeAvgLen;
7825
- score(query2, filter) {
7826
- const qTokens = tokenise(query2);
7827
- if (qTokens.length === 0) return [];
7828
- const results = [];
7829
- for (const doc of this.documents) {
7830
- if (filter && !filter(doc.id)) continue;
7831
- let docScore = 0;
7832
- for (const qTerm of qTokens) {
7833
- let tf = 0;
7834
- for (const t of doc.tokens) {
7835
- if (t === qTerm) tf++;
7836
- }
7837
- if (tf === 0) continue;
7838
- const dfVal = this.df[qTerm] ?? 0;
7839
- if (dfVal === 0) continue;
7840
- const idf = Math.log((this.N - dfVal + 0.5) / (dfVal + 0.5) + 1);
7841
- const lenRatio = B * (doc.len / this.safeAvgLen);
7842
- const tfComponent = tf * (K1 + 1) / (tf + K1 * (1 - B + lenRatio));
7843
- docScore += idf * tfComponent;
7844
- }
7845
- if (docScore > 0) results.push({ id: doc.id, score: docScore });
7846
- }
7847
- return results;
7848
- }
7849
- getDoc(id) {
7850
- return this.documents.find((d) => d.id === id);
7851
- }
7852
- extractSnippet(docId, queryTokens, radius = 40) {
7853
- const doc = this.getDoc(docId);
7854
- if (!doc) return "";
7855
- for (const tok of queryTokens) {
7856
- const idx = doc.raw.toLowerCase().indexOf(tok);
7857
- if (idx !== -1) {
7858
- const start = Math.max(0, idx - radius);
7859
- const end = Math.min(doc.raw.length, idx + tok.length + radius);
7860
- const excerpt = doc.raw.slice(start, end);
7861
- const ellipsis = "\u2026";
7862
- return (start > 0 ? ellipsis : "") + excerpt + (end < doc.raw.length ? ellipsis : "");
7863
- }
7864
- }
7865
- return doc.raw.slice(0, radius * 2) + (doc.raw.length > radius * 2 ? "\u2026" : "");
7866
8584
  }
7867
8585
  };
7868
8586
 
@@ -7908,7 +8626,7 @@ var codebaseSearchTool = {
7908
8626
  },
7909
8627
  required: ["query"]
7910
8628
  },
7911
- async execute(input, ctx) {
8629
+ async execute(input, ctx, execOpts) {
7912
8630
  const state = getIndexState();
7913
8631
  if (!state.ready) {
7914
8632
  return {
@@ -7927,51 +8645,30 @@ var codebaseSearchTool = {
7927
8645
  };
7928
8646
  }
7929
8647
  if (state.lastError) {
8648
+ const circuit = state.circuit;
8649
+ const retryHint = circuit.state === "open" ? `Indexing is paused (circuit open, retry in ${Math.ceil(circuit.cooldownRemainingMs / 1e3)}s); the user can run /codebase-reindex to retry now.` : "Try /codebase-reindex.";
7930
8650
  return {
7931
8651
  results: [],
7932
8652
  total: 0,
7933
8653
  query: input.query,
7934
- indexStatus: `Index build failed: ${state.lastError}. Try /codebase-reindex.`
8654
+ indexStatus: `Index build failed: ${state.lastError}. ${retryHint}`
7935
8655
  };
7936
8656
  }
7937
- const store = new IndexStore(ctx.projectRoot, { indexDir: codebaseIndexDirOverride(ctx) });
7938
- try {
7939
- const limit = Math.min(input.limit ?? 20, 100);
7940
- const candidates = store.search(input.query, {
8657
+ const limit = Math.min(input.limit ?? 20, 100);
8658
+ const { results, total } = await searchCodebaseIndex(
8659
+ {
8660
+ projectRoot: ctx.projectRoot,
8661
+ indexDir: codebaseIndexDirOverride(ctx),
8662
+ query: input.query,
7941
8663
  kind: input.kind,
7942
8664
  lang: input.lang,
7943
8665
  file: input.file,
7944
- lspKind: input.lspKind
7945
- });
7946
- if (candidates.length === 0) {
7947
- return { results: [], total: 0, query: input.query };
7948
- }
7949
- const indexable = candidates.map((c) => ({
7950
- id: c.id,
7951
- text: buildIndexableText(c.name, c.signature, c.docComment)
7952
- }));
7953
- const bm25 = buildBm25Index(indexable);
7954
- const scored = bm25.score(input.query, (id) => candidates.some((c) => c.id === id));
7955
- scored.sort((a, b) => b.score - a.score);
7956
- const top = scored.slice(0, limit);
7957
- const qTokens = tokenise(input.query);
7958
- const results = top.map(({ id, score }) => {
7959
- const c = expectDefined(candidates.find((c2) => c2.id === id));
7960
- const snippet = bm25.extractSnippet(id, qTokens);
7961
- return {
7962
- ...c,
7963
- score,
7964
- snippet
7965
- };
7966
- });
7967
- return {
7968
- results,
7969
- total: candidates.length,
7970
- query: input.query
7971
- };
7972
- } finally {
7973
- store.close();
7974
- }
8666
+ lspKind: input.lspKind,
8667
+ limit
8668
+ },
8669
+ { signal: execOpts?.signal }
8670
+ );
8671
+ return { results, total, query: input.query };
7975
8672
  }
7976
8673
  };
7977
8674
 
@@ -7990,7 +8687,7 @@ var codebaseStatsTool = {
7990
8687
  properties: {},
7991
8688
  additionalProperties: false
7992
8689
  },
7993
- async execute(_input, ctx) {
8690
+ async execute(_input, ctx, execOpts) {
7994
8691
  const idxState = getIndexState();
7995
8692
  if (!idxState.ready) {
7996
8693
  return {
@@ -8005,34 +8702,30 @@ var codebaseStatsTool = {
8005
8702
  indexStatus: idxState.indexing ? `Indexing in progress (${idxState.currentFile}/${idxState.totalFiles} files).` : "Index not yet built."
8006
8703
  };
8007
8704
  }
8705
+ const stats = await codebaseIndexStats(
8706
+ { projectRoot: ctx.projectRoot, indexDir: codebaseIndexDirOverride(ctx) },
8707
+ { signal: execOpts?.signal }
8708
+ );
8008
8709
  if (idxState.indexing) {
8009
- const store2 = new IndexStore(ctx.projectRoot, { indexDir: codebaseIndexDirOverride(ctx) });
8010
- try {
8011
- const stats = store2.getStats();
8012
- return {
8013
- ...stats,
8014
- indexStatus: `Index refresh in progress (${idxState.currentFile}/${idxState.totalFiles} files). Stats may be incomplete.`
8015
- };
8016
- } finally {
8017
- store2.close();
8018
- }
8019
- }
8020
- const store = new IndexStore(ctx.projectRoot, { indexDir: codebaseIndexDirOverride(ctx) });
8021
- try {
8022
- const stats = store.getStats();
8023
8710
  return {
8024
- totalSymbols: stats.totalSymbols,
8025
- totalFiles: stats.totalFiles,
8026
- byLang: stats.byLang,
8027
- byKind: stats.byKind,
8028
- lastIndexed: stats.lastIndexed,
8029
- sizeBytes: stats.sizeBytes,
8030
- indexPath: stats.indexPath,
8031
- version: stats.version
8711
+ ...stats,
8712
+ indexStatus: `Index refresh in progress (${idxState.currentFile}/${idxState.totalFiles} files). Stats may be incomplete.`
8032
8713
  };
8033
- } finally {
8034
- store.close();
8035
8714
  }
8715
+ const circuit = idxState.circuit;
8716
+ return {
8717
+ totalSymbols: stats.totalSymbols,
8718
+ totalFiles: stats.totalFiles,
8719
+ byLang: stats.byLang,
8720
+ byKind: stats.byKind,
8721
+ lastIndexed: stats.lastIndexed,
8722
+ sizeBytes: stats.sizeBytes,
8723
+ indexPath: stats.indexPath,
8724
+ version: stats.version,
8725
+ ...circuit.state === "open" ? {
8726
+ indexStatus: `Indexing is paused after repeated failures (last: ${circuit.lastFailure ?? "unknown"}); auto-retry in ${Math.ceil(circuit.cooldownRemainingMs / 1e3)}s, or run /codebase-reindex. Stats reflect the last successful build.`
8727
+ } : {}
8728
+ };
8036
8729
  }
8037
8730
  };
8038
8731
  var setWorkingDirTool = {
@@ -8430,6 +9123,6 @@ var builtinToolsPack = {
8430
9123
  tools: builtinTools
8431
9124
  };
8432
9125
 
8433
- export { CircuitBreaker, _resetProcessRegistry, auditTool, bashTool, batchToolUseTool, builtinTools, builtinToolsPack, cancelPendingReindexes, codebaseIndexTool, codebaseSearchTool, codebaseStatsTool, createModeTool, diffTool, documentTool, editTool, enqueueReindex, execTool, fetchTool, forgetTool, formatTool, getIndexState, getProcessRegistry, gitTool, globTool, grepTool, installTool, isIndexReady, isIndexableFile, isIndexing, jsonTool, lintTool, logsTool, onIndexStateChange, outdatedTool, patchTool, planTool, readTool, relatedMemoryTool, rememberTool, replaceTool, runStartupIndex, scaffoldTool, searchMemoryTool, searchTool, testTool, todoTool, toolHelpTool, toolSearchTool, toolUseTool, treeTool, typecheckTool, writeTool };
9126
+ export { CircuitBreaker, CircuitOpenError, IndexCircuitBreaker, IndexTimeoutError, _resetProcessRegistry, auditTool, bashTool, batchToolUseTool, builtinTools, builtinToolsPack, cancelPendingReindexes, codebaseIndexStats, codebaseIndexTool, codebaseSearchTool, codebaseStatsTool, createModeTool, diffTool, documentTool, editTool, enqueueReindex, execTool, fetchTool, forgetTool, formatTool, getIndexState, getProcessRegistry, gitTool, globTool, grepTool, indexCircuitBreaker, installTool, isIndexReady, isIndexableFile, isIndexing, jsonTool, lintTool, logsTool, onIndexStateChange, outdatedTool, patchTool, planTool, readTool, relatedMemoryTool, rememberTool, replaceTool, resetIndexCircuitBreaker, runStartupIndex, scaffoldTool, searchCodebaseIndex, searchMemoryTool, searchTool, shutdownCodebaseIndexHost, testTool, todoTool, toolHelpTool, toolSearchTool, toolUseTool, treeTool, typecheckTool, writeTool };
8434
9127
  //# sourceMappingURL=index.js.map
8435
9128
  //# sourceMappingURL=index.js.map