@wrongstack/tools 0.155.0 → 0.250.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 (71) hide show
  1. package/dist/audit.js +22 -1
  2. package/dist/audit.js.map +1 -1
  3. package/dist/background-indexer-DwJsyAB0.d.ts +373 -0
  4. package/dist/bash.js +121 -24
  5. package/dist/bash.js.map +1 -1
  6. package/dist/builtin.js +1553 -544
  7. package/dist/builtin.js.map +1 -1
  8. package/dist/circuit-breaker.d.ts +9 -2
  9. package/dist/circuit-breaker.js +11 -2
  10. package/dist/circuit-breaker.js.map +1 -1
  11. package/dist/codebase-index/index.d.ts +53 -2
  12. package/dist/codebase-index/index.js +866 -367
  13. package/dist/codebase-index/index.js.map +1 -1
  14. package/dist/codebase-index/worker.d.ts +2 -0
  15. package/dist/codebase-index/worker.js +2321 -0
  16. package/dist/codebase-index/worker.js.map +1 -0
  17. package/dist/diff.js +3 -2
  18. package/dist/diff.js.map +1 -1
  19. package/dist/document.js +1 -1
  20. package/dist/document.js.map +1 -1
  21. package/dist/edit.js +1 -1
  22. package/dist/edit.js.map +1 -1
  23. package/dist/exec.js +61 -11
  24. package/dist/exec.js.map +1 -1
  25. package/dist/fetch.js.map +1 -1
  26. package/dist/format.js +22 -1
  27. package/dist/format.js.map +1 -1
  28. package/dist/git.js +2 -1
  29. package/dist/git.js.map +1 -1
  30. package/dist/glob.js +1 -1
  31. package/dist/glob.js.map +1 -1
  32. package/dist/grep.js +3 -3
  33. package/dist/grep.js.map +1 -1
  34. package/dist/index.d.ts +5 -4
  35. package/dist/index.js +1593 -622
  36. package/dist/index.js.map +1 -1
  37. package/dist/install.js +66 -14
  38. package/dist/install.js.map +1 -1
  39. package/dist/lint.js +22 -1
  40. package/dist/lint.js.map +1 -1
  41. package/dist/logs.js +2 -2
  42. package/dist/logs.js.map +1 -1
  43. package/dist/outdated.js +2 -2
  44. package/dist/outdated.js.map +1 -1
  45. package/dist/pack.js +1553 -544
  46. package/dist/pack.js.map +1 -1
  47. package/dist/patch.js +2 -2
  48. package/dist/patch.js.map +1 -1
  49. package/dist/process-registry.d.ts +21 -16
  50. package/dist/process-registry.js +48 -10
  51. package/dist/process-registry.js.map +1 -1
  52. package/dist/read.js +1 -1
  53. package/dist/read.js.map +1 -1
  54. package/dist/replace.js +4 -3
  55. package/dist/replace.js.map +1 -1
  56. package/dist/scaffold.js +1 -1
  57. package/dist/scaffold.js.map +1 -1
  58. package/dist/search.js +19 -16
  59. package/dist/search.js.map +1 -1
  60. package/dist/test.js +22 -1
  61. package/dist/test.js.map +1 -1
  62. package/dist/todo.js +44 -0
  63. package/dist/todo.js.map +1 -1
  64. package/dist/tree.js +1 -1
  65. package/dist/tree.js.map +1 -1
  66. package/dist/typecheck.js +22 -1
  67. package/dist/typecheck.js.map +1 -1
  68. package/dist/write.js +1 -1
  69. package/dist/write.js.map +1 -1
  70. package/package.json +5 -5
  71. package/dist/background-indexer-CtbgPExj.d.ts +0 -228
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import * as fs4 from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
- import { resolve, sep, dirname } from 'node:path';
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, emptyPlan, clearPlan, savePlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, setPlanItemStatus, formatPlan, loadTasks, emptyTaskFile, saveTasks, computeTaskItemProgress, formatTaskList, 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, 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';
@@ -11,7 +11,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';
17
+ import { randomUUID } from 'node:crypto';
15
18
 
16
19
  // src/read.ts
17
20
  async function detectPackageManager(cwd) {
@@ -29,7 +32,7 @@ async function detectPackageManager(cwd) {
29
32
  return "npm";
30
33
  }
31
34
  function resolvePath(input, ctx) {
32
- return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.cwd, input);
35
+ return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);
33
36
  }
34
37
  function ensureInsideRoot(absPath, ctx) {
35
38
  const root = path.resolve(ctx.projectRoot);
@@ -598,7 +601,7 @@ async function globFiles(pattern, base, extraGlob) {
598
601
  function checkRg() {
599
602
  return new Promise((resolve7) => {
600
603
  try {
601
- const p = spawn("rg", ["--version"], { env: buildChildEnv(), stdio: "ignore" });
604
+ const p = spawn("rg", ["--version"], { env: buildChildEnv(), stdio: "ignore", windowsHide: true });
602
605
  p.on("error", () => resolve7(false));
603
606
  p.on("close", (code) => resolve7(code === 0));
604
607
  } catch {
@@ -611,7 +614,8 @@ function spawnRgFind(pattern, base) {
611
614
  const child = spawn("rg", args, {
612
615
  signal: AbortSignal.timeout(3e4),
613
616
  env: buildChildEnv(),
614
- stdio: ["ignore", "pipe", "pipe"]
617
+ stdio: ["ignore", "pipe", "pipe"],
618
+ windowsHide: true
615
619
  });
616
620
  let buf = "";
617
621
  child.stdout?.on("data", (chunk) => {
@@ -827,7 +831,7 @@ var grepTool = {
827
831
  async function detectRg(signal) {
828
832
  return new Promise((resolve7) => {
829
833
  try {
830
- const p = spawn("rg", ["--version"], { env: buildChildEnv(), stdio: "ignore", signal });
834
+ const p = spawn("rg", ["--version"], { env: buildChildEnv(), stdio: "ignore", signal, windowsHide: true });
831
835
  p.on("error", () => resolve7(false));
832
836
  p.on("close", (code) => resolve7(code === 0));
833
837
  } catch {
@@ -857,7 +861,7 @@ async function* runRgStream(input, base, mode, limit, signal) {
857
861
  const FLUSH_AT = 16;
858
862
  const MAX_BUF_BYTES = 1e6;
859
863
  let bufOverflow = false;
860
- 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 });
861
865
  const queue = [];
862
866
  let waiter;
863
867
  const wake = () => {
@@ -1098,8 +1102,13 @@ var CircuitBreaker = class {
1098
1102
  * Call this BEFORE spawning a bash/exec process.
1099
1103
  * Returns true if the call is allowed; false if the breaker is open.
1100
1104
  * When false, callers MUST NOT spawn a process.
1105
+ *
1106
+ * @param bypass - If true, skip the circuit breaker check entirely.
1107
+ * Use for background/fire-and-forget processes that should
1108
+ * not affect breaker state.
1101
1109
  */
1102
- beforeCall() {
1110
+ beforeCall(bypass = false) {
1111
+ if (bypass) return true;
1103
1112
  this._checkStateTransition();
1104
1113
  if (this.state === "open") return false;
1105
1114
  return true;
@@ -1109,8 +1118,12 @@ var CircuitBreaker = class {
1109
1118
  * `durationMs` is the wall-clock time the process ran.
1110
1119
  * `failed` is true when the process returned a non-zero exit code or
1111
1120
  * threw an exception before spawning.
1121
+ *
1122
+ * @param bypass - If true, do not update breaker state.
1123
+ * Use for background/fire-and-forget processes.
1112
1124
  */
1113
- afterCall(durationMs, failed) {
1125
+ afterCall(durationMs, failed, bypass = false) {
1126
+ if (bypass) return;
1114
1127
  const now = Date.now();
1115
1128
  if (this.state === "half-open") {
1116
1129
  if (failed) {
@@ -1209,6 +1222,17 @@ function redactCommand(cmd) {
1209
1222
  return result;
1210
1223
  }
1211
1224
  var DEFAULT_GRACE_MS = 2e3;
1225
+ function killWin32Tree(pid) {
1226
+ try {
1227
+ spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
1228
+ stdio: "ignore",
1229
+ windowsHide: true
1230
+ }).unref();
1231
+ return true;
1232
+ } catch {
1233
+ return false;
1234
+ }
1235
+ }
1212
1236
  var ProcessRegistryImpl = class {
1213
1237
  processes = /* @__PURE__ */ new Map();
1214
1238
  breaker;
@@ -1266,16 +1290,20 @@ var ProcessRegistryImpl = class {
1266
1290
  /**
1267
1291
  * Called before spawning a process. Returns true if allowed; false if
1268
1292
  * the circuit breaker is open.
1293
+ *
1294
+ * @param bypass - If true, skip circuit breaker check (for background processes).
1269
1295
  */
1270
- beforeCall() {
1271
- return this.breaker.beforeCall();
1296
+ beforeCall(bypass = false) {
1297
+ return this.breaker.beforeCall(bypass);
1272
1298
  }
1273
1299
  /**
1274
1300
  * Called after a process finishes. `durationMs` is wall-clock time;
1275
1301
  * `failed` is true for non-zero exit codes.
1302
+ *
1303
+ * @param bypass - If true, do not update circuit breaker state (for background processes).
1276
1304
  */
1277
- afterCall(durationMs, failed) {
1278
- this.breaker.afterCall(durationMs, failed);
1305
+ afterCall(durationMs, failed, bypass = false) {
1306
+ this.breaker.afterCall(durationMs, failed, bypass);
1279
1307
  }
1280
1308
  /** Force-open the circuit breaker (Ctrl+C, /kill force). */
1281
1309
  forceBreakerOpen() {
@@ -1306,9 +1334,22 @@ var ProcessRegistryImpl = class {
1306
1334
  const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;
1307
1335
  const isWin = os.platform() === "win32";
1308
1336
  if (isWin) {
1309
- try {
1310
- p.child.kill(force ? "SIGKILL" : "SIGTERM");
1311
- } catch {
1337
+ const liveRealChild = p.child.exitCode === null && typeof p.child.pid === "number";
1338
+ if (liveRealChild && killWin32Tree(pid)) {
1339
+ const fallback = setTimeout(() => {
1340
+ if (p.child.exitCode === null) {
1341
+ try {
1342
+ p.child.kill("SIGKILL");
1343
+ } catch {
1344
+ }
1345
+ }
1346
+ }, graceMs);
1347
+ fallback.unref?.();
1348
+ } else {
1349
+ try {
1350
+ p.child.kill(force ? "SIGKILL" : "SIGTERM");
1351
+ } catch {
1352
+ }
1312
1353
  }
1313
1354
  p.killed = true;
1314
1355
  return true;
@@ -1387,6 +1428,7 @@ var MAX_OUTPUT = 32768;
1387
1428
  var DEFAULT_TIMEOUT_MS = 3e5;
1388
1429
  var STREAM_FLUSH_INTERVAL_MS = 200;
1389
1430
  var STREAM_FLUSH_BYTES = 4 * 1024;
1431
+ var MAX_QUEUE_CHUNKS = 500;
1390
1432
  var bashTool = {
1391
1433
  name: "bash",
1392
1434
  category: "Shell",
@@ -1434,7 +1476,8 @@ var bashTool = {
1434
1476
  async *executeStream(input, ctx, opts) {
1435
1477
  if (!input?.command) throw new Error("bash: command is required");
1436
1478
  const registry = getProcessRegistry();
1437
- if (!registry.beforeCall()) {
1479
+ const bypassBreaker = !!input.background;
1480
+ if (!registry.beforeCall(bypassBreaker)) {
1438
1481
  yield {
1439
1482
  type: "final",
1440
1483
  output: {
@@ -1447,6 +1490,17 @@ var bashTool = {
1447
1490
  };
1448
1491
  return;
1449
1492
  }
1493
+ const PIPE_TO_SHELL_PATTERN = /\|\s*(sh|bash|ksh|zsh|fish|cmd|powershell|pwsh)/i;
1494
+ if (PIPE_TO_SHELL_PATTERN.test(input.command)) {
1495
+ console.warn(JSON.stringify({
1496
+ level: "warn",
1497
+ event: "bash.pipe_to_shell_detected",
1498
+ message: "Detected pipe-to-shell pattern. Consider reviewing the full command before confirming.",
1499
+ command_prefix: input.command.slice(0, 100),
1500
+ // Log first 100 chars for review
1501
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1502
+ }));
1503
+ }
1450
1504
  const timeoutMs = Math.max(1, Math.min(input.timeout_ms ?? DEFAULT_TIMEOUT_MS, 6e5));
1451
1505
  const isWin = os.platform() === "win32";
1452
1506
  const shell = (() => {
@@ -1472,6 +1526,10 @@ var bashTool = {
1472
1526
  env,
1473
1527
  stdio: ["ignore", "pipe", "pipe"],
1474
1528
  detached: true,
1529
+ // Detached console children on Windows allocate their own VISIBLE
1530
+ // console window (one per background command — test suites flash
1531
+ // dozens). CREATE_NO_WINDOW suppresses it; no-op elsewhere.
1532
+ windowsHide: true,
1475
1533
  signal: opts.signal
1476
1534
  });
1477
1535
  const pid2 = child2.pid;
@@ -1505,7 +1563,7 @@ var bashTool = {
1505
1563
  }
1506
1564
  });
1507
1565
  child2.on("close", () => {
1508
- registry.afterCall(Date.now() - startedAt, false);
1566
+ registry.afterCall(Date.now() - startedAt, false, bypassBreaker);
1509
1567
  });
1510
1568
  if (typeof pid2 === "number") child2.unref();
1511
1569
  yield {
@@ -1524,7 +1582,8 @@ var bashTool = {
1524
1582
  env,
1525
1583
  stdio: ["ignore", "pipe", "pipe"],
1526
1584
  detached,
1527
- signal: opts.signal
1585
+ windowsHide: true,
1586
+ ...isWin ? {} : { signal: opts.signal }
1528
1587
  });
1529
1588
  const pid = child.pid;
1530
1589
  if (typeof pid === "number") {
@@ -1538,14 +1597,27 @@ var bashTool = {
1538
1597
  });
1539
1598
  }
1540
1599
  let buf = "";
1541
- let pending = "";
1600
+ let pending2 = "";
1542
1601
  let timedOut = false;
1543
1602
  const timers = [];
1544
1603
  function killWithTimeout(child2, timeoutMs2) {
1545
1604
  if (isWin) {
1546
- try {
1547
- child2.kill();
1548
- } catch {
1605
+ if (typeof child2.pid === "number" && child2.exitCode === null && killWin32Tree(child2.pid)) {
1606
+ const fallback = setTimeout(() => {
1607
+ if (child2.exitCode === null) {
1608
+ try {
1609
+ child2.kill();
1610
+ } catch {
1611
+ }
1612
+ }
1613
+ }, 2e3);
1614
+ timers.push(fallback);
1615
+ fallback.unref?.();
1616
+ } else {
1617
+ try {
1618
+ child2.kill();
1619
+ } catch {
1620
+ }
1549
1621
  }
1550
1622
  return;
1551
1623
  }
@@ -1584,6 +1656,11 @@ var bashTool = {
1584
1656
  }, timeoutMs);
1585
1657
  timers.push(timer);
1586
1658
  timer.unref?.();
1659
+ const onAbort = () => killWithTimeout(child, 2e3);
1660
+ if (isWin) {
1661
+ if (opts.signal.aborted) onAbort();
1662
+ else opts.signal.addEventListener("abort", onAbort, { once: true });
1663
+ }
1587
1664
  const queue = [];
1588
1665
  let resolveNext = null;
1589
1666
  const push = (c) => {
@@ -1602,24 +1679,38 @@ var bashTool = {
1602
1679
  });
1603
1680
  let lastFlush = Date.now();
1604
1681
  const flush = () => {
1605
- if (pending.length === 0) return null;
1606
- const text = pending;
1607
- pending = "";
1682
+ if (pending2.length === 0) return null;
1683
+ const text = pending2;
1684
+ pending2 = "";
1608
1685
  lastFlush = Date.now();
1609
1686
  return text;
1610
1687
  };
1611
- child.stdout?.on("data", (chunk) => {
1612
- const text = chunk.toString();
1613
- buf += text;
1614
- pending += text;
1615
- push({ kind: "data", text });
1616
- });
1617
- child.stderr?.on("data", (chunk) => {
1688
+ let paused = false;
1689
+ const pauseIfFlooded = () => {
1690
+ if (!paused && queue.length >= MAX_QUEUE_CHUNKS) {
1691
+ paused = true;
1692
+ child.stdout?.pause();
1693
+ child.stderr?.pause();
1694
+ }
1695
+ };
1696
+ const resumeIfDrained = () => {
1697
+ if (paused && queue.length < MAX_QUEUE_CHUNKS) {
1698
+ paused = false;
1699
+ child.stdout?.resume();
1700
+ child.stderr?.resume();
1701
+ }
1702
+ };
1703
+ const onData = (chunk) => {
1618
1704
  const text = chunk.toString();
1619
- buf += text;
1620
- pending += text;
1705
+ if (buf.length < MAX_OUTPUT) {
1706
+ buf += text.slice(0, MAX_OUTPUT - buf.length);
1707
+ }
1708
+ pending2 += text;
1621
1709
  push({ kind: "data", text });
1622
- });
1710
+ pauseIfFlooded();
1711
+ };
1712
+ child.stdout?.on("data", onData);
1713
+ child.stderr?.on("data", onData);
1623
1714
  child.on("error", (err) => {
1624
1715
  for (const t of timers) clearTimeout(t);
1625
1716
  registry.afterCall(Date.now() - startedAt, true);
@@ -1634,6 +1725,7 @@ var bashTool = {
1634
1725
  try {
1635
1726
  while (true) {
1636
1727
  const c = await next();
1728
+ resumeIfDrained();
1637
1729
  if (c.kind === "error") throw c.err;
1638
1730
  if (c.kind === "end") {
1639
1731
  const remainder = flush();
@@ -1651,13 +1743,22 @@ var bashTool = {
1651
1743
  return;
1652
1744
  }
1653
1745
  const now = Date.now();
1654
- if (pending.length >= STREAM_FLUSH_BYTES || now - lastFlush >= STREAM_FLUSH_INTERVAL_MS) {
1746
+ if (pending2.length >= STREAM_FLUSH_BYTES || now - lastFlush >= STREAM_FLUSH_INTERVAL_MS) {
1655
1747
  const text = flush();
1656
1748
  if (text) yield { type: "partial_output", text };
1657
1749
  }
1658
1750
  }
1659
1751
  } finally {
1660
1752
  for (const t of timers) clearTimeout(t);
1753
+ if (isWin) opts.signal.removeEventListener("abort", onAbort);
1754
+ child.stdout?.off("data", onData);
1755
+ child.stderr?.off("data", onData);
1756
+ child.stdout?.destroy();
1757
+ child.stderr?.destroy();
1758
+ if (child.exitCode === null && !child.killed) {
1759
+ if (typeof pid === "number") registry.kill(pid, { force: true });
1760
+ else killWithTimeout(child, 2e3);
1761
+ }
1661
1762
  }
1662
1763
  }
1663
1764
  };
@@ -1890,12 +1991,14 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
1890
1991
  let killed = false;
1891
1992
  const startedAt = Date.now();
1892
1993
  const resolved = resolveWin32Command(cmd);
1893
- const needsShell = process.platform === "win32" && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
1994
+ const isWin = process.platform === "win32";
1995
+ const needsShell = isWin && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
1894
1996
  const child = spawn(resolved, args, {
1895
1997
  cwd,
1896
- signal,
1897
1998
  env: buildChildEnv(sessionId),
1898
1999
  stdio: ["ignore", "pipe", "pipe"],
2000
+ windowsHide: true,
2001
+ ...isWin ? {} : { signal },
1899
2002
  ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {}
1900
2003
  });
1901
2004
  const registry = getProcessRegistry();
@@ -1909,6 +2012,15 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
1909
2012
  if (typeof pid === "number") registry.kill(pid);
1910
2013
  else child.kill("SIGTERM");
1911
2014
  }, timeout);
2015
+ const onAbort = () => {
2016
+ killed = true;
2017
+ if (typeof pid === "number") registry.kill(pid, { force: true });
2018
+ else child.kill("SIGTERM");
2019
+ };
2020
+ if (isWin) {
2021
+ if (signal.aborted) onAbort();
2022
+ else signal.addEventListener("abort", onAbort, { once: true });
2023
+ }
1912
2024
  child.stdout?.on("data", (chunk) => {
1913
2025
  if (stdout.length < MAX_OUTPUT2) stdout += chunk.toString();
1914
2026
  });
@@ -1917,6 +2029,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
1917
2029
  });
1918
2030
  child.on("close", (code) => {
1919
2031
  clearTimeout(timer);
2032
+ if (isWin) signal.removeEventListener("abort", onAbort);
1920
2033
  if (typeof pid === "number") registry.unregister(pid);
1921
2034
  const durationMs = Date.now() - startedAt;
1922
2035
  const exitCode = killed ? 124 : code ?? 1;
@@ -1933,6 +2046,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
1933
2046
  });
1934
2047
  child.on("error", (err) => {
1935
2048
  clearTimeout(timer);
2049
+ if (isWin) signal.removeEventListener("abort", onAbort);
1936
2050
  if (typeof pid === "number") registry.unregister(pid);
1937
2051
  registry.afterCall(Date.now() - startedAt, true);
1938
2052
  resolve7({
@@ -2350,13 +2464,24 @@ var searchTool = {
2350
2464
  async function duckduckgoSearch(query2, num, signal) {
2351
2465
  const encoded = encodeURIComponent(query2);
2352
2466
  const url = `https://lite.duckduckgo.com/lite/?q=${encoded}&kd=-1&kl=wt-wt`;
2353
- const results = await fetchWithTimeout(url, signal, TIMEOUT_MS2).then((r) => r.text()).then((html) => parseDuckDuckGo(html, num)).catch(() => [{ title: "Search unavailable", url: "", snippet: "Could not reach DuckDuckGo" }]);
2354
- return {
2355
- query: query2,
2356
- results,
2357
- source: "duckduckgo",
2358
- truncated: results.length >= num
2359
- };
2467
+ try {
2468
+ const response = await fetchWithTimeout(url, signal, TIMEOUT_MS2);
2469
+ const html = await response.text();
2470
+ const results = parseDuckDuckGo(html, num);
2471
+ return {
2472
+ query: query2,
2473
+ results,
2474
+ source: "duckduckgo",
2475
+ truncated: results.length >= num
2476
+ };
2477
+ } catch {
2478
+ return {
2479
+ query: query2,
2480
+ results: [{ title: "Search unavailable", url: "", snippet: "Could not reach DuckDuckGo" }],
2481
+ source: "duckduckgo",
2482
+ truncated: false
2483
+ };
2484
+ }
2360
2485
  }
2361
2486
  function takeFrom(iter, max) {
2362
2487
  const out = [];
@@ -2475,21 +2600,11 @@ async function fetchWithTimeout(url, signal, timeoutMs) {
2475
2600
  }
2476
2601
  }
2477
2602
  function anySignal(...signals) {
2478
- const controller = new AbortController();
2479
- for (const s of signals) {
2480
- if (s.aborted) {
2481
- controller.abort();
2482
- break;
2483
- }
2484
- s.addEventListener("abort", () => controller.abort());
2485
- }
2486
- return controller.signal;
2603
+ return AbortSignal.any(signals);
2487
2604
  }
2488
2605
  function stripTags2(html) {
2489
2606
  return html.replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").trim();
2490
2607
  }
2491
-
2492
- // src/todo.ts
2493
2608
  var todoTool = {
2494
2609
  name: "todo",
2495
2610
  category: "Session",
@@ -2548,6 +2663,48 @@ var todoTool = {
2548
2663
  }
2549
2664
  }
2550
2665
  ctx.state.replaceTodos(items);
2666
+ const completedPlanIds = /* @__PURE__ */ new Set();
2667
+ const completedTaskIds = /* @__PURE__ */ new Set();
2668
+ const pendingPlanIds = /* @__PURE__ */ new Set();
2669
+ const pendingTaskIds = /* @__PURE__ */ new Set();
2670
+ for (const item of items) {
2671
+ if (item.promotedFromPlan) {
2672
+ (item.status === "completed" ? completedPlanIds : pendingPlanIds).add(item.promotedFromPlan);
2673
+ }
2674
+ if (item.promotedFromTask) {
2675
+ (item.status === "completed" ? completedTaskIds : pendingTaskIds).add(item.promotedFromTask);
2676
+ }
2677
+ }
2678
+ for (const planId of completedPlanIds) {
2679
+ if (pendingPlanIds.has(planId)) continue;
2680
+ const planPath = ctx.meta["plan.path"];
2681
+ if (typeof planPath !== "string" || !planPath) continue;
2682
+ try {
2683
+ const plan = await loadPlan(planPath);
2684
+ if (plan) {
2685
+ const updated = setPlanItemStatus(plan, planId, "done");
2686
+ await savePlan(planPath, updated);
2687
+ }
2688
+ } catch {
2689
+ }
2690
+ }
2691
+ for (const taskId of completedTaskIds) {
2692
+ if (pendingTaskIds.has(taskId)) continue;
2693
+ const taskPath = ctx.meta["task.path"];
2694
+ if (typeof taskPath !== "string" || !taskPath) continue;
2695
+ try {
2696
+ const file = await loadTasks(taskPath);
2697
+ if (file) {
2698
+ const task = file.tasks.find((t) => t.id === taskId);
2699
+ if (task && task.status !== "completed") {
2700
+ task.status = "completed";
2701
+ task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2702
+ await saveTasks(taskPath, file);
2703
+ }
2704
+ }
2705
+ } catch {
2706
+ }
2707
+ }
2551
2708
  return {
2552
2709
  count: items.length,
2553
2710
  in_progress: items.filter((t) => t.status === "in_progress").length
@@ -2558,7 +2715,7 @@ var planTool = {
2558
2715
  name: "plan",
2559
2716
  category: "Session",
2560
2717
  description: "Manage a persistent strategic plan for the current session. Unlike todos, plans are meant for higher-level, multi-phase approaches and survive across conversation resumptions. Use this to outline big-picture work, then promote concrete items into the todo list when ready to execute.",
2561
- usageHint: 'RECOMMENDED FOR COMPLEX, MULTI-PHASE WORK:\n\n- Start by creating a high-level plan with `action: "add"` or using templates (`template_use`).\n- Use `promote` to turn a plan item into actionable todos.\n- Keep plans at the "why and what" level, and todos at the "how and next step" level.\n- Common templates: "new-feature", "bug-fix", "refactor", "release", "security-audit".\n\nThis tool is excellent for maintaining long-term direction across many turns or even multiple sessions.',
2718
+ usageHint: 'RECOMMENDED FOR COMPLEX, MULTI-PHASE WORK:\n\n- Start by creating a high-level plan with `action: "add"` or using templates (`template_use`).\n- Use `promote` to turn a plan item into actionable todos.\n- Use `taskify` to convert a plan item into a structured task (with type/priority/deps).\n- Keep plans at the "why and what" level, and todos at the "how and next step" level.\n- Common templates: "new-feature", "bug-fix", "refactor", "release", "security-audit".\n\nThis tool is excellent for maintaining long-term direction across many turns or even multiple sessions.',
2562
2719
  permission: "confirm",
2563
2720
  mutating: true,
2564
2721
  capabilities: ["fs.write"],
@@ -2575,9 +2732,9 @@ var planTool = {
2575
2732
  "done",
2576
2733
  "remove",
2577
2734
  "promote",
2578
- "derive",
2579
2735
  "template_use",
2580
- "clear"
2736
+ "clear",
2737
+ "taskify"
2581
2738
  ],
2582
2739
  description: "The operation to perform on the plan board."
2583
2740
  },
@@ -2596,7 +2753,7 @@ var planTool = {
2596
2753
  subtasks: {
2597
2754
  type: "array",
2598
2755
  items: { type: "string" },
2599
- description: "List of subtask titles. Used with promote or derive to break a plan item into multiple todos."
2756
+ description: "List of subtask titles. Used with promote to break a plan item into multiple todos."
2600
2757
  },
2601
2758
  template: {
2602
2759
  type: "string",
@@ -2617,92 +2774,151 @@ var planTool = {
2617
2774
  };
2618
2775
  }
2619
2776
  const sessionId = ctx.session?.id ?? "unknown";
2620
- let plan = await loadPlan(planPath) ?? emptyPlan(sessionId);
2621
- switch (input.action) {
2622
- case "show":
2623
- break;
2624
- case "add": {
2625
- const title = input.title?.trim();
2626
- if (!title) {
2627
- return mkResult(plan, false, "add requires `title`.");
2628
- }
2629
- ({ plan } = addPlanItem(plan, title, input.details?.trim() || void 0));
2630
- await savePlan(planPath, plan);
2631
- break;
2632
- }
2633
- case "start":
2634
- case "done": {
2635
- if (!input.target) {
2636
- return mkResult(plan, false, `${input.action} requires \`target\` (id|index|substring).`);
2777
+ let early = null;
2778
+ const taskifyMeta = { title: "", details: "" };
2779
+ let didTaskify = false;
2780
+ const plan = await mutatePlan(planPath, sessionId, async (p) => {
2781
+ switch (input.action) {
2782
+ case "show":
2783
+ break;
2784
+ case "add": {
2785
+ const title = input.title?.trim();
2786
+ if (!title) {
2787
+ early = mkResult(p, false, "add requires `title`.");
2788
+ return p;
2789
+ }
2790
+ const { plan: updated } = addPlanItem(p, title, input.details?.trim() || void 0);
2791
+ return updated;
2637
2792
  }
2638
- const next = setPlanItemStatus(
2639
- plan,
2640
- input.target,
2641
- input.action === "start" ? "in_progress" : "done"
2642
- );
2643
- if (next === plan) {
2644
- return mkResult(plan, false, `No plan item matched "${input.target}".`);
2793
+ case "start":
2794
+ case "done": {
2795
+ if (!input.target) {
2796
+ early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
2797
+ return p;
2798
+ }
2799
+ const next = setPlanItemStatus(
2800
+ p,
2801
+ input.target,
2802
+ input.action === "start" ? "in_progress" : "done"
2803
+ );
2804
+ if (next === p) {
2805
+ early = mkResult(p, false, `No plan item matched "${input.target}".`);
2806
+ return p;
2807
+ }
2808
+ return next;
2645
2809
  }
2646
- plan = next;
2647
- await savePlan(planPath, plan);
2648
- break;
2649
- }
2650
- case "remove": {
2651
- if (!input.target) {
2652
- return mkResult(plan, false, "remove requires `target` (id|index|substring).");
2810
+ case "remove": {
2811
+ if (!input.target) {
2812
+ early = mkResult(p, false, "remove requires `target` (id|index|substring).");
2813
+ return p;
2814
+ }
2815
+ const next = removePlanItem(p, input.target);
2816
+ if (next === p) {
2817
+ early = mkResult(p, false, `No plan item matched "${input.target}".`);
2818
+ return p;
2819
+ }
2820
+ return next;
2653
2821
  }
2654
- const next = removePlanItem(plan, input.target);
2655
- if (next === plan) {
2656
- return mkResult(plan, false, `No plan item matched "${input.target}".`);
2822
+ case "promote": {
2823
+ if (!input.target) {
2824
+ early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
2825
+ return p;
2826
+ }
2827
+ const derived = deriveTodosFromPlanItem(p, input.target, input.subtasks);
2828
+ if (!derived) {
2829
+ early = mkResult(p, false, `No plan item matched "${input.target}".`);
2830
+ return p;
2831
+ }
2832
+ ctx.state.replaceTodos(derived.todos);
2833
+ early = mkResult(
2834
+ derived.plan,
2835
+ true,
2836
+ `${input.action} ok \u2014 ${derived.todos.length} todo(s) created.`,
2837
+ derived.todos
2838
+ );
2839
+ return derived.plan;
2657
2840
  }
2658
- plan = next;
2659
- await savePlan(planPath, plan);
2660
- break;
2661
- }
2662
- case "promote":
2663
- case "derive": {
2664
- if (!input.target) {
2665
- return mkResult(plan, false, `${input.action} requires \`target\` (id|index|substring).`);
2841
+ case "template_use": {
2842
+ const templateName = input.template?.trim();
2843
+ if (!templateName) {
2844
+ early = mkResult(p, false, "template_use requires `template` name.");
2845
+ return p;
2846
+ }
2847
+ const template = getPlanTemplate(templateName);
2848
+ if (!template) {
2849
+ early = mkResult(p, false, `Unknown template "${templateName}".`);
2850
+ return p;
2851
+ }
2852
+ let updated = p;
2853
+ for (const item of template.items) {
2854
+ ({ plan: updated } = addPlanItem(updated, item.title, item.details));
2855
+ }
2856
+ early = mkResult(
2857
+ updated,
2858
+ true,
2859
+ `Applied template "${template.name}" \u2014 ${template.items.length} items added.`
2860
+ );
2861
+ return updated;
2666
2862
  }
2667
- const derived = deriveTodosFromPlanItem(plan, input.target, input.subtasks);
2668
- if (!derived) {
2669
- return mkResult(plan, false, `No plan item matched "${input.target}".`);
2863
+ case "clear":
2864
+ return clearPlan(p);
2865
+ case "taskify": {
2866
+ if (!input.target) {
2867
+ early = mkResult(p, false, "taskify requires `target` (plan item id|index|substring).");
2868
+ return p;
2869
+ }
2870
+ let itemIdx = -1;
2871
+ const asNum = Number.parseInt(input.target, 10);
2872
+ if (!Number.isNaN(asNum) && asNum >= 1 && asNum <= p.items.length) {
2873
+ itemIdx = asNum - 1;
2874
+ } else {
2875
+ itemIdx = p.items.findIndex((it) => it.id === input.target);
2876
+ if (itemIdx === -1) {
2877
+ const lower = input.target.toLowerCase();
2878
+ itemIdx = p.items.findIndex((it) => it.title.toLowerCase().includes(lower));
2879
+ }
2880
+ }
2881
+ if (itemIdx === -1 || !p.items[itemIdx]) {
2882
+ early = mkResult(p, false, `No plan item matched "${input.target}".`);
2883
+ return p;
2884
+ }
2885
+ const item = p.items[itemIdx];
2886
+ taskifyMeta.title = item.title;
2887
+ taskifyMeta.details = item.details ?? "";
2888
+ didTaskify = true;
2889
+ break;
2670
2890
  }
2671
- plan = derived.plan;
2672
- await savePlan(planPath, plan);
2673
- ctx.state.replaceTodos(derived.todos);
2674
- return mkResult(
2675
- plan,
2676
- true,
2677
- `${input.action} ok \u2014 ${derived.todos.length} todo(s) created.`,
2678
- derived.todos
2679
- );
2891
+ default:
2892
+ early = mkResult(p, false, `Unknown action "${input.action}".`);
2893
+ return p;
2680
2894
  }
2681
- case "template_use": {
2682
- const templateName = input.template?.trim();
2683
- if (!templateName) {
2684
- return mkResult(plan, false, "template_use requires `template` name.");
2685
- }
2686
- const template = getPlanTemplate(templateName);
2687
- if (!template) {
2688
- return mkResult(plan, false, `Unknown template "${templateName}".`);
2689
- }
2690
- for (const item of template.items) {
2691
- ({ plan } = addPlanItem(plan, item.title, item.details));
2692
- }
2693
- await savePlan(planPath, plan);
2694
- return mkResult(
2695
- plan,
2696
- true,
2697
- `Applied template "${template.name}" \u2014 ${template.items.length} items added.`
2698
- );
2895
+ return p;
2896
+ });
2897
+ if (early) return early;
2898
+ if (didTaskify) {
2899
+ const taskPath = ctx.meta["task.path"];
2900
+ if (typeof taskPath !== "string" || !taskPath) {
2901
+ return mkResult(plan, false, "Task storage path not configured \u2014 cannot taskify.");
2699
2902
  }
2700
- case "clear":
2701
- plan = clearPlan(plan);
2702
- await savePlan(planPath, plan);
2703
- break;
2704
- default:
2705
- return mkResult(plan, false, `Unknown action "${input.action}".`);
2903
+ const taskFile = await loadTasks(taskPath) ?? emptyTaskFile(sessionId);
2904
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2905
+ taskFile.tasks.push({
2906
+ id: `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
2907
+ title: taskifyMeta.title,
2908
+ description: taskifyMeta.details || void 0,
2909
+ type: "feature",
2910
+ priority: "medium",
2911
+ status: "pending",
2912
+ createdAt: now,
2913
+ updatedAt: now
2914
+ });
2915
+ await saveTasks(taskPath, taskFile);
2916
+ return mkResult(
2917
+ plan,
2918
+ true,
2919
+ `taskify ok \u2014 added "${taskifyMeta.title}" to tasks.
2920
+ ${formatTaskList(taskFile.tasks)}`
2921
+ );
2706
2922
  }
2707
2923
  return mkResult(plan, true, `Plan ${input.action} ok.`);
2708
2924
  }
@@ -2944,7 +3160,8 @@ function runGit(args, cwd, signal) {
2944
3160
  cwd,
2945
3161
  signal,
2946
3162
  env: buildChildEnv(),
2947
- stdio: ["ignore", "pipe", "pipe"]
3163
+ stdio: ["ignore", "pipe", "pipe"],
3164
+ windowsHide: true
2948
3165
  });
2949
3166
  child.stdout?.on("data", (chunk) => {
2950
3167
  if (stdout.length < MAX_OUTPUT3) {
@@ -3067,7 +3284,7 @@ function runPatch(args, cwd, signal) {
3067
3284
  let stdout = "";
3068
3285
  let stderr = "";
3069
3286
  const env = { ...buildChildEnv(), LANG: "C", LC_ALL: "C" };
3070
- const child = spawn("patch", args, { cwd, signal, env, stdio: ["pipe", "pipe", "pipe"] });
3287
+ const child = spawn("patch", args, { cwd, signal, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
3071
3288
  child.stdout?.on("data", (c) => {
3072
3289
  stdout += c.toString();
3073
3290
  });
@@ -3306,7 +3523,8 @@ function runGit2(args, cwd, signal) {
3306
3523
  cwd,
3307
3524
  signal,
3308
3525
  env: buildChildEnv(),
3309
- stdio: ["ignore", "pipe", "pipe"]
3526
+ stdio: ["ignore", "pipe", "pipe"],
3527
+ windowsHide: true
3310
3528
  });
3311
3529
  child.stdout?.on("data", (c) => {
3312
3530
  stdout += c.toString();
@@ -3531,9 +3749,10 @@ async function walkDir(dir, depth, opts) {
3531
3749
  async function* spawnStream(opts) {
3532
3750
  const max = opts.maxBytes ?? 2e5;
3533
3751
  const flushAt = opts.flushBytes ?? 4 * 1024;
3752
+ const maxQueue = opts.maxQueueSize ?? 500;
3534
3753
  let stdout = "";
3535
3754
  let stderr = "";
3536
- let pending = "";
3755
+ let pending2 = "";
3537
3756
  let error;
3538
3757
  const cmd = resolveWin32Command(opts.cmd);
3539
3758
  const needsShell = process.platform === "win32" && (cmd.endsWith(".cmd") || cmd.endsWith(".bat"));
@@ -3542,10 +3761,12 @@ async function* spawnStream(opts) {
3542
3761
  signal: opts.signal,
3543
3762
  env: buildChildEnv(),
3544
3763
  stdio: ["ignore", "pipe", "pipe"],
3764
+ windowsHide: true,
3545
3765
  ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {}
3546
3766
  });
3547
3767
  const queue = [];
3548
3768
  let waiter;
3769
+ let paused = false;
3549
3770
  const wake = () => {
3550
3771
  if (waiter) {
3551
3772
  const w = waiter;
@@ -3553,17 +3774,34 @@ async function* spawnStream(opts) {
3553
3774
  w();
3554
3775
  }
3555
3776
  };
3777
+ const resume = () => {
3778
+ if (paused && queue.length < maxQueue) {
3779
+ paused = false;
3780
+ child.stdout?.resume();
3781
+ child.stderr?.resume();
3782
+ }
3783
+ };
3556
3784
  child.stdout?.on("data", (c) => {
3557
3785
  const s = c.toString();
3558
3786
  if (stdout.length < max) stdout += s;
3559
3787
  queue.push({ kind: "out", data: s });
3560
3788
  wake();
3789
+ if (!paused && queue.length >= maxQueue) {
3790
+ paused = true;
3791
+ child.stdout?.pause();
3792
+ child.stderr?.pause();
3793
+ }
3561
3794
  });
3562
3795
  child.stderr?.on("data", (c) => {
3563
3796
  const s = c.toString();
3564
3797
  if (stderr.length < max) stderr += s;
3565
3798
  queue.push({ kind: "err", data: s });
3566
3799
  wake();
3800
+ if (!paused && queue.length >= maxQueue) {
3801
+ paused = true;
3802
+ child.stdout?.pause();
3803
+ child.stderr?.pause();
3804
+ }
3567
3805
  });
3568
3806
  child.on("error", (e) => {
3569
3807
  error = e.message;
@@ -3583,6 +3821,7 @@ async function* spawnStream(opts) {
3583
3821
  });
3584
3822
  }
3585
3823
  const chunk = queue.shift();
3824
+ resume();
3586
3825
  if (chunk.kind === "close") {
3587
3826
  if (!spawnFailed) exitCode = chunk.code ?? 0;
3588
3827
  break;
@@ -3592,14 +3831,14 @@ async function* spawnStream(opts) {
3592
3831
  exitCode = 1;
3593
3832
  continue;
3594
3833
  }
3595
- pending += chunk.data;
3596
- if (pending.length >= flushAt) {
3597
- yield { type: "partial_output", text: pending };
3598
- pending = "";
3834
+ pending2 += chunk.data;
3835
+ if (pending2.length >= flushAt) {
3836
+ yield { type: "partial_output", text: pending2 };
3837
+ pending2 = "";
3599
3838
  }
3600
3839
  }
3601
- if (pending.length > 0) {
3602
- yield { type: "partial_output", text: pending };
3840
+ if (pending2.length > 0) {
3841
+ yield { type: "partial_output", text: pending2 };
3603
3842
  }
3604
3843
  return {
3605
3844
  stdout,
@@ -4035,8 +4274,6 @@ function parseResult(runner, result, duration) {
4035
4274
  truncated: result.truncated
4036
4275
  };
4037
4276
  }
4038
-
4039
- // src/install.ts
4040
4277
  var installTool = {
4041
4278
  name: "install",
4042
4279
  category: "Package Management",
@@ -4131,18 +4368,48 @@ var installTool = {
4131
4368
  signal: opts.signal,
4132
4369
  maxBytes: 1e5
4133
4370
  });
4134
- yield {
4135
- type: "final",
4136
- output: {
4137
- packages: pkgList,
4138
- exit_code: result.exitCode,
4139
- output: normalizeCommandOutput(result.stdout || result.stderr || result.error || ""),
4140
- dry_run: args.includes("--dry-run"),
4141
- truncated: result.truncated
4142
- }
4371
+ const output = {
4372
+ packages: pkgList,
4373
+ exit_code: result.exitCode,
4374
+ output: normalizeCommandOutput(result.stdout || result.stderr || result.error || ""),
4375
+ dry_run: args.includes("--dry-run"),
4376
+ truncated: result.truncated
4143
4377
  };
4378
+ const isSuccess = result.exitCode === 0 && !output.dry_run && !input.global;
4379
+ if (isSuccess && pkgList.length > 0) {
4380
+ const trackerOpts = ctx.meta?.["packageTrackerOpts"];
4381
+ if (trackerOpts) {
4382
+ const manifestPath = resolveManifestPath(cwd, pkgManager);
4383
+ for (const pkg of pkgList) {
4384
+ try {
4385
+ await recordPackageAction(trackerOpts, {
4386
+ manifestPath,
4387
+ packageName: pkg,
4388
+ versionSpec: "latest",
4389
+ // exact version resolved by package manager at install time
4390
+ ecosystem: detectPackageEcosystem(manifestPath),
4391
+ agentId: ctx.agentId,
4392
+ agentName: ctx.agentName,
4393
+ sessionId: ctx.session.id
4394
+ });
4395
+ } catch {
4396
+ }
4397
+ }
4398
+ }
4399
+ }
4400
+ yield { type: "final", output };
4144
4401
  }
4145
4402
  };
4403
+ function resolveManifestPath(cwd, pkgManager) {
4404
+ switch (pkgManager) {
4405
+ case "pnpm":
4406
+ case "yarn":
4407
+ case "npm":
4408
+ return join(cwd, "package.json");
4409
+ default:
4410
+ return join(cwd, "package.json");
4411
+ }
4412
+ }
4146
4413
 
4147
4414
  // src/audit.ts
4148
4415
  var auditTool = {
@@ -4284,7 +4551,7 @@ function runOutdated(manager, args, cwd, signal) {
4284
4551
  const MAX = 1e5;
4285
4552
  const resolved = resolveWin32Command(manager);
4286
4553
  const needsShell = process.platform === "win32" && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
4287
- const child = spawn(resolved, args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {} });
4554
+ const child = spawn(resolved, args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true, ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {} });
4288
4555
  child.stdout?.on("data", (c) => {
4289
4556
  if (stdout.length < MAX) stdout += c.toString();
4290
4557
  });
@@ -4438,7 +4705,7 @@ async function dockerLogs(service, lines, filterRe, cwd, signal, since) {
4438
4705
  clearTimeout(timer);
4439
4706
  resolve7(result);
4440
4707
  };
4441
- const child = spawn("docker", args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"] });
4708
+ const child = spawn("docker", args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
4442
4709
  const timer = setTimeout(() => {
4443
4710
  child.kill("SIGTERM");
4444
4711
  finish(empty());
@@ -5499,8 +5766,91 @@ ${mode.description}`
5499
5766
  };
5500
5767
  }
5501
5768
 
5769
+ // src/codebase-index/circuit-breaker.ts
5770
+ var CircuitOpenError = class extends Error {
5771
+ name = "CircuitOpenError";
5772
+ };
5773
+ var IndexTimeoutError = class extends Error {
5774
+ name = "IndexTimeoutError";
5775
+ };
5776
+ var LockError = class extends Error {
5777
+ name = "LockError";
5778
+ };
5779
+ var IndexCircuitBreaker = class {
5780
+ failureThreshold;
5781
+ cooldownMs;
5782
+ now;
5783
+ state = "closed";
5784
+ consecutiveFailures = 0;
5785
+ openedAt = 0;
5786
+ lastFailure = null;
5787
+ probeInFlight = false;
5788
+ constructor(opts = {}) {
5789
+ this.failureThreshold = opts.failureThreshold ?? 3;
5790
+ this.cooldownMs = opts.cooldownMs ?? 6e4;
5791
+ this.now = opts.now ?? Date.now;
5792
+ }
5793
+ /**
5794
+ * True when a run may proceed. An open circuit transitions to half-open once
5795
+ * the cooldown has elapsed, admitting exactly one probe; further requests
5796
+ * are rejected until that probe settles via recordSuccess/recordFailure.
5797
+ */
5798
+ allowRequest() {
5799
+ if (this.state === "closed") return true;
5800
+ if (this.state === "open") {
5801
+ if (this.now() - this.openedAt < this.cooldownMs) return false;
5802
+ this.state = "half-open";
5803
+ this.probeInFlight = true;
5804
+ return true;
5805
+ }
5806
+ if (this.probeInFlight) return false;
5807
+ this.probeInFlight = true;
5808
+ return true;
5809
+ }
5810
+ recordSuccess() {
5811
+ this.state = "closed";
5812
+ this.consecutiveFailures = 0;
5813
+ this.lastFailure = null;
5814
+ this.probeInFlight = false;
5815
+ }
5816
+ recordFailure(err) {
5817
+ if (err instanceof LockError) {
5818
+ this.lastFailure = `[transient/lock] ${err.message}`;
5819
+ this.probeInFlight = false;
5820
+ return;
5821
+ }
5822
+ this.lastFailure = err instanceof Error ? err.message : String(err);
5823
+ this.probeInFlight = false;
5824
+ this.consecutiveFailures++;
5825
+ if (this.state === "half-open" || this.consecutiveFailures >= this.failureThreshold) {
5826
+ this.state = "open";
5827
+ this.openedAt = this.now();
5828
+ }
5829
+ }
5830
+ /** Force-close the circuit (manual recovery: `/codebase-reindex`). */
5831
+ reset() {
5832
+ this.state = "closed";
5833
+ this.consecutiveFailures = 0;
5834
+ this.lastFailure = null;
5835
+ this.probeInFlight = false;
5836
+ this.openedAt = 0;
5837
+ }
5838
+ snapshot() {
5839
+ return {
5840
+ state: this.state,
5841
+ consecutiveFailures: this.consecutiveFailures,
5842
+ lastFailure: this.lastFailure,
5843
+ cooldownRemainingMs: this.state === "open" ? Math.max(0, this.cooldownMs - (this.now() - this.openedAt)) : 0
5844
+ };
5845
+ }
5846
+ };
5847
+ var indexCircuitBreaker = new IndexCircuitBreaker();
5848
+ function resetIndexCircuitBreaker() {
5849
+ indexCircuitBreaker.reset();
5850
+ }
5851
+
5502
5852
  // src/codebase-index/schema.ts
5503
- var SCHEMA_VERSION = 1;
5853
+ var SCHEMA_VERSION = 2;
5504
5854
 
5505
5855
  // src/codebase-index/lsp-kind.ts
5506
5856
  function lspKindToInternalKind(k) {
@@ -5535,50 +5885,202 @@ function lspKindToInternalKind(k) {
5535
5885
  }
5536
5886
  }
5537
5887
 
5538
- // src/codebase-index/writer.ts
5539
- var DB_FILE = "index.db";
5540
- function resolveIndexDir(projectRoot, override) {
5541
- return override ?? resolveWstackPaths({ projectRoot }).projectCodebaseIndex;
5888
+ // src/codebase-index/bm25.ts
5889
+ var K1 = 1.5;
5890
+ var B = 0.75;
5891
+ function tokenise(text) {
5892
+ const sanitised = text.replace(/[^\p{L}\p{N}$'_]/gu, " ").replace(/_/g, " ");
5893
+ return sanitised.toLowerCase().split(" ").filter(Boolean);
5542
5894
  }
5543
- function codebaseIndexDirOverride(ctx) {
5544
- const v = ctx.meta?.["codebaseIndexDir"];
5545
- return typeof v === "string" ? v : void 0;
5895
+ function splitName(name) {
5896
+ return name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
5546
5897
  }
5547
- var warningSilenced = false;
5548
- function silenceSqliteExperimentalWarning() {
5549
- if (warningSilenced) return;
5550
- warningSilenced = true;
5551
- const original = process.emitWarning.bind(process);
5552
- process.emitWarning = ((warning, ...rest) => {
5553
- const msg = typeof warning === "string" ? warning : warning?.message ?? "";
5554
- const name = typeof warning === "string" ? String(rest[0] ?? "") : warning?.name ?? "";
5555
- if (/sqlite/i.test(msg) && /experimental/i.test(`${name} ${msg}`)) return;
5556
- original(warning, ...rest);
5557
- });
5898
+ function buildIndexableText(name, signature, docComment) {
5899
+ return [splitName(name), name, signature, docComment].filter(Boolean).join(" ");
5558
5900
  }
5559
- var DatabaseSyncCtor;
5560
- function loadDatabaseSync() {
5561
- if (DatabaseSyncCtor) return DatabaseSyncCtor;
5562
- silenceSqliteExperimentalWarning();
5563
- try {
5564
- const req = createRequire(import.meta.url);
5565
- DatabaseSyncCtor = req("node:sqlite").DatabaseSync;
5566
- } catch (err) {
5567
- throw new Error(
5568
- `The codebase index needs Node's built-in SQLite (node:sqlite), available since Node 22.5. This runtime doesn't provide it: ${err instanceof Error ? err.message : String(err)}`
5569
- );
5901
+ function buildBm25Index(docs) {
5902
+ const documents = docs.map((d) => {
5903
+ const tokens = tokenise(d.text);
5904
+ return { id: d.id, tokens, raw: d.text, len: tokens.length };
5905
+ });
5906
+ const df = {};
5907
+ for (const doc of documents) {
5908
+ const seen = /* @__PURE__ */ new Set();
5909
+ for (const t of doc.tokens) {
5910
+ if (!seen.has(t)) {
5911
+ df[t] = (df[t] ?? 0) + 1;
5912
+ seen.add(t);
5913
+ }
5914
+ }
5570
5915
  }
5571
- return DatabaseSyncCtor;
5916
+ const N = documents.length;
5917
+ const totalLen = documents.reduce((sum, d) => sum + d.len, 0);
5918
+ const avgLen = N === 0 ? 0 : totalLen / N;
5919
+ return new Bm25Index(documents, df, N, avgLen);
5572
5920
  }
5573
- var IndexStore = class {
5574
- db;
5575
- /** Absolute path to this project's index directory. */
5576
- indexDir;
5577
- constructor(projectRoot, opts = {}) {
5578
- this.indexDir = resolveIndexDir(projectRoot, opts.indexDir);
5579
- fs7.mkdirSync(this.indexDir, { recursive: true });
5580
- const Database = loadDatabaseSync();
5581
- this.db = new Database(path.join(this.indexDir, DB_FILE));
5921
+ var Bm25Index = class {
5922
+ constructor(documents, df, N, avgLen) {
5923
+ this.documents = documents;
5924
+ this.df = df;
5925
+ this.N = N;
5926
+ this.safeAvgLen = avgLen === 0 ? 1 : avgLen;
5927
+ }
5928
+ documents;
5929
+ df;
5930
+ N;
5931
+ safeAvgLen;
5932
+ score(query2, filter) {
5933
+ const qTokens = tokenise(query2);
5934
+ if (qTokens.length === 0) return [];
5935
+ const results = [];
5936
+ for (const doc of this.documents) {
5937
+ if (filter && !filter(doc.id)) continue;
5938
+ let docScore = 0;
5939
+ for (const qTerm of qTokens) {
5940
+ let tf = 0;
5941
+ for (const t of doc.tokens) {
5942
+ if (t === qTerm) tf++;
5943
+ }
5944
+ if (tf === 0) continue;
5945
+ const dfVal = this.df[qTerm] ?? 0;
5946
+ if (dfVal === 0) continue;
5947
+ const idf = Math.log((this.N - dfVal + 0.5) / (dfVal + 0.5) + 1);
5948
+ const lenRatio = B * (doc.len / this.safeAvgLen);
5949
+ const tfComponent = tf * (K1 + 1) / (tf + K1 * (1 - B + lenRatio));
5950
+ docScore += idf * tfComponent;
5951
+ }
5952
+ if (docScore > 0) results.push({ id: doc.id, score: docScore });
5953
+ }
5954
+ return results;
5955
+ }
5956
+ getDoc(id) {
5957
+ return this.documents.find((d) => d.id === id);
5958
+ }
5959
+ extractSnippet(docId, queryTokens, radius = 40) {
5960
+ const doc = this.getDoc(docId);
5961
+ if (!doc) return "";
5962
+ for (const tok of queryTokens) {
5963
+ const idx = doc.raw.toLowerCase().indexOf(tok);
5964
+ if (idx !== -1) {
5965
+ const start = Math.max(0, idx - radius);
5966
+ const end = Math.min(doc.raw.length, idx + tok.length + radius);
5967
+ const excerpt = doc.raw.slice(start, end);
5968
+ const ellipsis = "\u2026";
5969
+ return (start > 0 ? ellipsis : "") + excerpt + (end < doc.raw.length ? ellipsis : "");
5970
+ }
5971
+ }
5972
+ return doc.raw.slice(0, radius * 2) + (doc.raw.length > radius * 2 ? "\u2026" : "");
5973
+ }
5974
+ };
5975
+
5976
+ // src/codebase-index/writer.ts
5977
+ var DB_FILE = "index.db";
5978
+ function resolveIndexDir(projectRoot, override) {
5979
+ return override ?? resolveWstackPaths({ projectRoot }).projectCodebaseIndex;
5980
+ }
5981
+ function codebaseIndexDirOverride(ctx) {
5982
+ const v = ctx.meta?.["codebaseIndexDir"];
5983
+ return typeof v === "string" ? v : void 0;
5984
+ }
5985
+ var warningSilenced = false;
5986
+ function silenceSqliteExperimentalWarning() {
5987
+ if (warningSilenced) return;
5988
+ warningSilenced = true;
5989
+ const original = process.emitWarning.bind(process);
5990
+ process.emitWarning = ((warning, ...rest) => {
5991
+ const msg = typeof warning === "string" ? warning : warning?.message ?? "";
5992
+ const name = typeof warning === "string" ? String(rest[0] ?? "") : warning?.name ?? "";
5993
+ if (/sqlite/i.test(msg) && /experimental/i.test(`${name} ${msg}`)) return;
5994
+ original(warning, ...rest);
5995
+ });
5996
+ }
5997
+ var DatabaseSyncCtor;
5998
+ function loadDatabaseSync() {
5999
+ if (DatabaseSyncCtor) return DatabaseSyncCtor;
6000
+ silenceSqliteExperimentalWarning();
6001
+ try {
6002
+ const req = createRequire(import.meta.url);
6003
+ DatabaseSyncCtor = req("node:sqlite").DatabaseSync;
6004
+ } catch (err) {
6005
+ throw new Error(
6006
+ `The codebase index needs Node's built-in SQLite (node:sqlite), available since Node 22.5. This runtime doesn't provide it: ${err instanceof Error ? err.message : String(err)}`
6007
+ );
6008
+ }
6009
+ return DatabaseSyncCtor;
6010
+ }
6011
+ var MAX_LOCK_RETRIES = 3;
6012
+ var LOCK_RETRY_BASE_DELAY_MS = 50;
6013
+ var LOCK_RETRY_MAX_DELAY_MS = 500;
6014
+ function isLockError(err) {
6015
+ if (!(err instanceof Error)) return false;
6016
+ const e = err;
6017
+ const code = e.code ?? e.sqliteCode;
6018
+ if (typeof code === "string" && /SQLITE_(BUSY|LOCKED)/.test(code)) return true;
6019
+ if (typeof code === "number" && (code === 5 || code === 6)) return true;
6020
+ if (/SQLITE_(BUSY|LOCKED)/.test(err.message)) return true;
6021
+ return false;
6022
+ }
6023
+ function sleepSync(ms) {
6024
+ try {
6025
+ const sab = new SharedArrayBuffer(4);
6026
+ const view = new Int32Array(sab);
6027
+ Atomics.wait(view, 0, 0, ms);
6028
+ } catch {
6029
+ }
6030
+ }
6031
+ var IndexStore = class {
6032
+ db;
6033
+ /** Absolute path to this project's index directory. */
6034
+ indexDir;
6035
+ /**
6036
+ * True when the SQLite build provides FTS5 (Node's bundled SQLite does).
6037
+ * When false, ranked search falls back to the LIKE + in-process BM25 path.
6038
+ */
6039
+ ftsAvailable = false;
6040
+ /**
6041
+ * Execute a SQLite write operation with automatic retry on lock conflicts.
6042
+ *
6043
+ * When another wstack process is holding the write lock the statement first
6044
+ * waits up to `busy_timeout` ms, then throws SQLITE_BUSY. This wrapper catches
6045
+ * that error and retries (up to MAX_LOCK_RETRIES) with exponential backoff,
6046
+ * giving the competing writer time to finish and release the lock.
6047
+ *
6048
+ * @param fn The write operation to execute. Can return a value which is
6049
+ * returned to the caller on success.
6050
+ * @throws {@link LockError} when all retries are exhausted on a lock conflict
6051
+ * (non-lock errors always propagate on the first attempt).
6052
+ */
6053
+ runWithRetry(fn) {
6054
+ let lastError;
6055
+ for (let attempt = 0; attempt <= MAX_LOCK_RETRIES; attempt++) {
6056
+ try {
6057
+ return fn();
6058
+ } catch (err) {
6059
+ lastError = err;
6060
+ if (!isLockError(err)) throw err;
6061
+ if (attempt === MAX_LOCK_RETRIES) {
6062
+ const msg = lastError instanceof Error ? lastError.message : String(lastError);
6063
+ throw new LockError(`SQLite lock conflict after ${MAX_LOCK_RETRIES} retries: ${msg}`);
6064
+ }
6065
+ const delay = Math.min(
6066
+ LOCK_RETRY_BASE_DELAY_MS * Math.pow(2, attempt),
6067
+ LOCK_RETRY_MAX_DELAY_MS
6068
+ );
6069
+ sleepSync(delay);
6070
+ }
6071
+ }
6072
+ throw lastError;
6073
+ }
6074
+ constructor(projectRoot, opts = {}) {
6075
+ this.indexDir = resolveIndexDir(projectRoot, opts.indexDir);
6076
+ fs7.mkdirSync(this.indexDir, { recursive: true });
6077
+ const Database = loadDatabaseSync();
6078
+ this.db = new Database(path.join(this.indexDir, DB_FILE));
6079
+ try {
6080
+ this.db.exec("PRAGMA journal_mode = WAL");
6081
+ this.db.exec("PRAGMA busy_timeout = 5000");
6082
+ } catch {
6083
+ }
5582
6084
  this.initSchema();
5583
6085
  }
5584
6086
  initSchema() {
@@ -5587,6 +6089,21 @@ var IndexStore = class {
5587
6089
  key TEXT PRIMARY KEY,
5588
6090
  value TEXT NOT NULL
5589
6091
  );
6092
+ `);
6093
+ const storedRows = this.db.prepare("SELECT value FROM metadata WHERE key = ?").all("version");
6094
+ const storedVersion = storedRows.length ? Number(storedRows[0]?.value) : null;
6095
+ if (storedVersion !== null && storedVersion !== SCHEMA_VERSION) {
6096
+ this.db.exec(`
6097
+ DROP TABLE IF EXISTS symbols;
6098
+ DROP TABLE IF EXISTS files;
6099
+ DROP TABLE IF EXISTS refs;
6100
+ `);
6101
+ this.db.exec("DROP TABLE IF EXISTS symbols_fts");
6102
+ this.db.prepare("UPDATE metadata SET value = ? WHERE key = ?").run(String(SCHEMA_VERSION), "version");
6103
+ } else if (storedVersion === null) {
6104
+ this.db.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)").run("version", String(SCHEMA_VERSION));
6105
+ }
6106
+ this.db.exec(`
5590
6107
  CREATE TABLE IF NOT EXISTS files (
5591
6108
  file TEXT PRIMARY KEY,
5592
6109
  lang TEXT NOT NULL,
@@ -5627,53 +6144,76 @@ var IndexStore = class {
5627
6144
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_r_to_id ON refs(to_id)");
5628
6145
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_r_to_name ON refs(to_name)");
5629
6146
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_r_call_type ON refs(call_type)");
5630
- const versionRows = this.db.prepare("SELECT value FROM metadata WHERE key = ?").all("version");
5631
- if (!versionRows.length) {
5632
- this.db.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)").run("version", String(SCHEMA_VERSION));
6147
+ try {
6148
+ this.db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(text, tokenize = 'unicode61')");
6149
+ this.ftsAvailable = true;
6150
+ } catch {
6151
+ this.ftsAvailable = false;
5633
6152
  }
5634
6153
  }
5635
6154
  // ─── Symbol CRUD ─────────────────────────────────────────────────────────────
5636
6155
  insertSymbols(symbols, nextId) {
5637
- const stmt = this.db.prepare(
5638
- `INSERT INTO symbols(id, lang, kind, name, file, line, col, signature, doc_comment, scope, text, file_fk)
5639
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
5640
- );
5641
- let id = nextId;
5642
- for (const s of symbols) {
5643
- stmt.run(
5644
- id++,
5645
- s.lang,
5646
- s.kind,
5647
- s.name,
5648
- s.file,
5649
- s.line,
5650
- s.col,
5651
- s.signature,
5652
- s.docComment,
5653
- s.scope,
5654
- s.text,
5655
- s.file
6156
+ return this.runWithRetry(() => {
6157
+ const stmt = this.db.prepare(
6158
+ `INSERT INTO symbols(id, lang, kind, name, file, line, col, signature, doc_comment, scope, text, file_fk)
6159
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
5656
6160
  );
5657
- }
5658
- return id;
6161
+ const ftsStmt = this.ftsAvailable ? this.db.prepare("INSERT INTO symbols_fts(rowid, text) VALUES (?, ?)") : null;
6162
+ let id = nextId;
6163
+ for (const s of symbols) {
6164
+ stmt.run(
6165
+ id,
6166
+ s.lang,
6167
+ s.kind,
6168
+ s.name,
6169
+ s.file,
6170
+ s.line,
6171
+ s.col,
6172
+ s.signature,
6173
+ s.docComment,
6174
+ s.scope,
6175
+ s.text,
6176
+ s.file
6177
+ );
6178
+ ftsStmt?.run(id, buildIndexableText(s.name, s.signature, s.docComment));
6179
+ id++;
6180
+ }
6181
+ return id;
6182
+ });
5659
6183
  }
5660
6184
  deleteSymbolsForFile(file) {
5661
- this.db.prepare("DELETE FROM symbols WHERE file_fk = ?").run(file);
6185
+ this.runWithRetry(() => {
6186
+ if (this.ftsAvailable) {
6187
+ this.db.prepare("DELETE FROM symbols_fts WHERE rowid IN (SELECT id FROM symbols WHERE file_fk = ?)").run(file);
6188
+ }
6189
+ this.db.prepare("DELETE FROM symbols WHERE file_fk = ?").run(file);
6190
+ });
5662
6191
  }
6192
+ /**
6193
+ * Remove every trace of a file (refs, symbols, FTS rows, file meta). Used
6194
+ * when a source file disappears between index runs — previously this only
6195
+ * dropped the `files` row, leaving its symbols orphaned but still searchable.
6196
+ */
5663
6197
  deleteFile(file) {
5664
- this.db.prepare("DELETE FROM files WHERE file = ?").run(file);
6198
+ this.runWithRetry(() => {
6199
+ this.deleteRefsForFile(file);
6200
+ this.deleteSymbolsForFile(file);
6201
+ this.db.prepare("DELETE FROM files WHERE file = ?").run(file);
6202
+ });
5665
6203
  }
5666
6204
  // ─── File metadata ──────────────────────────────────────────────────────────
5667
6205
  upsertFile(meta) {
5668
- this.db.prepare(
5669
- `INSERT INTO files(file, lang, mtime_ms, symbol_count, last_indexed)
5670
- VALUES (?, ?, ?, ?, ?)
5671
- ON CONFLICT(file) DO UPDATE SET
5672
- lang = excluded.lang,
5673
- mtime_ms = excluded.mtime_ms,
5674
- symbol_count = excluded.symbol_count,
5675
- last_indexed = excluded.last_indexed`
5676
- ).run(meta.file, meta.lang, meta.mtimeMs, meta.symbolCount, meta.lastIndexed);
6206
+ this.runWithRetry(() => {
6207
+ this.db.prepare(
6208
+ `INSERT INTO files(file, lang, mtime_ms, symbol_count, last_indexed)
6209
+ VALUES (?, ?, ?, ?, ?)
6210
+ ON CONFLICT(file) DO UPDATE SET
6211
+ lang = excluded.lang,
6212
+ mtime_ms = excluded.mtime_ms,
6213
+ symbol_count = excluded.symbol_count,
6214
+ last_indexed = excluded.last_indexed`
6215
+ ).run(meta.file, meta.lang, meta.mtimeMs, meta.symbolCount, meta.lastIndexed);
6216
+ });
5677
6217
  }
5678
6218
  getFileMeta(file) {
5679
6219
  const rows = this.db.prepare(
@@ -5740,6 +6280,94 @@ var IndexStore = class {
5740
6280
  lspKind: filter?.lspKind
5741
6281
  }));
5742
6282
  }
6283
+ /**
6284
+ * Ranked search — the one-stop query the codebase-search tool and plug-lsp
6285
+ * use. With FTS5 this is a single indexed `MATCH` ranked by SQLite's native
6286
+ * `bm25()` with a built-in `snippet()`; without FTS5 it falls back to the
6287
+ * legacy LIKE scan + in-process BM25 (identical semantics, slower).
6288
+ *
6289
+ * Tokens are matched as prefixes (`"tok"*`), mirroring the old
6290
+ * `LIKE '%tok%'` recall for the common symbol-search shapes ("user" finds
6291
+ * "users", camelCase-split text makes "complex" find "complexOperation").
6292
+ */
6293
+ searchRanked(query2, filter, limit) {
6294
+ const tokens = tokenise(query2);
6295
+ if (tokens.length === 0 || !this.ftsAvailable) {
6296
+ return this.searchRankedFallback(query2, filter, limit);
6297
+ }
6298
+ let effectiveKind = filter?.kind;
6299
+ if (filter?.lspKind !== void 0) {
6300
+ const mapped = lspKindToInternalKind(filter.lspKind);
6301
+ if (mapped === null) return { results: [], total: 0 };
6302
+ effectiveKind = mapped;
6303
+ }
6304
+ const match = tokens.map((t) => `"${t.replaceAll('"', "")}"*`).join(" OR ");
6305
+ const conditions = ["symbols_fts MATCH ?"];
6306
+ const values = [match];
6307
+ if (effectiveKind) {
6308
+ conditions.push("s.kind = ?");
6309
+ values.push(effectiveKind);
6310
+ }
6311
+ if (filter?.lang) {
6312
+ conditions.push("s.lang = ?");
6313
+ values.push(filter.lang);
6314
+ }
6315
+ if (filter?.file) {
6316
+ conditions.push("s.file LIKE ?");
6317
+ values.push(`%${filter.file}%`);
6318
+ }
6319
+ const where = conditions.join(" AND ");
6320
+ 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);
6321
+ const total = countRows[0] ? Number(countRows[0].n) : 0;
6322
+ if (total === 0) return { results: [], total: 0 };
6323
+ const rows = this.db.prepare(
6324
+ `SELECT s.id, s.lang, s.kind, s.name, s.file, s.line, s.col, s.signature, s.doc_comment,
6325
+ -bm25(symbols_fts) AS score,
6326
+ snippet(symbols_fts, 0, '', '', '\u2026', 12) AS snippet
6327
+ FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid
6328
+ WHERE ${where}
6329
+ ORDER BY bm25(symbols_fts)
6330
+ LIMIT ?`
6331
+ ).all(...values, limit);
6332
+ return {
6333
+ results: rows.map((r) => ({
6334
+ id: r.id,
6335
+ lang: r.lang,
6336
+ kind: r.kind,
6337
+ name: r.name,
6338
+ file: r.file,
6339
+ line: r.line,
6340
+ col: r.col,
6341
+ signature: r.signature,
6342
+ docComment: r.doc_comment,
6343
+ // bm25() is negative-is-better; negate so callers keep "higher is
6344
+ // better" and clamp so a match never reports a zero score.
6345
+ score: Math.max(1e-4, r.score),
6346
+ snippet: r.snippet,
6347
+ lspKind: filter?.lspKind
6348
+ })),
6349
+ total
6350
+ };
6351
+ }
6352
+ /** Legacy ranked path: LIKE candidates + in-process BM25 + JS snippets. */
6353
+ searchRankedFallback(query2, filter, limit) {
6354
+ const candidates = this.search(query2, filter);
6355
+ if (candidates.length === 0) return { results: [], total: 0 };
6356
+ if (!query2.trim()) {
6357
+ return { results: candidates.slice(0, limit), total: candidates.length };
6358
+ }
6359
+ const bm25 = buildBm25Index(
6360
+ candidates.map((c) => ({ id: c.id, text: buildIndexableText(c.name, c.signature, c.docComment) }))
6361
+ );
6362
+ const scored = bm25.score(query2, (id) => candidates.some((c) => c.id === id));
6363
+ scored.sort((a, b) => b.score - a.score);
6364
+ const qTokens = tokenise(query2);
6365
+ const results = scored.slice(0, limit).map(({ id, score }) => {
6366
+ const c = expectDefined(candidates.find((cand) => cand.id === id));
6367
+ return { ...c, score, snippet: bm25.extractSnippet(id, qTokens) };
6368
+ });
6369
+ return { results, total: candidates.length };
6370
+ }
5743
6371
  getAllIndexable() {
5744
6372
  return this.db.prepare("SELECT id, text FROM symbols").all().map(
5745
6373
  ({ id, text }) => ({ id, text })
@@ -5789,14 +6417,19 @@ var IndexStore = class {
5789
6417
  };
5790
6418
  }
5791
6419
  setLastIndexed(ts2) {
5792
- this.db.prepare(
5793
- "INSERT OR REPLACE INTO metadata(key, value) VALUES('last_indexed', ?)"
5794
- ).run(String(ts2));
6420
+ this.runWithRetry(() => {
6421
+ this.db.prepare(
6422
+ "INSERT OR REPLACE INTO metadata(key, value) VALUES('last_indexed', ?)"
6423
+ ).run(String(ts2));
6424
+ });
5795
6425
  }
5796
6426
  clearAll() {
5797
- this.db.exec("DELETE FROM symbols");
5798
- this.db.exec("DELETE FROM files");
5799
- this.db.exec("DELETE FROM refs");
6427
+ this.runWithRetry(() => {
6428
+ this.db.exec("DELETE FROM symbols");
6429
+ this.db.exec("DELETE FROM files");
6430
+ this.db.exec("DELETE FROM refs");
6431
+ if (this.ftsAvailable) this.db.exec("DELETE FROM symbols_fts");
6432
+ });
5800
6433
  }
5801
6434
  // ─── Ref CRUD ────────────────────────────────────────────────────────────────
5802
6435
  /**
@@ -5804,46 +6437,52 @@ var IndexStore = class {
5804
6437
  * Replaces any existing refs from the same source (idempotent on re-index).
5805
6438
  */
5806
6439
  insertRefs(fromId, refs) {
5807
- this.db.prepare("DELETE FROM refs WHERE from_id = ?").run(fromId);
5808
- if (refs.length === 0) return;
5809
- const stmt = this.db.prepare(
5810
- `INSERT INTO refs(from_id, to_name, to_id, call_type, line)
5811
- VALUES (?, ?, ?, ?, ?)`
5812
- );
5813
- for (const ref of refs) {
5814
- stmt.run(fromId, ref.toName, ref.toId ?? null, ref.callType, ref.line);
5815
- }
6440
+ this.runWithRetry(() => {
6441
+ this.db.prepare("DELETE FROM refs WHERE from_id = ?").run(fromId);
6442
+ if (refs.length === 0) return;
6443
+ const stmt = this.db.prepare(
6444
+ `INSERT INTO refs(from_id, to_name, to_id, call_type, line)
6445
+ VALUES (?, ?, ?, ?, ?)`
6446
+ );
6447
+ for (const ref of refs) {
6448
+ stmt.run(fromId, ref.toName, ref.toId ?? null, ref.callType, ref.line);
6449
+ }
6450
+ });
5816
6451
  }
5817
6452
  /**
5818
6453
  * Delete all refs whose source symbols are in a given file.
5819
6454
  * Used when re-indexing a file to clear stale refs.
5820
6455
  */
5821
6456
  deleteRefsForFile(file) {
5822
- const ids = this.db.prepare(
5823
- "SELECT id FROM symbols WHERE file = ?"
5824
- ).all(file);
5825
- if (!ids.length) return;
5826
- const placeholders = ids.map(() => "?").join(",");
5827
- this.db.prepare(`DELETE FROM refs WHERE from_id IN (${placeholders})`).run(...ids.map((r) => r.id));
6457
+ this.runWithRetry(() => {
6458
+ const ids = this.db.prepare(
6459
+ "SELECT id FROM symbols WHERE file = ?"
6460
+ ).all(file);
6461
+ if (!ids.length) return;
6462
+ const placeholders = ids.map(() => "?").join(",");
6463
+ this.db.prepare(`DELETE FROM refs WHERE from_id IN (${placeholders})`).run(...ids.map((r) => r.id));
6464
+ });
5828
6465
  }
5829
6466
  /**
5830
6467
  * Resolve `to_name` → `to_id` for all refs that have a name but no id.
5831
6468
  * Call this after all symbols have been inserted to fill in cross-references.
5832
6469
  */
5833
6470
  resolveRefs() {
5834
- const unresolved = this.db.prepare(
5835
- "SELECT id, to_name FROM refs WHERE to_id IS NULL AND to_name IS NOT NULL"
5836
- ).all();
5837
- let resolved = 0;
5838
- for (const row of unresolved) {
5839
- const target = this.db.prepare("SELECT id FROM symbols WHERE name = ? LIMIT 1").all(row.to_name);
5840
- const first = target[0];
5841
- if (first) {
5842
- this.db.prepare("UPDATE refs SET to_id = ? WHERE id = ?").run(first.id, row.id);
5843
- resolved++;
6471
+ return this.runWithRetry(() => {
6472
+ const unresolved = this.db.prepare(
6473
+ "SELECT id, to_name FROM refs WHERE to_id IS NULL AND to_name IS NOT NULL"
6474
+ ).all();
6475
+ let resolved = 0;
6476
+ for (const row of unresolved) {
6477
+ const target = this.db.prepare("SELECT id FROM symbols WHERE name = ? LIMIT 1").all(row.to_name);
6478
+ const first = target[0];
6479
+ if (first) {
6480
+ this.db.prepare("UPDATE refs SET to_id = ? WHERE id = ?").run(first.id, row.id);
6481
+ resolved++;
6482
+ }
5844
6483
  }
5845
- }
5846
- return resolved;
6484
+ return resolved;
6485
+ });
5847
6486
  }
5848
6487
  /**
5849
6488
  * Find all references TO a given symbol (who calls / uses this symbol?).
@@ -6604,7 +7243,7 @@ function parseSymbols4(opts) {
6604
7243
  }
6605
7244
  function checkNativeParser() {
6606
7245
  try {
6607
- execFileSync("rustc", ["--version"], { stdio: "pipe" });
7246
+ execFileSync("rustc", ["--version"], { stdio: "pipe", windowsHide: true });
6608
7247
  const toolsDir = path.join(process.cwd(), "tools");
6609
7248
  try {
6610
7249
  execFileSync(
@@ -6617,7 +7256,7 @@ function checkNativeParser() {
6617
7256
  "--manifest-path",
6618
7257
  path.join(toolsDir, "Cargo.toml")
6619
7258
  ],
6620
- { stdio: "pipe" }
7259
+ { stdio: "pipe", windowsHide: true }
6621
7260
  );
6622
7261
  return true;
6623
7262
  } catch {
@@ -6640,7 +7279,8 @@ function tryNativeParse(file, content) {
6640
7279
  cwd: process.cwd(),
6641
7280
  encoding: "utf8",
6642
7281
  timeout: 15e3,
6643
- stdio: ["pipe", "pipe", "pipe"]
7282
+ stdio: ["pipe", "pipe", "pipe"],
7283
+ windowsHide: true
6644
7284
  }
6645
7285
  );
6646
7286
  if (result.status === 0 && result.stdout) {
@@ -7054,10 +7694,6 @@ function isScalar(value) {
7054
7694
  if (/^'[^']*'$/.test(value) || /^"[^"]*"$/.test(value)) return true;
7055
7695
  return false;
7056
7696
  }
7057
- function truncate(s, max) {
7058
- if (s.length <= max) return s;
7059
- return s.slice(0, max) + "...";
7060
- }
7061
7697
  function makeSymbol2(opts) {
7062
7698
  return {
7063
7699
  id: 0,
@@ -7124,140 +7760,20 @@ async function loadGitignoreMatcher(projectRoot) {
7124
7760
  return compileGitignore(lines);
7125
7761
  }
7126
7762
 
7127
- // src/codebase-index/background-indexer.ts
7128
- var _ready = false;
7129
- var _indexing = false;
7130
- var _currentFile = 0;
7131
- var _totalFiles = 0;
7132
- var _lastError = null;
7133
- function isIndexReady() {
7134
- return _ready;
7135
- }
7136
- function setIndexReady() {
7137
- _ready = true;
7138
- }
7139
- function isIndexing() {
7140
- return _indexing;
7141
- }
7142
- function getIndexState() {
7143
- return {
7144
- ready: _ready,
7145
- indexing: _indexing,
7146
- currentFile: _currentFile,
7147
- totalFiles: _totalFiles,
7148
- lastError: _lastError
7149
- };
7150
- }
7151
- var _listeners = [];
7152
- function onIndexStateChange(listener) {
7153
- _listeners.push(listener);
7154
- return () => {
7155
- _listeners = _listeners.filter((l) => l !== listener);
7156
- };
7157
- }
7158
- function emitState() {
7159
- const state = getIndexState();
7160
- for (const l of _listeners) l(state);
7161
- }
7162
- function _setIndexProgress(current, total) {
7163
- _currentFile = current;
7164
- _totalFiles = total;
7165
- emitState();
7166
- }
7167
- function stubCtx(projectRoot) {
7168
- return {
7169
- projectRoot,
7170
- cwd: projectRoot,
7171
- messages: [],
7172
- todos: [],
7173
- readFiles: /* @__PURE__ */ new Set(),
7174
- fileMtimes: /* @__PURE__ */ new Map()
7175
- };
7763
+ // src/codebase-index/indexer.ts
7764
+ var YIELD_EVERY_N = 50;
7765
+ function yieldEventLoop() {
7766
+ return new Promise((resolve7) => setImmediate(resolve7));
7176
7767
  }
7177
- var chain = Promise.resolve();
7178
- function withMutex(job) {
7179
- const run = chain.then(job, job);
7180
- chain = run.then(
7181
- () => void 0,
7182
- () => void 0
7768
+ function throwIfAborted(signal) {
7769
+ if (!signal?.aborted) return;
7770
+ if (signal.reason instanceof Error) throw signal.reason;
7771
+ throw new Error(
7772
+ typeof signal.reason === "string" ? signal.reason : "Indexing cancelled"
7183
7773
  );
7184
- return run;
7185
- }
7186
- var DEFAULT_DEBOUNCE_MS = 400;
7187
- var debounceTimers = /* @__PURE__ */ new Map();
7188
- function debounceKey(indexDir, file) {
7189
- return `${indexDir ?? ""}|${file}`;
7190
7774
  }
7191
- function isIndexableFile(filePath) {
7192
- return detectLang(filePath) !== null;
7193
- }
7194
- async function runStartupIndex(opts) {
7195
- _indexing = true;
7196
- _currentFile = 0;
7197
- _totalFiles = 0;
7198
- _lastError = null;
7199
- emitState();
7200
- try {
7201
- const result = await withMutex(
7202
- () => runIndexer(stubCtx(opts.projectRoot), {
7203
- projectRoot: opts.projectRoot,
7204
- indexDir: opts.indexDir,
7205
- force: opts.force,
7206
- signal: opts.signal
7207
- })
7208
- );
7209
- _ready = true;
7210
- return result;
7211
- } catch (err) {
7212
- _lastError = err instanceof Error ? err.message : String(err);
7213
- _ready = true;
7214
- throw err;
7215
- } finally {
7216
- _indexing = false;
7217
- emitState();
7218
- }
7219
- }
7220
- function enqueueReindex(opts) {
7221
- const files = opts.files.filter(isIndexableFile);
7222
- if (files.length === 0) return;
7223
- const ms = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
7224
- for (const file of files) {
7225
- const key = debounceKey(opts.indexDir, file);
7226
- const existing = debounceTimers.get(key);
7227
- if (existing) clearTimeout(existing);
7228
- const timer = setTimeout(() => {
7229
- debounceTimers.delete(key);
7230
- void withMutex(
7231
- () => runIndexer(stubCtx(opts.projectRoot), {
7232
- projectRoot: opts.projectRoot,
7233
- files: [file],
7234
- indexDir: opts.indexDir
7235
- })
7236
- ).catch((err) => opts.onError?.(err));
7237
- }, ms);
7238
- timer.unref?.();
7239
- debounceTimers.set(key, timer);
7240
- }
7241
- }
7242
- function cancelPendingReindexes() {
7243
- for (const t of debounceTimers.values()) clearTimeout(t);
7244
- debounceTimers.clear();
7245
- }
7246
-
7247
- // src/codebase-index/indexer.ts
7248
- var YIELD_EVERY_N = 50;
7249
- function yieldEventLoop() {
7250
- return new Promise((resolve7) => setImmediate(resolve7));
7251
- }
7252
- function throwIfAborted(signal) {
7253
- if (!signal?.aborted) return;
7254
- if (signal.reason instanceof Error) throw signal.reason;
7255
- throw new Error(
7256
- typeof signal.reason === "string" ? signal.reason : "Indexing cancelled"
7257
- );
7258
- }
7259
- function isAbortError(err) {
7260
- return err instanceof DOMException && err.name === "AbortError";
7775
+ function isAbortError(err) {
7776
+ return err instanceof DOMException && err.name === "AbortError";
7261
7777
  }
7262
7778
  var DEFAULT_IGNORE5 = [
7263
7779
  "node_modules",
@@ -7343,8 +7859,18 @@ async function parseFile(file, content, lang) {
7343
7859
  }
7344
7860
  }
7345
7861
  async function runIndexer(_ctx, opts) {
7346
- const { projectRoot, force = false, langs, ignore = [], indexDir, signal } = opts;
7347
- const store = new IndexStore(projectRoot, { indexDir });
7862
+ const store = new IndexStore(opts.projectRoot, { indexDir: opts.indexDir });
7863
+ try {
7864
+ return await runIndexerWithStore(store, opts);
7865
+ } finally {
7866
+ try {
7867
+ store.close();
7868
+ } catch {
7869
+ }
7870
+ }
7871
+ }
7872
+ async function runIndexerWithStore(store, opts) {
7873
+ const { projectRoot, force = false, langs, ignore = [], signal } = opts;
7348
7874
  const startMs = Date.now();
7349
7875
  const errors = [];
7350
7876
  const langStats = {};
@@ -7371,7 +7897,7 @@ async function runIndexer(_ctx, opts) {
7371
7897
  }
7372
7898
  for (let fi = 0; fi < files.length; fi++) {
7373
7899
  const file = expectDefined(files[fi]);
7374
- _setIndexProgress(fi + 1, files.length);
7900
+ opts.onProgress?.(fi + 1, files.length);
7375
7901
  if (fi > 0 && fi % YIELD_EVERY_N === 0) {
7376
7902
  await yieldEventLoop();
7377
7903
  throwIfAborted(signal);
@@ -7457,7 +7983,6 @@ async function runIndexer(_ctx, opts) {
7457
7983
  }
7458
7984
  const durationMs = Date.now() - startMs;
7459
7985
  store.setLastIndexed(Date.now());
7460
- store.close();
7461
7986
  return {
7462
7987
  filesIndexed,
7463
7988
  symbolsIndexed,
@@ -7467,6 +7992,349 @@ async function runIndexer(_ctx, opts) {
7467
7992
  };
7468
7993
  }
7469
7994
 
7995
+ // src/codebase-index/index-service.ts
7996
+ function stubCtx(projectRoot) {
7997
+ return {
7998
+ projectRoot,
7999
+ cwd: projectRoot,
8000
+ messages: [],
8001
+ todos: [],
8002
+ readFiles: /* @__PURE__ */ new Set(),
8003
+ fileMtimes: /* @__PURE__ */ new Map()
8004
+ };
8005
+ }
8006
+ async function indexService(args, hooks = {}) {
8007
+ return runIndexer(stubCtx(args.projectRoot), {
8008
+ projectRoot: args.projectRoot,
8009
+ indexDir: args.indexDir,
8010
+ files: args.files,
8011
+ force: args.force,
8012
+ langs: args.langs,
8013
+ ignore: args.ignore,
8014
+ signal: hooks.signal,
8015
+ onProgress: hooks.onProgress
8016
+ });
8017
+ }
8018
+ function searchService(args) {
8019
+ const store = new IndexStore(args.projectRoot, { indexDir: args.indexDir });
8020
+ try {
8021
+ return store.searchRanked(
8022
+ args.query,
8023
+ {
8024
+ kind: args.kind,
8025
+ lang: args.lang,
8026
+ file: args.file,
8027
+ lspKind: args.lspKind
8028
+ },
8029
+ args.limit
8030
+ );
8031
+ } finally {
8032
+ store.close();
8033
+ }
8034
+ }
8035
+ function statsService(args) {
8036
+ const store = new IndexStore(args.projectRoot, { indexDir: args.indexDir });
8037
+ try {
8038
+ return store.getStats();
8039
+ } finally {
8040
+ store.close();
8041
+ }
8042
+ }
8043
+
8044
+ // src/codebase-index/background-indexer.ts
8045
+ var DEFAULT_FULL_INDEX_TIMEOUT_MS = 12e4;
8046
+ var DEFAULT_INCREMENTAL_TIMEOUT_MS = 3e4;
8047
+ var DEFAULT_QUERY_TIMEOUT_MS = 8e3;
8048
+ var _ready = false;
8049
+ var _indexing = false;
8050
+ var _currentFile = 0;
8051
+ var _totalFiles = 0;
8052
+ var _lastError = null;
8053
+ function isIndexReady() {
8054
+ return _ready;
8055
+ }
8056
+ function isIndexing() {
8057
+ return _indexing;
8058
+ }
8059
+ function getIndexState() {
8060
+ return {
8061
+ ready: _ready,
8062
+ indexing: _indexing,
8063
+ currentFile: _currentFile,
8064
+ totalFiles: _totalFiles,
8065
+ lastError: _lastError,
8066
+ circuit: indexCircuitBreaker.snapshot()
8067
+ };
8068
+ }
8069
+ var _listeners = [];
8070
+ function onIndexStateChange(listener) {
8071
+ _listeners.push(listener);
8072
+ return () => {
8073
+ _listeners = _listeners.filter((l) => l !== listener);
8074
+ };
8075
+ }
8076
+ function emitState() {
8077
+ const state = getIndexState();
8078
+ for (const l of _listeners) l(state);
8079
+ }
8080
+ function setIndexProgress(current, total) {
8081
+ _currentFile = current;
8082
+ _totalFiles = total;
8083
+ emitState();
8084
+ }
8085
+ var worker = null;
8086
+ var workerUnavailable = false;
8087
+ var nextRpcId = 1;
8088
+ var pending = /* @__PURE__ */ new Map();
8089
+ function resolveWorkerUrl() {
8090
+ if (process.env["WRONGSTACK_INDEX_INLINE"]) return null;
8091
+ for (const rel of ["./worker.js", "./codebase-index/worker.js"]) {
8092
+ try {
8093
+ const url = new URL(rel, import.meta.url);
8094
+ if (url.protocol === "file:" && fs7.existsSync(fileURLToPath(url))) return url;
8095
+ } catch {
8096
+ }
8097
+ }
8098
+ return null;
8099
+ }
8100
+ function failAllPending(err) {
8101
+ const entries = [...pending.values()];
8102
+ pending.clear();
8103
+ for (const p of entries) p.reject(err);
8104
+ }
8105
+ function ensureWorker() {
8106
+ if (worker) return worker;
8107
+ if (workerUnavailable) return null;
8108
+ const url = resolveWorkerUrl();
8109
+ if (!url) {
8110
+ workerUnavailable = true;
8111
+ return null;
8112
+ }
8113
+ try {
8114
+ const w = new Worker(url, { name: "wstack-codebase-index" });
8115
+ w.unref();
8116
+ w.on("message", (msg) => {
8117
+ if (msg.type === "progress") {
8118
+ pending.get(msg.id)?.onProgress?.(msg.current, msg.total);
8119
+ return;
8120
+ }
8121
+ const entry = pending.get(msg.id);
8122
+ if (!entry) return;
8123
+ pending.delete(msg.id);
8124
+ if (msg.ok) entry.resolve(msg.result);
8125
+ else entry.reject(new Error(msg.error));
8126
+ });
8127
+ w.on("error", (err) => {
8128
+ worker = null;
8129
+ failAllPending(err);
8130
+ });
8131
+ w.on("exit", () => {
8132
+ if (worker === w) worker = null;
8133
+ failAllPending(new Error("codebase-index worker exited"));
8134
+ });
8135
+ worker = w;
8136
+ return w;
8137
+ } catch {
8138
+ workerUnavailable = true;
8139
+ return null;
8140
+ }
8141
+ }
8142
+ function terminateWorker(reason) {
8143
+ const w = worker;
8144
+ worker = null;
8145
+ failAllPending(reason);
8146
+ if (w) void w.terminate().catch(() => {
8147
+ });
8148
+ }
8149
+ function shutdownCodebaseIndexHost() {
8150
+ cancelPendingReindexes();
8151
+ terminateWorker(new Error("codebase-index host shut down"));
8152
+ workerUnavailable = false;
8153
+ }
8154
+ function callIndexOp(op, args, opts) {
8155
+ const w = ensureWorker();
8156
+ if (!w) return callInline(op, args, opts);
8157
+ return new Promise((resolve7, reject) => {
8158
+ const id = nextRpcId++;
8159
+ const timer = setTimeout(() => {
8160
+ pending.delete(id);
8161
+ const err = new IndexTimeoutError(
8162
+ `Index ${op} exceeded its ${opts.timeoutMs}ms watchdog timeout`
8163
+ );
8164
+ terminateWorker(err);
8165
+ reject(err);
8166
+ }, opts.timeoutMs);
8167
+ timer.unref?.();
8168
+ const onAbort = () => {
8169
+ w.postMessage({ type: "cancel", id });
8170
+ };
8171
+ if (opts.signal?.aborted) onAbort();
8172
+ else opts.signal?.addEventListener("abort", onAbort, { once: true });
8173
+ const cleanup = () => {
8174
+ clearTimeout(timer);
8175
+ opts.signal?.removeEventListener("abort", onAbort);
8176
+ };
8177
+ pending.set(id, {
8178
+ resolve: (v) => {
8179
+ cleanup();
8180
+ resolve7(v);
8181
+ },
8182
+ reject: (e) => {
8183
+ cleanup();
8184
+ reject(e);
8185
+ },
8186
+ onProgress: opts.onProgress
8187
+ });
8188
+ w.postMessage({ type: "request", id, op, args });
8189
+ });
8190
+ }
8191
+ async function callInline(op, args, opts) {
8192
+ const ac = new AbortController();
8193
+ const onOuterAbort = () => ac.abort(opts.signal?.reason ?? new Error("Indexing cancelled"));
8194
+ if (opts.signal?.aborted) onOuterAbort();
8195
+ else opts.signal?.addEventListener("abort", onOuterAbort, { once: true });
8196
+ let timer;
8197
+ const watchdog = new Promise((_, reject) => {
8198
+ timer = setTimeout(() => {
8199
+ const err = new IndexTimeoutError(
8200
+ `Index ${op} exceeded its ${opts.timeoutMs}ms watchdog timeout`
8201
+ );
8202
+ ac.abort(err);
8203
+ reject(err);
8204
+ }, opts.timeoutMs);
8205
+ timer.unref?.();
8206
+ });
8207
+ const job = async () => {
8208
+ switch (op) {
8209
+ case "index":
8210
+ return await indexService(args, {
8211
+ signal: ac.signal,
8212
+ onProgress: opts.onProgress
8213
+ });
8214
+ case "search":
8215
+ return searchService(args);
8216
+ case "stats":
8217
+ return statsService(args);
8218
+ default:
8219
+ throw new Error(`unknown index op: ${String(op)}`);
8220
+ }
8221
+ };
8222
+ try {
8223
+ return await Promise.race([job(), watchdog]);
8224
+ } finally {
8225
+ if (timer) clearTimeout(timer);
8226
+ opts.signal?.removeEventListener("abort", onOuterAbort);
8227
+ }
8228
+ }
8229
+ var chain = Promise.resolve();
8230
+ function withMutex(job) {
8231
+ const run = chain.then(job, job);
8232
+ chain = run.then(
8233
+ () => void 0,
8234
+ () => void 0
8235
+ );
8236
+ return run;
8237
+ }
8238
+ function circuitOpenError() {
8239
+ const c = indexCircuitBreaker.snapshot();
8240
+ return new CircuitOpenError(
8241
+ "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."
8242
+ );
8243
+ }
8244
+ var DEFAULT_DEBOUNCE_MS = 400;
8245
+ var debounceTimers = /* @__PURE__ */ new Map();
8246
+ function debounceKey(indexDir, file) {
8247
+ return `${indexDir ?? ""}|${file}`;
8248
+ }
8249
+ function isIndexableFile(filePath) {
8250
+ return detectLang(filePath) !== null;
8251
+ }
8252
+ async function runStartupIndex(opts) {
8253
+ if (!indexCircuitBreaker.allowRequest()) throw circuitOpenError();
8254
+ _indexing = true;
8255
+ emitState();
8256
+ try {
8257
+ const result = await withMutex(() => {
8258
+ _currentFile = 0;
8259
+ _totalFiles = 0;
8260
+ _lastError = null;
8261
+ return callIndexOp(
8262
+ "index",
8263
+ {
8264
+ projectRoot: opts.projectRoot,
8265
+ indexDir: opts.indexDir,
8266
+ force: opts.force,
8267
+ langs: opts.langs
8268
+ },
8269
+ {
8270
+ timeoutMs: opts.timeoutMs ?? DEFAULT_FULL_INDEX_TIMEOUT_MS,
8271
+ signal: opts.signal,
8272
+ onProgress: setIndexProgress
8273
+ }
8274
+ );
8275
+ });
8276
+ _ready = true;
8277
+ indexCircuitBreaker.recordSuccess();
8278
+ return result;
8279
+ } catch (err) {
8280
+ _lastError = err instanceof Error ? err.message : String(err);
8281
+ _ready = true;
8282
+ if (!opts.signal?.aborted) indexCircuitBreaker.recordFailure(err);
8283
+ throw err;
8284
+ } finally {
8285
+ _indexing = false;
8286
+ emitState();
8287
+ }
8288
+ }
8289
+ function enqueueReindex(opts) {
8290
+ const files = opts.files.filter(isIndexableFile);
8291
+ if (files.length === 0) return;
8292
+ const ms = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
8293
+ for (const file of files) {
8294
+ const key = debounceKey(opts.indexDir, file);
8295
+ const existing = debounceTimers.get(key);
8296
+ if (existing) clearTimeout(existing);
8297
+ const timer = setTimeout(() => {
8298
+ debounceTimers.delete(key);
8299
+ if (!indexCircuitBreaker.allowRequest()) {
8300
+ opts.onError?.(circuitOpenError());
8301
+ return;
8302
+ }
8303
+ void withMutex(
8304
+ () => callIndexOp(
8305
+ "index",
8306
+ { projectRoot: opts.projectRoot, files: [file], indexDir: opts.indexDir },
8307
+ { timeoutMs: opts.timeoutMs ?? DEFAULT_INCREMENTAL_TIMEOUT_MS }
8308
+ )
8309
+ ).then(
8310
+ () => indexCircuitBreaker.recordSuccess(),
8311
+ (err) => {
8312
+ indexCircuitBreaker.recordFailure(err);
8313
+ opts.onError?.(err);
8314
+ }
8315
+ );
8316
+ }, ms);
8317
+ timer.unref?.();
8318
+ debounceTimers.set(key, timer);
8319
+ }
8320
+ }
8321
+ function cancelPendingReindexes() {
8322
+ for (const t of debounceTimers.values()) clearTimeout(t);
8323
+ debounceTimers.clear();
8324
+ }
8325
+ async function searchCodebaseIndex(args, opts = {}) {
8326
+ return callIndexOp("search", args, {
8327
+ timeoutMs: opts.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
8328
+ signal: opts.signal
8329
+ });
8330
+ }
8331
+ async function codebaseIndexStats(args, opts = {}) {
8332
+ return callIndexOp("stats", args, {
8333
+ timeoutMs: opts.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
8334
+ signal: opts.signal
8335
+ });
8336
+ }
8337
+
7470
8338
  // src/codebase-index/codebase-index-tool.ts
7471
8339
  var codebaseIndexTool = {
7472
8340
  name: "codebase-index",
@@ -7502,103 +8370,24 @@ var codebaseIndexTool = {
7502
8370
  note: "A full index is already in progress. Retry codebase-index after it completes (check codebase-stats)."
7503
8371
  };
7504
8372
  }
7505
- const result = await runIndexer(ctx, {
8373
+ const circuit = indexCircuitBreaker.snapshot();
8374
+ if (circuit.state === "open" && circuit.cooldownRemainingMs > 0) {
8375
+ return {
8376
+ filesIndexed: 0,
8377
+ symbolsIndexed: 0,
8378
+ langStats: {},
8379
+ durationMs: 0,
8380
+ errors: [],
8381
+ 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.`
8382
+ };
8383
+ }
8384
+ return await runStartupIndex({
7506
8385
  projectRoot: ctx.projectRoot,
7507
8386
  force: input.force ?? false,
7508
8387
  langs: input.langs,
7509
8388
  indexDir: codebaseIndexDirOverride(ctx),
7510
8389
  signal: execOpts?.signal
7511
8390
  });
7512
- setIndexReady();
7513
- return result;
7514
- }
7515
- };
7516
-
7517
- // src/codebase-index/bm25.ts
7518
- var K1 = 1.5;
7519
- var B = 0.75;
7520
- function tokenise(text) {
7521
- const sanitised = text.replace(/[^\p{L}\p{N}$'_]/gu, " ").replace(/_/g, " ");
7522
- return sanitised.toLowerCase().split(" ").filter(Boolean);
7523
- }
7524
- function splitName(name) {
7525
- return name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
7526
- }
7527
- function buildIndexableText(name, signature, docComment) {
7528
- return [splitName(name), name, signature, docComment].filter(Boolean).join(" ");
7529
- }
7530
- function buildBm25Index(docs) {
7531
- const documents = docs.map((d) => {
7532
- const tokens = tokenise(d.text);
7533
- return { id: d.id, tokens, raw: d.text, len: tokens.length };
7534
- });
7535
- const df = {};
7536
- for (const doc of documents) {
7537
- const seen = /* @__PURE__ */ new Set();
7538
- for (const t of doc.tokens) {
7539
- if (!seen.has(t)) {
7540
- df[t] = (df[t] ?? 0) + 1;
7541
- seen.add(t);
7542
- }
7543
- }
7544
- }
7545
- const N = documents.length;
7546
- const totalLen = documents.reduce((sum, d) => sum + d.len, 0);
7547
- const avgLen = N === 0 ? 0 : totalLen / N;
7548
- return new Bm25Index(documents, df, N, avgLen);
7549
- }
7550
- var Bm25Index = class {
7551
- constructor(documents, df, N, avgLen) {
7552
- this.documents = documents;
7553
- this.df = df;
7554
- this.N = N;
7555
- this.safeAvgLen = avgLen === 0 ? 1 : avgLen;
7556
- }
7557
- documents;
7558
- df;
7559
- N;
7560
- safeAvgLen;
7561
- score(query2, filter) {
7562
- const qTokens = tokenise(query2);
7563
- if (qTokens.length === 0) return [];
7564
- const results = [];
7565
- for (const doc of this.documents) {
7566
- if (filter && !filter(doc.id)) continue;
7567
- let docScore = 0;
7568
- for (const qTerm of qTokens) {
7569
- let tf = 0;
7570
- for (const t of doc.tokens) {
7571
- if (t === qTerm) tf++;
7572
- }
7573
- if (tf === 0) continue;
7574
- const dfVal = this.df[qTerm] ?? 0;
7575
- if (dfVal === 0) continue;
7576
- const idf = Math.log((this.N - dfVal + 0.5) / (dfVal + 0.5) + 1);
7577
- const lenRatio = B * (doc.len / this.safeAvgLen);
7578
- const tfComponent = tf * (K1 + 1) / (tf + K1 * (1 - B + lenRatio));
7579
- docScore += idf * tfComponent;
7580
- }
7581
- if (docScore > 0) results.push({ id: doc.id, score: docScore });
7582
- }
7583
- return results;
7584
- }
7585
- getDoc(id) {
7586
- return this.documents.find((d) => d.id === id);
7587
- }
7588
- extractSnippet(docId, queryTokens, radius = 40) {
7589
- const doc = this.getDoc(docId);
7590
- if (!doc) return "";
7591
- for (const tok of queryTokens) {
7592
- const idx = doc.raw.toLowerCase().indexOf(tok);
7593
- if (idx !== -1) {
7594
- const start = Math.max(0, idx - radius);
7595
- const end = Math.min(doc.raw.length, idx + tok.length + radius);
7596
- const excerpt = doc.raw.slice(start, end);
7597
- const ellipsis = "\u2026";
7598
- return (start > 0 ? ellipsis : "") + excerpt + (end < doc.raw.length ? ellipsis : "");
7599
- }
7600
- }
7601
- return doc.raw.slice(0, radius * 2) + (doc.raw.length > radius * 2 ? "\u2026" : "");
7602
8391
  }
7603
8392
  };
7604
8393
 
@@ -7644,7 +8433,7 @@ var codebaseSearchTool = {
7644
8433
  },
7645
8434
  required: ["query"]
7646
8435
  },
7647
- async execute(input, ctx) {
8436
+ async execute(input, ctx, execOpts) {
7648
8437
  const state = getIndexState();
7649
8438
  if (!state.ready) {
7650
8439
  return {
@@ -7663,51 +8452,30 @@ var codebaseSearchTool = {
7663
8452
  };
7664
8453
  }
7665
8454
  if (state.lastError) {
8455
+ const circuit = state.circuit;
8456
+ 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.";
7666
8457
  return {
7667
8458
  results: [],
7668
8459
  total: 0,
7669
8460
  query: input.query,
7670
- indexStatus: `Index build failed: ${state.lastError}. Try /codebase-reindex.`
8461
+ indexStatus: `Index build failed: ${state.lastError}. ${retryHint}`
7671
8462
  };
7672
8463
  }
7673
- const store = new IndexStore(ctx.projectRoot, { indexDir: codebaseIndexDirOverride(ctx) });
7674
- try {
7675
- const limit = Math.min(input.limit ?? 20, 100);
7676
- const candidates = store.search(input.query, {
8464
+ const limit = Math.min(input.limit ?? 20, 100);
8465
+ const { results, total } = await searchCodebaseIndex(
8466
+ {
8467
+ projectRoot: ctx.projectRoot,
8468
+ indexDir: codebaseIndexDirOverride(ctx),
8469
+ query: input.query,
7677
8470
  kind: input.kind,
7678
8471
  lang: input.lang,
7679
8472
  file: input.file,
7680
- lspKind: input.lspKind
7681
- });
7682
- if (candidates.length === 0) {
7683
- return { results: [], total: 0, query: input.query };
7684
- }
7685
- const indexable = candidates.map((c) => ({
7686
- id: c.id,
7687
- text: buildIndexableText(c.name, c.signature, c.docComment)
7688
- }));
7689
- const bm25 = buildBm25Index(indexable);
7690
- const scored = bm25.score(input.query, (id) => candidates.some((c) => c.id === id));
7691
- scored.sort((a, b) => b.score - a.score);
7692
- const top = scored.slice(0, limit);
7693
- const qTokens = tokenise(input.query);
7694
- const results = top.map(({ id, score }) => {
7695
- const c = expectDefined(candidates.find((c2) => c2.id === id));
7696
- const snippet = bm25.extractSnippet(id, qTokens);
7697
- return {
7698
- ...c,
7699
- score,
7700
- snippet
7701
- };
7702
- });
7703
- return {
7704
- results,
7705
- total: candidates.length,
7706
- query: input.query
7707
- };
7708
- } finally {
7709
- store.close();
7710
- }
8473
+ lspKind: input.lspKind,
8474
+ limit
8475
+ },
8476
+ { signal: execOpts?.signal }
8477
+ );
8478
+ return { results, total, query: input.query };
7711
8479
  }
7712
8480
  };
7713
8481
 
@@ -7726,7 +8494,7 @@ var codebaseStatsTool = {
7726
8494
  properties: {},
7727
8495
  additionalProperties: false
7728
8496
  },
7729
- async execute(_input, ctx) {
8497
+ async execute(_input, ctx, execOpts) {
7730
8498
  const idxState = getIndexState();
7731
8499
  if (!idxState.ready) {
7732
8500
  return {
@@ -7741,51 +8509,113 @@ var codebaseStatsTool = {
7741
8509
  indexStatus: idxState.indexing ? `Indexing in progress (${idxState.currentFile}/${idxState.totalFiles} files).` : "Index not yet built."
7742
8510
  };
7743
8511
  }
8512
+ const stats = await codebaseIndexStats(
8513
+ { projectRoot: ctx.projectRoot, indexDir: codebaseIndexDirOverride(ctx) },
8514
+ { signal: execOpts?.signal }
8515
+ );
7744
8516
  if (idxState.indexing) {
7745
- const store2 = new IndexStore(ctx.projectRoot, { indexDir: codebaseIndexDirOverride(ctx) });
7746
- try {
7747
- const stats = store2.getStats();
7748
- return {
7749
- ...stats,
7750
- indexStatus: `Index refresh in progress (${idxState.currentFile}/${idxState.totalFiles} files). Stats may be incomplete.`
7751
- };
7752
- } finally {
7753
- store2.close();
8517
+ return {
8518
+ ...stats,
8519
+ indexStatus: `Index refresh in progress (${idxState.currentFile}/${idxState.totalFiles} files). Stats may be incomplete.`
8520
+ };
8521
+ }
8522
+ const circuit = idxState.circuit;
8523
+ return {
8524
+ totalSymbols: stats.totalSymbols,
8525
+ totalFiles: stats.totalFiles,
8526
+ byLang: stats.byLang,
8527
+ byKind: stats.byKind,
8528
+ lastIndexed: stats.lastIndexed,
8529
+ sizeBytes: stats.sizeBytes,
8530
+ indexPath: stats.indexPath,
8531
+ version: stats.version,
8532
+ ...circuit.state === "open" ? {
8533
+ 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.`
8534
+ } : {}
8535
+ };
8536
+ }
8537
+ };
8538
+ var setWorkingDirTool = {
8539
+ name: "set_working_dir",
8540
+ category: "Context",
8541
+ description: "Change the current working directory for all subsequent file operations. The new directory must be inside the project root. Use this to navigate between subdirectories when working on files in different parts of the project.",
8542
+ usageHint: "Change the working directory so relative paths in subsequent tool calls resolve from a different directory. Pass `path` to set a new directory, or omit to query the current one. The directory must exist and be inside the project root.",
8543
+ permission: "confirm",
8544
+ mutating: true,
8545
+ capabilities: ["fs.read"],
8546
+ timeoutMs: 5e3,
8547
+ inputSchema: {
8548
+ type: "object",
8549
+ properties: {
8550
+ path: {
8551
+ type: "string",
8552
+ description: "Directory to navigate to. Can be relative (to projectRoot) or absolute. If omitted, returns the current working directory without changing it."
7754
8553
  }
7755
8554
  }
7756
- const store = new IndexStore(ctx.projectRoot, { indexDir: codebaseIndexDirOverride(ctx) });
8555
+ },
8556
+ async execute(input, ctx, _opts) {
8557
+ if (!input.path) {
8558
+ return {
8559
+ current: ctx.workingDir,
8560
+ message: `Current working directory is ${ctx.workingDir}`
8561
+ };
8562
+ }
8563
+ const previous = ctx.workingDir;
8564
+ let resolved;
7757
8565
  try {
7758
- const stats = store.getStats();
8566
+ resolved = ctx.setWorkingDir(input.path);
8567
+ } catch (err) {
7759
8568
  return {
7760
- totalSymbols: stats.totalSymbols,
7761
- totalFiles: stats.totalFiles,
7762
- byLang: stats.byLang,
7763
- byKind: stats.byKind,
7764
- lastIndexed: stats.lastIndexed,
7765
- sizeBytes: stats.sizeBytes,
7766
- indexPath: stats.indexPath,
7767
- version: stats.version
8569
+ current: ctx.workingDir,
8570
+ error: err instanceof Error ? err.message : String(err)
7768
8571
  };
7769
- } finally {
7770
- store.close();
7771
8572
  }
8573
+ try {
8574
+ await fs4.access(resolved);
8575
+ } catch {
8576
+ try {
8577
+ ctx.setWorkingDir(previous);
8578
+ } catch {
8579
+ }
8580
+ return {
8581
+ current: ctx.workingDir,
8582
+ error: `Directory does not exist: ${resolved}`
8583
+ };
8584
+ }
8585
+ return {
8586
+ current: resolved,
8587
+ previous,
8588
+ message: `Working directory changed to ${resolved}`
8589
+ };
7772
8590
  }
7773
8591
  };
8592
+ function findTaskIndex(tasks, query2) {
8593
+ const asNum = Number.parseInt(query2, 10);
8594
+ if (!Number.isNaN(asNum)) {
8595
+ const idx = asNum - 1;
8596
+ if (tasks[idx]) return idx;
8597
+ }
8598
+ const byId = tasks.findIndex((t) => t.id === query2);
8599
+ if (byId >= 0) return byId;
8600
+ const lower = query2.toLowerCase();
8601
+ return tasks.findIndex((t) => t.title.toLowerCase().includes(lower));
8602
+ }
7774
8603
  var taskTool = {
7775
8604
  name: "task",
7776
8605
  category: "Session",
7777
8606
  description: "Manage structured work items with dependencies, types, and priorities. Use this for complex, multi-step work where tasks have ordering constraints. Unlike `todo` (flat, tactical), `task` supports typed work (feature/bugfix/refactor/etc.), dependencies between items, priority ranking, and agent assignment. The task list persists across session resumes.",
7778
- usageHint: 'USE FOR STRUCTURED WORK:\n- `action: "replace"` \u2014 set the complete task list (tasks ordered by priority)\n- `action: "add"` \u2014 append a single task\n- `action: "status"` \u2014 update a task\'s status (e.g. pending\u2192in_progress, in_progress\u2192completed)\n- `action: "show"` \u2014 view current tasks without changing them\n\nTask fields:\n- `dependsOn`: list of task IDs this one waits for\n- `type`: "feature" | "bugfix" | "refactor" | "docs" | "test" | "chore"\n- `priority`: "critical" | "high" | "medium" | "low"\n- `assignee`: agent/subagent name (e.g. "bug-hunter", "refactor-planner")\n- `estimateHours`: rough time estimate',
7779
- permission: "auto",
7780
- mutating: false,
8607
+ usageHint: 'USE FOR STRUCTURED WORK:\n- `action: "replace"` \u2014 set the complete task list (tasks ordered by priority)\n- `action: "add"` \u2014 append a single task\n- `action: "status"` \u2014 update a task\'s status (e.g. pending\u2192in_progress, in_progress\u2192completed)\n- `action: "show"` \u2014 view current tasks without changing them\n- `action: "promote"` \u2014 convert a task into actionable todo items via `target` (id|index|substring)\n- `action: "planify"` \u2014 promote a task to a plan item (strategic level) via `target` (id|index|substring)\n\nTask fields:\n- `dependsOn`: list of task IDs this one waits for\n- `type`: "feature" | "bugfix" | "refactor" | "docs" | "test" | "chore"\n- `priority`: "critical" | "high" | "medium" | "low"\n- `assignee`: agent/subagent name (e.g. "bug-hunter", "refactor-planner")\n- `estimateHours`: rough time estimate',
8608
+ permission: "confirm",
8609
+ mutating: true,
8610
+ capabilities: ["fs.write"],
7781
8611
  timeoutMs: 2e3,
7782
8612
  inputSchema: {
7783
8613
  type: "object",
7784
8614
  properties: {
7785
8615
  action: {
7786
8616
  type: "string",
7787
- enum: ["replace", "add", "status", "show"],
7788
- description: "replace = set full list, add = append, status = update task status, show = view only."
8617
+ enum: ["replace", "add", "status", "show", "promote", "planify"],
8618
+ description: "replace = set full list, add = append, status = update task status, show = view only, promote = convert task to todos, planify = convert task to plan item."
7789
8619
  },
7790
8620
  tasks: {
7791
8621
  type: "array",
@@ -7829,11 +8659,20 @@ var taskTool = {
7829
8659
  required: ["title", "type", "priority"],
7830
8660
  description: "Single task to append (id/createdAt/updatedAt auto-generated)."
7831
8661
  },
7832
- id: { type: "string", description: "Task id for action=status." },
8662
+ id: { type: "string", description: "Task id for action=status or target for action=promote." },
7833
8663
  status: {
7834
8664
  type: "string",
7835
8665
  enum: ["pending", "in_progress", "blocked", "failed", "review", "completed"],
7836
8666
  description: "New status for action=status."
8667
+ },
8668
+ target: {
8669
+ type: "string",
8670
+ description: "Target task identifier (id, 1-based index, or title substring) for action=promote."
8671
+ },
8672
+ subtasks: {
8673
+ type: "array",
8674
+ items: { type: "string" },
8675
+ description: "Optional subtask titles for action=promote. Each becomes a pending todo."
7837
8676
  }
7838
8677
  },
7839
8678
  required: ["action"]
@@ -7844,65 +8683,196 @@ var taskTool = {
7844
8683
  return { ok: false, message: "Task storage path not configured.", count: 0, completed: 0, inProgress: 0 };
7845
8684
  }
7846
8685
  const sessionId = ctx.session?.id ?? "unknown";
7847
- const file = await loadTasks(taskPath) ?? emptyTaskFile(sessionId);
7848
- switch (input.action) {
7849
- case "show":
7850
- break;
7851
- case "replace": {
7852
- if (!Array.isArray(input.tasks)) {
7853
- return { ok: false, message: "action=replace requires `tasks` array.", count: 0, completed: 0, inProgress: 0 };
8686
+ let early = null;
8687
+ const promoteMeta = { count: 0, title: "" };
8688
+ const planifyMeta = { title: "", details: "" };
8689
+ let didPlanify = false;
8690
+ const file = await mutateTasks(taskPath, sessionId, async (f) => {
8691
+ switch (input.action) {
8692
+ case "show":
8693
+ break;
8694
+ case "replace": {
8695
+ if (!Array.isArray(input.tasks)) {
8696
+ early = { ok: false, message: "action=replace requires `tasks` array.", count: 0, completed: 0, inProgress: 0 };
8697
+ return f;
8698
+ }
8699
+ const newIds = new Set(input.tasks.map((t) => t.id));
8700
+ for (const t of input.tasks) {
8701
+ if (t.dependsOn && t.dependsOn.length > 0) {
8702
+ const missing = t.dependsOn.filter((d) => !newIds.has(d));
8703
+ if (missing.length > 0) {
8704
+ early = {
8705
+ ok: false,
8706
+ message: `dependsOn validation failed: task "${t.id}" references unknown IDs: ${missing.join(", ")}`,
8707
+ count: 0,
8708
+ completed: 0,
8709
+ inProgress: 0
8710
+ };
8711
+ return f;
8712
+ }
8713
+ }
8714
+ }
8715
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8716
+ f.tasks = input.tasks.map((t) => ({
8717
+ ...t,
8718
+ createdAt: t.createdAt || now,
8719
+ updatedAt: now
8720
+ }));
8721
+ break;
7854
8722
  }
7855
- const now = (/* @__PURE__ */ new Date()).toISOString();
7856
- file.tasks = input.tasks.map((t) => ({
7857
- ...t,
7858
- createdAt: t.createdAt || now,
7859
- updatedAt: now
7860
- }));
7861
- await saveTasks(taskPath, file);
7862
- break;
7863
- }
7864
- case "add": {
7865
- const t = input.task;
7866
- if (!t || !t.title) {
7867
- return { ok: false, message: "action=add requires `task` with at least `title`.", count: 0, completed: 0, inProgress: 0 };
8723
+ case "add": {
8724
+ const t = input.task;
8725
+ if (!t || !t.title) {
8726
+ early = { ok: false, message: "action=add requires `task` with at least `title`.", count: 0, completed: 0, inProgress: 0 };
8727
+ return f;
8728
+ }
8729
+ if (t.dependsOn && t.dependsOn.length > 0) {
8730
+ const existingIds = new Set(f.tasks.map((e) => e.id));
8731
+ const missing = t.dependsOn.filter((d) => !existingIds.has(d));
8732
+ if (missing.length > 0) {
8733
+ early = {
8734
+ ok: false,
8735
+ message: `dependsOn validation failed: unknown task IDs: ${missing.join(", ")}`,
8736
+ count: 0,
8737
+ completed: 0,
8738
+ inProgress: 0
8739
+ };
8740
+ return f;
8741
+ }
8742
+ }
8743
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8744
+ const newTask = {
8745
+ id: `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
8746
+ title: t.title,
8747
+ description: t.description,
8748
+ type: t.type || "feature",
8749
+ priority: t.priority || "medium",
8750
+ status: t.status || "pending",
8751
+ dependsOn: t.dependsOn,
8752
+ assignee: t.assignee,
8753
+ estimateHours: t.estimateHours,
8754
+ tags: t.tags,
8755
+ createdAt: now,
8756
+ updatedAt: now
8757
+ };
8758
+ f.tasks.push(newTask);
8759
+ break;
7868
8760
  }
7869
- const now = (/* @__PURE__ */ new Date()).toISOString();
7870
- const newTask = {
7871
- id: `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
7872
- title: t.title,
7873
- description: t.description,
7874
- type: t.type || "feature",
7875
- priority: t.priority || "medium",
7876
- status: t.status || "pending",
7877
- dependsOn: t.dependsOn,
7878
- assignee: t.assignee,
7879
- estimateHours: t.estimateHours,
7880
- tags: t.tags,
7881
- createdAt: now,
7882
- updatedAt: now
7883
- };
7884
- file.tasks.push(newTask);
7885
- await saveTasks(taskPath, file);
7886
- break;
7887
- }
7888
- case "status": {
7889
- if (!input.id || !input.status) {
7890
- return { ok: false, message: "action=status requires `id` and `status`.", count: 0, completed: 0, inProgress: 0 };
8761
+ case "status": {
8762
+ if (!input.id || !input.status) {
8763
+ early = { ok: false, message: "action=status requires `id` and `status`.", count: 0, completed: 0, inProgress: 0 };
8764
+ return f;
8765
+ }
8766
+ const task = f.tasks.find((t) => t.id === input.id);
8767
+ if (!task) {
8768
+ early = { ok: false, message: `Task "${input.id}" not found.`, count: 0, completed: 0, inProgress: 0 };
8769
+ return f;
8770
+ }
8771
+ task.status = input.status;
8772
+ task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
8773
+ break;
8774
+ }
8775
+ case "promote": {
8776
+ const target = input.target?.trim();
8777
+ if (!target) {
8778
+ early = { ok: false, message: "action=promote requires `target` (task id, index, or title substring).", count: 0, completed: 0, inProgress: 0 };
8779
+ return f;
8780
+ }
8781
+ const idx = findTaskIndex(f.tasks, target);
8782
+ if (idx === -1) {
8783
+ early = { ok: false, message: `No task matched "${target}".`, count: 0, completed: 0, inProgress: 0 };
8784
+ return f;
8785
+ }
8786
+ const match = f.tasks[idx];
8787
+ if (!match) {
8788
+ early = { ok: false, message: `No task matched "${target}".`, count: 0, completed: 0, inProgress: 0 };
8789
+ return f;
8790
+ }
8791
+ if (match.status !== "completed" && match.status !== "failed") {
8792
+ match.status = "in_progress";
8793
+ match.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
8794
+ }
8795
+ const todos = [];
8796
+ const ts2 = Date.now();
8797
+ todos.push({
8798
+ id: `todo_${ts2}_task`,
8799
+ content: match.title,
8800
+ status: "in_progress",
8801
+ activeForm: match.title,
8802
+ promotedFromTask: match.id
8803
+ });
8804
+ if (match.description) {
8805
+ todos.push({
8806
+ id: `todo_${ts2}_${randomUUID().slice(0, 6)}`,
8807
+ content: match.description.slice(0, 200),
8808
+ status: "pending",
8809
+ promotedFromTask: match.id
8810
+ });
8811
+ }
8812
+ if (input.subtasks && input.subtasks.length > 0) {
8813
+ for (const st of input.subtasks) {
8814
+ todos.push({
8815
+ id: `todo_${ts2}_${randomUUID().slice(0, 6)}`,
8816
+ content: st,
8817
+ status: "pending",
8818
+ promotedFromTask: match.id
8819
+ });
8820
+ }
8821
+ }
8822
+ ctx.state.replaceTodos(todos);
8823
+ promoteMeta.count = todos.length;
8824
+ promoteMeta.title = match.title;
8825
+ break;
7891
8826
  }
7892
- const task = file.tasks.find((t) => t.id === input.id);
7893
- if (!task) {
7894
- return { ok: false, message: `Task "${input.id}" not found.`, count: 0, completed: 0, inProgress: 0 };
8827
+ case "planify": {
8828
+ const target = input.target?.trim();
8829
+ if (!target) {
8830
+ early = { ok: false, message: "action=planify requires `target` (task id, index, or title substring).", count: 0, completed: 0, inProgress: 0 };
8831
+ return f;
8832
+ }
8833
+ const idx = findTaskIndex(f.tasks, target);
8834
+ if (idx === -1) {
8835
+ early = { ok: false, message: `No task matched "${target}".`, count: 0, completed: 0, inProgress: 0 };
8836
+ return f;
8837
+ }
8838
+ const match = f.tasks[idx];
8839
+ if (!match) {
8840
+ early = { ok: false, message: `No task matched "${target}".`, count: 0, completed: 0, inProgress: 0 };
8841
+ return f;
8842
+ }
8843
+ planifyMeta.title = match.title;
8844
+ planifyMeta.details = match.description ?? "";
8845
+ didPlanify = true;
8846
+ break;
7895
8847
  }
7896
- task.status = input.status;
7897
- task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
7898
- await saveTasks(taskPath, file);
7899
- break;
8848
+ default:
8849
+ early = { ok: false, message: `Unknown action "${input.action}". Use replace | add | status | show | promote | planify.`, count: 0, completed: 0, inProgress: 0 };
8850
+ return f;
7900
8851
  }
7901
- default:
7902
- return { ok: false, message: `Unknown action "${input.action}". Use replace | add | status | show.`, count: 0, completed: 0, inProgress: 0 };
8852
+ return f;
8853
+ });
8854
+ if (early) return early;
8855
+ if (didPlanify) {
8856
+ const { title, details } = planifyMeta;
8857
+ const planPath = ctx.meta["plan.path"];
8858
+ if (typeof planPath === "string" && planPath) {
8859
+ const planCfg = await loadPlan(planPath) ?? emptyPlan(sessionId);
8860
+ const { plan: updated } = addPlanItem(planCfg, title, details || void 0);
8861
+ await savePlan(planPath, updated);
8862
+ return {
8863
+ ok: true,
8864
+ message: `planify ok \u2014 added "${title}" to plan.
8865
+ ${formatPlan(updated)}`,
8866
+ count: file.tasks.length,
8867
+ completed: computeTaskItemProgress(file.tasks).completed,
8868
+ inProgress: computeTaskItemProgress(file.tasks).inProgress
8869
+ };
8870
+ }
8871
+ return { ok: false, message: "Plan storage path not configured \u2014 cannot planify.", count: 0, completed: 0, inProgress: 0 };
7903
8872
  }
7904
8873
  const p = computeTaskItemProgress(file.tasks);
7905
- const summary = file.tasks.length > 0 ? formatTaskList(file.tasks) : "No tasks.";
8874
+ const summary = promoteMeta.count > 0 ? `promote ok \u2014 ${promoteMeta.count} todo(s) created from "${promoteMeta.title}".
8875
+ ${formatTaskList(file.tasks)}` : file.tasks.length > 0 ? formatTaskList(file.tasks) : "No tasks.";
7906
8876
  return {
7907
8877
  ok: true,
7908
8878
  message: summary,
@@ -7949,7 +8919,8 @@ var builtinTools = [
7949
8919
  toolHelpTool,
7950
8920
  codebaseIndexTool,
7951
8921
  codebaseSearchTool,
7952
- codebaseStatsTool
8922
+ codebaseStatsTool,
8923
+ setWorkingDirTool
7953
8924
  ];
7954
8925
 
7955
8926
  // src/pack.ts
@@ -7959,6 +8930,6 @@ var builtinToolsPack = {
7959
8930
  tools: builtinTools
7960
8931
  };
7961
8932
 
7962
- 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 };
8933
+ 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 };
7963
8934
  //# sourceMappingURL=index.js.map
7964
8935
  //# sourceMappingURL=index.js.map