@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/builtin.js CHANGED
@@ -1,17 +1,20 @@
1
1
  import { spawn, execFileSync, spawnSync } from 'node:child_process';
2
2
  import * as Core from '@wrongstack/core';
3
- import { buildChildEnv, expectDefined, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff, compileGlob, loadPlan, emptyPlan, clearPlan, savePlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, setPlanItemStatus, formatPlan, loadTasks, emptyTaskFile, saveTasks, computeTaskItemProgress, formatTaskList, resolveWstackPaths } from '@wrongstack/core';
3
+ import { buildChildEnv, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff, compileGlob, expectDefined, recordPackageAction, detectPackageEcosystem, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, setPlanItemStatus, loadTasks, emptyTaskFile, saveTasks, formatTaskList, formatPlan, mutateTasks, loadPlan, emptyPlan, savePlan, computeTaskItemProgress, resolveWstackPaths, truncate } from '@wrongstack/core';
4
4
  import * as fs from 'node:fs';
5
5
  import { statSync, writeFileSync, mkdirSync } from 'node:fs';
6
6
  import * as path2 from 'node:path';
7
- import { resolve, sep, dirname } from 'node:path';
8
- import * as fs13 from 'node:fs/promises';
7
+ import { resolve, sep, dirname, join } from 'node:path';
8
+ import * as fs14 from 'node:fs/promises';
9
9
  import * as os from 'node:os';
10
10
  import { createRequire } from 'node:module';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { Worker } from 'node:worker_threads';
11
13
  import * as ts from 'typescript';
12
14
  import * as dns from 'node:dns/promises';
13
15
  import * as net from 'node:net';
14
16
  import { Agent } from 'undici';
17
+ import { randomUUID } from 'node:crypto';
15
18
 
16
19
  // src/_spawn-stream.ts
17
20
  function resolveWin32Command(cmd) {
@@ -39,9 +42,10 @@ function resolveWin32Command(cmd) {
39
42
  async function* spawnStream(opts) {
40
43
  const max = opts.maxBytes ?? 2e5;
41
44
  const flushAt = opts.flushBytes ?? 4 * 1024;
45
+ const maxQueue = opts.maxQueueSize ?? 500;
42
46
  let stdout = "";
43
47
  let stderr = "";
44
- let pending = "";
48
+ let pending2 = "";
45
49
  let error;
46
50
  const cmd = resolveWin32Command(opts.cmd);
47
51
  const needsShell = process.platform === "win32" && (cmd.endsWith(".cmd") || cmd.endsWith(".bat"));
@@ -50,10 +54,12 @@ async function* spawnStream(opts) {
50
54
  signal: opts.signal,
51
55
  env: buildChildEnv(),
52
56
  stdio: ["ignore", "pipe", "pipe"],
57
+ windowsHide: true,
53
58
  ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {}
54
59
  });
55
60
  const queue = [];
56
61
  let waiter;
62
+ let paused = false;
57
63
  const wake = () => {
58
64
  if (waiter) {
59
65
  const w = waiter;
@@ -61,17 +67,34 @@ async function* spawnStream(opts) {
61
67
  w();
62
68
  }
63
69
  };
70
+ const resume = () => {
71
+ if (paused && queue.length < maxQueue) {
72
+ paused = false;
73
+ child.stdout?.resume();
74
+ child.stderr?.resume();
75
+ }
76
+ };
64
77
  child.stdout?.on("data", (c) => {
65
78
  const s = c.toString();
66
79
  if (stdout.length < max) stdout += s;
67
80
  queue.push({ kind: "out", data: s });
68
81
  wake();
82
+ if (!paused && queue.length >= maxQueue) {
83
+ paused = true;
84
+ child.stdout?.pause();
85
+ child.stderr?.pause();
86
+ }
69
87
  });
70
88
  child.stderr?.on("data", (c) => {
71
89
  const s = c.toString();
72
90
  if (stderr.length < max) stderr += s;
73
91
  queue.push({ kind: "err", data: s });
74
92
  wake();
93
+ if (!paused && queue.length >= maxQueue) {
94
+ paused = true;
95
+ child.stdout?.pause();
96
+ child.stderr?.pause();
97
+ }
75
98
  });
76
99
  child.on("error", (e) => {
77
100
  error = e.message;
@@ -91,6 +114,7 @@ async function* spawnStream(opts) {
91
114
  });
92
115
  }
93
116
  const chunk = queue.shift();
117
+ resume();
94
118
  if (chunk.kind === "close") {
95
119
  if (!spawnFailed) exitCode = chunk.code ?? 0;
96
120
  break;
@@ -100,14 +124,14 @@ async function* spawnStream(opts) {
100
124
  exitCode = 1;
101
125
  continue;
102
126
  }
103
- pending += chunk.data;
104
- if (pending.length >= flushAt) {
105
- yield { type: "partial_output", text: pending };
106
- pending = "";
127
+ pending2 += chunk.data;
128
+ if (pending2.length >= flushAt) {
129
+ yield { type: "partial_output", text: pending2 };
130
+ pending2 = "";
107
131
  }
108
132
  }
109
- if (pending.length > 0) {
110
- yield { type: "partial_output", text: pending };
133
+ if (pending2.length > 0) {
134
+ yield { type: "partial_output", text: pending2 };
111
135
  }
112
136
  return {
113
137
  stdout,
@@ -132,7 +156,7 @@ async function detectPackageManager(cwd) {
132
156
  return "npm";
133
157
  }
134
158
  function resolvePath(input, ctx) {
135
- return path2.isAbsolute(input) ? path2.normalize(input) : path2.resolve(ctx.cwd, input);
159
+ return path2.isAbsolute(input) ? path2.normalize(input) : path2.resolve(ctx.workingDir ?? ctx.cwd, input);
136
160
  }
137
161
  function ensureInsideRoot(absPath, ctx) {
138
162
  const root = path2.resolve(ctx.projectRoot);
@@ -147,12 +171,12 @@ function safeResolve(input, ctx) {
147
171
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
148
172
  }
149
173
  async function assertRealInsideRoot(absPath, ctx) {
150
- const realRoot = await fs13.realpath(ctx.projectRoot).catch(() => path2.resolve(ctx.projectRoot));
174
+ const realRoot = await fs14.realpath(ctx.projectRoot).catch(() => path2.resolve(ctx.projectRoot));
151
175
  let probe = absPath;
152
176
  for (; ; ) {
153
177
  let real;
154
178
  try {
155
- real = await fs13.realpath(probe);
179
+ real = await fs14.realpath(probe);
156
180
  } catch (err) {
157
181
  if (err.code === "ENOENT") {
158
182
  const parent = path2.dirname(probe);
@@ -423,8 +447,13 @@ var CircuitBreaker = class {
423
447
  * Call this BEFORE spawning a bash/exec process.
424
448
  * Returns true if the call is allowed; false if the breaker is open.
425
449
  * When false, callers MUST NOT spawn a process.
450
+ *
451
+ * @param bypass - If true, skip the circuit breaker check entirely.
452
+ * Use for background/fire-and-forget processes that should
453
+ * not affect breaker state.
426
454
  */
427
- beforeCall() {
455
+ beforeCall(bypass = false) {
456
+ if (bypass) return true;
428
457
  this._checkStateTransition();
429
458
  if (this.state === "open") return false;
430
459
  return true;
@@ -434,8 +463,12 @@ var CircuitBreaker = class {
434
463
  * `durationMs` is the wall-clock time the process ran.
435
464
  * `failed` is true when the process returned a non-zero exit code or
436
465
  * threw an exception before spawning.
466
+ *
467
+ * @param bypass - If true, do not update breaker state.
468
+ * Use for background/fire-and-forget processes.
437
469
  */
438
- afterCall(durationMs, failed) {
470
+ afterCall(durationMs, failed, bypass = false) {
471
+ if (bypass) return;
439
472
  const now = Date.now();
440
473
  if (this.state === "half-open") {
441
474
  if (failed) {
@@ -534,6 +567,17 @@ function redactCommand(cmd) {
534
567
  return result;
535
568
  }
536
569
  var DEFAULT_GRACE_MS = 2e3;
570
+ function killWin32Tree(pid) {
571
+ try {
572
+ spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
573
+ stdio: "ignore",
574
+ windowsHide: true
575
+ }).unref();
576
+ return true;
577
+ } catch {
578
+ return false;
579
+ }
580
+ }
537
581
  var ProcessRegistryImpl = class {
538
582
  processes = /* @__PURE__ */ new Map();
539
583
  breaker;
@@ -591,16 +635,20 @@ var ProcessRegistryImpl = class {
591
635
  /**
592
636
  * Called before spawning a process. Returns true if allowed; false if
593
637
  * the circuit breaker is open.
638
+ *
639
+ * @param bypass - If true, skip circuit breaker check (for background processes).
594
640
  */
595
- beforeCall() {
596
- return this.breaker.beforeCall();
641
+ beforeCall(bypass = false) {
642
+ return this.breaker.beforeCall(bypass);
597
643
  }
598
644
  /**
599
645
  * Called after a process finishes. `durationMs` is wall-clock time;
600
646
  * `failed` is true for non-zero exit codes.
647
+ *
648
+ * @param bypass - If true, do not update circuit breaker state (for background processes).
601
649
  */
602
- afterCall(durationMs, failed) {
603
- this.breaker.afterCall(durationMs, failed);
650
+ afterCall(durationMs, failed, bypass = false) {
651
+ this.breaker.afterCall(durationMs, failed, bypass);
604
652
  }
605
653
  /** Force-open the circuit breaker (Ctrl+C, /kill force). */
606
654
  forceBreakerOpen() {
@@ -631,9 +679,22 @@ var ProcessRegistryImpl = class {
631
679
  const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;
632
680
  const isWin = os.platform() === "win32";
633
681
  if (isWin) {
634
- try {
635
- p.child.kill(force ? "SIGKILL" : "SIGTERM");
636
- } catch {
682
+ const liveRealChild = p.child.exitCode === null && typeof p.child.pid === "number";
683
+ if (liveRealChild && killWin32Tree(pid)) {
684
+ const fallback = setTimeout(() => {
685
+ if (p.child.exitCode === null) {
686
+ try {
687
+ p.child.kill("SIGKILL");
688
+ } catch {
689
+ }
690
+ }
691
+ }, graceMs);
692
+ fallback.unref?.();
693
+ } else {
694
+ try {
695
+ p.child.kill(force ? "SIGKILL" : "SIGTERM");
696
+ } catch {
697
+ }
637
698
  }
638
699
  p.killed = true;
639
700
  return true;
@@ -709,6 +770,7 @@ var MAX_OUTPUT = 32768;
709
770
  var DEFAULT_TIMEOUT_MS = 3e5;
710
771
  var STREAM_FLUSH_INTERVAL_MS = 200;
711
772
  var STREAM_FLUSH_BYTES = 4 * 1024;
773
+ var MAX_QUEUE_CHUNKS = 500;
712
774
  var bashTool = {
713
775
  name: "bash",
714
776
  category: "Shell",
@@ -756,7 +818,8 @@ var bashTool = {
756
818
  async *executeStream(input, ctx, opts) {
757
819
  if (!input?.command) throw new Error("bash: command is required");
758
820
  const registry = getProcessRegistry();
759
- if (!registry.beforeCall()) {
821
+ const bypassBreaker = !!input.background;
822
+ if (!registry.beforeCall(bypassBreaker)) {
760
823
  yield {
761
824
  type: "final",
762
825
  output: {
@@ -769,6 +832,17 @@ var bashTool = {
769
832
  };
770
833
  return;
771
834
  }
835
+ const PIPE_TO_SHELL_PATTERN = /\|\s*(sh|bash|ksh|zsh|fish|cmd|powershell|pwsh)/i;
836
+ if (PIPE_TO_SHELL_PATTERN.test(input.command)) {
837
+ console.warn(JSON.stringify({
838
+ level: "warn",
839
+ event: "bash.pipe_to_shell_detected",
840
+ message: "Detected pipe-to-shell pattern. Consider reviewing the full command before confirming.",
841
+ command_prefix: input.command.slice(0, 100),
842
+ // Log first 100 chars for review
843
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
844
+ }));
845
+ }
772
846
  const timeoutMs = Math.max(1, Math.min(input.timeout_ms ?? DEFAULT_TIMEOUT_MS, 6e5));
773
847
  const isWin = os.platform() === "win32";
774
848
  const shell = (() => {
@@ -794,6 +868,10 @@ var bashTool = {
794
868
  env,
795
869
  stdio: ["ignore", "pipe", "pipe"],
796
870
  detached: true,
871
+ // Detached console children on Windows allocate their own VISIBLE
872
+ // console window (one per background command — test suites flash
873
+ // dozens). CREATE_NO_WINDOW suppresses it; no-op elsewhere.
874
+ windowsHide: true,
797
875
  signal: opts.signal
798
876
  });
799
877
  const pid2 = child2.pid;
@@ -827,7 +905,7 @@ var bashTool = {
827
905
  }
828
906
  });
829
907
  child2.on("close", () => {
830
- registry.afterCall(Date.now() - startedAt, false);
908
+ registry.afterCall(Date.now() - startedAt, false, bypassBreaker);
831
909
  });
832
910
  if (typeof pid2 === "number") child2.unref();
833
911
  yield {
@@ -846,7 +924,8 @@ var bashTool = {
846
924
  env,
847
925
  stdio: ["ignore", "pipe", "pipe"],
848
926
  detached,
849
- signal: opts.signal
927
+ windowsHide: true,
928
+ ...isWin ? {} : { signal: opts.signal }
850
929
  });
851
930
  const pid = child.pid;
852
931
  if (typeof pid === "number") {
@@ -860,14 +939,27 @@ var bashTool = {
860
939
  });
861
940
  }
862
941
  let buf = "";
863
- let pending = "";
942
+ let pending2 = "";
864
943
  let timedOut = false;
865
944
  const timers = [];
866
945
  function killWithTimeout(child2, timeoutMs2) {
867
946
  if (isWin) {
868
- try {
869
- child2.kill();
870
- } catch {
947
+ if (typeof child2.pid === "number" && child2.exitCode === null && killWin32Tree(child2.pid)) {
948
+ const fallback = setTimeout(() => {
949
+ if (child2.exitCode === null) {
950
+ try {
951
+ child2.kill();
952
+ } catch {
953
+ }
954
+ }
955
+ }, 2e3);
956
+ timers.push(fallback);
957
+ fallback.unref?.();
958
+ } else {
959
+ try {
960
+ child2.kill();
961
+ } catch {
962
+ }
871
963
  }
872
964
  return;
873
965
  }
@@ -906,6 +998,11 @@ var bashTool = {
906
998
  }, timeoutMs);
907
999
  timers.push(timer);
908
1000
  timer.unref?.();
1001
+ const onAbort = () => killWithTimeout(child, 2e3);
1002
+ if (isWin) {
1003
+ if (opts.signal.aborted) onAbort();
1004
+ else opts.signal.addEventListener("abort", onAbort, { once: true });
1005
+ }
909
1006
  const queue = [];
910
1007
  let resolveNext = null;
911
1008
  const push = (c) => {
@@ -924,24 +1021,38 @@ var bashTool = {
924
1021
  });
925
1022
  let lastFlush = Date.now();
926
1023
  const flush = () => {
927
- if (pending.length === 0) return null;
928
- const text = pending;
929
- pending = "";
1024
+ if (pending2.length === 0) return null;
1025
+ const text = pending2;
1026
+ pending2 = "";
930
1027
  lastFlush = Date.now();
931
1028
  return text;
932
1029
  };
933
- child.stdout?.on("data", (chunk) => {
934
- const text = chunk.toString();
935
- buf += text;
936
- pending += text;
937
- push({ kind: "data", text });
938
- });
939
- child.stderr?.on("data", (chunk) => {
1030
+ let paused = false;
1031
+ const pauseIfFlooded = () => {
1032
+ if (!paused && queue.length >= MAX_QUEUE_CHUNKS) {
1033
+ paused = true;
1034
+ child.stdout?.pause();
1035
+ child.stderr?.pause();
1036
+ }
1037
+ };
1038
+ const resumeIfDrained = () => {
1039
+ if (paused && queue.length < MAX_QUEUE_CHUNKS) {
1040
+ paused = false;
1041
+ child.stdout?.resume();
1042
+ child.stderr?.resume();
1043
+ }
1044
+ };
1045
+ const onData = (chunk) => {
940
1046
  const text = chunk.toString();
941
- buf += text;
942
- pending += text;
1047
+ if (buf.length < MAX_OUTPUT) {
1048
+ buf += text.slice(0, MAX_OUTPUT - buf.length);
1049
+ }
1050
+ pending2 += text;
943
1051
  push({ kind: "data", text });
944
- });
1052
+ pauseIfFlooded();
1053
+ };
1054
+ child.stdout?.on("data", onData);
1055
+ child.stderr?.on("data", onData);
945
1056
  child.on("error", (err) => {
946
1057
  for (const t of timers) clearTimeout(t);
947
1058
  registry.afterCall(Date.now() - startedAt, true);
@@ -956,6 +1067,7 @@ var bashTool = {
956
1067
  try {
957
1068
  while (true) {
958
1069
  const c = await next();
1070
+ resumeIfDrained();
959
1071
  if (c.kind === "error") throw c.err;
960
1072
  if (c.kind === "end") {
961
1073
  const remainder = flush();
@@ -973,13 +1085,22 @@ var bashTool = {
973
1085
  return;
974
1086
  }
975
1087
  const now = Date.now();
976
- if (pending.length >= STREAM_FLUSH_BYTES || now - lastFlush >= STREAM_FLUSH_INTERVAL_MS) {
1088
+ if (pending2.length >= STREAM_FLUSH_BYTES || now - lastFlush >= STREAM_FLUSH_INTERVAL_MS) {
977
1089
  const text = flush();
978
1090
  if (text) yield { type: "partial_output", text };
979
1091
  }
980
1092
  }
981
1093
  } finally {
982
1094
  for (const t of timers) clearTimeout(t);
1095
+ if (isWin) opts.signal.removeEventListener("abort", onAbort);
1096
+ child.stdout?.off("data", onData);
1097
+ child.stderr?.off("data", onData);
1098
+ child.stdout?.destroy();
1099
+ child.stderr?.destroy();
1100
+ if (child.exitCode === null && !child.killed) {
1101
+ if (typeof pid === "number") registry.kill(pid, { force: true });
1102
+ else killWithTimeout(child, 2e3);
1103
+ }
983
1104
  }
984
1105
  }
985
1106
  };
@@ -1088,8 +1209,88 @@ async function executeSingle(call, ctx, opts) {
1088
1209
  }
1089
1210
  }
1090
1211
 
1212
+ // src/codebase-index/circuit-breaker.ts
1213
+ var CircuitOpenError = class extends Error {
1214
+ name = "CircuitOpenError";
1215
+ };
1216
+ var IndexTimeoutError = class extends Error {
1217
+ name = "IndexTimeoutError";
1218
+ };
1219
+ var LockError = class extends Error {
1220
+ name = "LockError";
1221
+ };
1222
+ var IndexCircuitBreaker = class {
1223
+ failureThreshold;
1224
+ cooldownMs;
1225
+ now;
1226
+ state = "closed";
1227
+ consecutiveFailures = 0;
1228
+ openedAt = 0;
1229
+ lastFailure = null;
1230
+ probeInFlight = false;
1231
+ constructor(opts = {}) {
1232
+ this.failureThreshold = opts.failureThreshold ?? 3;
1233
+ this.cooldownMs = opts.cooldownMs ?? 6e4;
1234
+ this.now = opts.now ?? Date.now;
1235
+ }
1236
+ /**
1237
+ * True when a run may proceed. An open circuit transitions to half-open once
1238
+ * the cooldown has elapsed, admitting exactly one probe; further requests
1239
+ * are rejected until that probe settles via recordSuccess/recordFailure.
1240
+ */
1241
+ allowRequest() {
1242
+ if (this.state === "closed") return true;
1243
+ if (this.state === "open") {
1244
+ if (this.now() - this.openedAt < this.cooldownMs) return false;
1245
+ this.state = "half-open";
1246
+ this.probeInFlight = true;
1247
+ return true;
1248
+ }
1249
+ if (this.probeInFlight) return false;
1250
+ this.probeInFlight = true;
1251
+ return true;
1252
+ }
1253
+ recordSuccess() {
1254
+ this.state = "closed";
1255
+ this.consecutiveFailures = 0;
1256
+ this.lastFailure = null;
1257
+ this.probeInFlight = false;
1258
+ }
1259
+ recordFailure(err) {
1260
+ if (err instanceof LockError) {
1261
+ this.lastFailure = `[transient/lock] ${err.message}`;
1262
+ this.probeInFlight = false;
1263
+ return;
1264
+ }
1265
+ this.lastFailure = err instanceof Error ? err.message : String(err);
1266
+ this.probeInFlight = false;
1267
+ this.consecutiveFailures++;
1268
+ if (this.state === "half-open" || this.consecutiveFailures >= this.failureThreshold) {
1269
+ this.state = "open";
1270
+ this.openedAt = this.now();
1271
+ }
1272
+ }
1273
+ /** Force-close the circuit (manual recovery: `/codebase-reindex`). */
1274
+ reset() {
1275
+ this.state = "closed";
1276
+ this.consecutiveFailures = 0;
1277
+ this.lastFailure = null;
1278
+ this.probeInFlight = false;
1279
+ this.openedAt = 0;
1280
+ }
1281
+ snapshot() {
1282
+ return {
1283
+ state: this.state,
1284
+ consecutiveFailures: this.consecutiveFailures,
1285
+ lastFailure: this.lastFailure,
1286
+ cooldownRemainingMs: this.state === "open" ? Math.max(0, this.cooldownMs - (this.now() - this.openedAt)) : 0
1287
+ };
1288
+ }
1289
+ };
1290
+ var indexCircuitBreaker = new IndexCircuitBreaker();
1291
+
1091
1292
  // src/codebase-index/schema.ts
1092
- var SCHEMA_VERSION = 1;
1293
+ var SCHEMA_VERSION = 2;
1093
1294
 
1094
1295
  // src/codebase-index/lsp-kind.ts
1095
1296
  function lspKindToInternalKind(k) {
@@ -1124,6 +1325,94 @@ function lspKindToInternalKind(k) {
1124
1325
  }
1125
1326
  }
1126
1327
 
1328
+ // src/codebase-index/bm25.ts
1329
+ var K1 = 1.5;
1330
+ var B = 0.75;
1331
+ function tokenise(text) {
1332
+ const sanitised = text.replace(/[^\p{L}\p{N}$'_]/gu, " ").replace(/_/g, " ");
1333
+ return sanitised.toLowerCase().split(" ").filter(Boolean);
1334
+ }
1335
+ function splitName(name) {
1336
+ return name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
1337
+ }
1338
+ function buildIndexableText(name, signature, docComment) {
1339
+ return [splitName(name), name, signature, docComment].filter(Boolean).join(" ");
1340
+ }
1341
+ function buildBm25Index(docs) {
1342
+ const documents = docs.map((d) => {
1343
+ const tokens = tokenise(d.text);
1344
+ return { id: d.id, tokens, raw: d.text, len: tokens.length };
1345
+ });
1346
+ const df = {};
1347
+ for (const doc of documents) {
1348
+ const seen = /* @__PURE__ */ new Set();
1349
+ for (const t of doc.tokens) {
1350
+ if (!seen.has(t)) {
1351
+ df[t] = (df[t] ?? 0) + 1;
1352
+ seen.add(t);
1353
+ }
1354
+ }
1355
+ }
1356
+ const N = documents.length;
1357
+ const totalLen = documents.reduce((sum, d) => sum + d.len, 0);
1358
+ const avgLen = N === 0 ? 0 : totalLen / N;
1359
+ return new Bm25Index(documents, df, N, avgLen);
1360
+ }
1361
+ var Bm25Index = class {
1362
+ constructor(documents, df, N, avgLen) {
1363
+ this.documents = documents;
1364
+ this.df = df;
1365
+ this.N = N;
1366
+ this.safeAvgLen = avgLen === 0 ? 1 : avgLen;
1367
+ }
1368
+ documents;
1369
+ df;
1370
+ N;
1371
+ safeAvgLen;
1372
+ score(query2, filter) {
1373
+ const qTokens = tokenise(query2);
1374
+ if (qTokens.length === 0) return [];
1375
+ const results = [];
1376
+ for (const doc of this.documents) {
1377
+ if (filter && !filter(doc.id)) continue;
1378
+ let docScore = 0;
1379
+ for (const qTerm of qTokens) {
1380
+ let tf = 0;
1381
+ for (const t of doc.tokens) {
1382
+ if (t === qTerm) tf++;
1383
+ }
1384
+ if (tf === 0) continue;
1385
+ const dfVal = this.df[qTerm] ?? 0;
1386
+ if (dfVal === 0) continue;
1387
+ const idf = Math.log((this.N - dfVal + 0.5) / (dfVal + 0.5) + 1);
1388
+ const lenRatio = B * (doc.len / this.safeAvgLen);
1389
+ const tfComponent = tf * (K1 + 1) / (tf + K1 * (1 - B + lenRatio));
1390
+ docScore += idf * tfComponent;
1391
+ }
1392
+ if (docScore > 0) results.push({ id: doc.id, score: docScore });
1393
+ }
1394
+ return results;
1395
+ }
1396
+ getDoc(id) {
1397
+ return this.documents.find((d) => d.id === id);
1398
+ }
1399
+ extractSnippet(docId, queryTokens, radius = 40) {
1400
+ const doc = this.getDoc(docId);
1401
+ if (!doc) return "";
1402
+ for (const tok of queryTokens) {
1403
+ const idx = doc.raw.toLowerCase().indexOf(tok);
1404
+ if (idx !== -1) {
1405
+ const start = Math.max(0, idx - radius);
1406
+ const end = Math.min(doc.raw.length, idx + tok.length + radius);
1407
+ const excerpt = doc.raw.slice(start, end);
1408
+ const ellipsis = "\u2026";
1409
+ return (start > 0 ? ellipsis : "") + excerpt + (end < doc.raw.length ? ellipsis : "");
1410
+ }
1411
+ }
1412
+ return doc.raw.slice(0, radius * 2) + (doc.raw.length > radius * 2 ? "\u2026" : "");
1413
+ }
1414
+ };
1415
+
1127
1416
  // src/codebase-index/writer.ts
1128
1417
  var DB_FILE = "index.db";
1129
1418
  function resolveIndexDir(projectRoot, override) {
@@ -1159,15 +1448,79 @@ function loadDatabaseSync() {
1159
1448
  }
1160
1449
  return DatabaseSyncCtor;
1161
1450
  }
1451
+ var MAX_LOCK_RETRIES = 3;
1452
+ var LOCK_RETRY_BASE_DELAY_MS = 50;
1453
+ var LOCK_RETRY_MAX_DELAY_MS = 500;
1454
+ function isLockError(err) {
1455
+ if (!(err instanceof Error)) return false;
1456
+ const e = err;
1457
+ const code = e.code ?? e.sqliteCode;
1458
+ if (typeof code === "string" && /SQLITE_(BUSY|LOCKED)/.test(code)) return true;
1459
+ if (typeof code === "number" && (code === 5 || code === 6)) return true;
1460
+ if (/SQLITE_(BUSY|LOCKED)/.test(err.message)) return true;
1461
+ return false;
1462
+ }
1463
+ function sleepSync(ms) {
1464
+ try {
1465
+ const sab = new SharedArrayBuffer(4);
1466
+ const view = new Int32Array(sab);
1467
+ Atomics.wait(view, 0, 0, ms);
1468
+ } catch {
1469
+ }
1470
+ }
1162
1471
  var IndexStore = class {
1163
1472
  db;
1164
1473
  /** Absolute path to this project's index directory. */
1165
1474
  indexDir;
1475
+ /**
1476
+ * True when the SQLite build provides FTS5 (Node's bundled SQLite does).
1477
+ * When false, ranked search falls back to the LIKE + in-process BM25 path.
1478
+ */
1479
+ ftsAvailable = false;
1480
+ /**
1481
+ * Execute a SQLite write operation with automatic retry on lock conflicts.
1482
+ *
1483
+ * When another wstack process is holding the write lock the statement first
1484
+ * waits up to `busy_timeout` ms, then throws SQLITE_BUSY. This wrapper catches
1485
+ * that error and retries (up to MAX_LOCK_RETRIES) with exponential backoff,
1486
+ * giving the competing writer time to finish and release the lock.
1487
+ *
1488
+ * @param fn The write operation to execute. Can return a value which is
1489
+ * returned to the caller on success.
1490
+ * @throws {@link LockError} when all retries are exhausted on a lock conflict
1491
+ * (non-lock errors always propagate on the first attempt).
1492
+ */
1493
+ runWithRetry(fn) {
1494
+ let lastError;
1495
+ for (let attempt = 0; attempt <= MAX_LOCK_RETRIES; attempt++) {
1496
+ try {
1497
+ return fn();
1498
+ } catch (err) {
1499
+ lastError = err;
1500
+ if (!isLockError(err)) throw err;
1501
+ if (attempt === MAX_LOCK_RETRIES) {
1502
+ const msg = lastError instanceof Error ? lastError.message : String(lastError);
1503
+ throw new LockError(`SQLite lock conflict after ${MAX_LOCK_RETRIES} retries: ${msg}`);
1504
+ }
1505
+ const delay = Math.min(
1506
+ LOCK_RETRY_BASE_DELAY_MS * Math.pow(2, attempt),
1507
+ LOCK_RETRY_MAX_DELAY_MS
1508
+ );
1509
+ sleepSync(delay);
1510
+ }
1511
+ }
1512
+ throw lastError;
1513
+ }
1166
1514
  constructor(projectRoot, opts = {}) {
1167
1515
  this.indexDir = resolveIndexDir(projectRoot, opts.indexDir);
1168
1516
  fs.mkdirSync(this.indexDir, { recursive: true });
1169
1517
  const Database = loadDatabaseSync();
1170
1518
  this.db = new Database(path2.join(this.indexDir, DB_FILE));
1519
+ try {
1520
+ this.db.exec("PRAGMA journal_mode = WAL");
1521
+ this.db.exec("PRAGMA busy_timeout = 5000");
1522
+ } catch {
1523
+ }
1171
1524
  this.initSchema();
1172
1525
  }
1173
1526
  initSchema() {
@@ -1176,6 +1529,21 @@ var IndexStore = class {
1176
1529
  key TEXT PRIMARY KEY,
1177
1530
  value TEXT NOT NULL
1178
1531
  );
1532
+ `);
1533
+ const storedRows = this.db.prepare("SELECT value FROM metadata WHERE key = ?").all("version");
1534
+ const storedVersion = storedRows.length ? Number(storedRows[0]?.value) : null;
1535
+ if (storedVersion !== null && storedVersion !== SCHEMA_VERSION) {
1536
+ this.db.exec(`
1537
+ DROP TABLE IF EXISTS symbols;
1538
+ DROP TABLE IF EXISTS files;
1539
+ DROP TABLE IF EXISTS refs;
1540
+ `);
1541
+ this.db.exec("DROP TABLE IF EXISTS symbols_fts");
1542
+ this.db.prepare("UPDATE metadata SET value = ? WHERE key = ?").run(String(SCHEMA_VERSION), "version");
1543
+ } else if (storedVersion === null) {
1544
+ this.db.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)").run("version", String(SCHEMA_VERSION));
1545
+ }
1546
+ this.db.exec(`
1179
1547
  CREATE TABLE IF NOT EXISTS files (
1180
1548
  file TEXT PRIMARY KEY,
1181
1549
  lang TEXT NOT NULL,
@@ -1216,53 +1584,76 @@ var IndexStore = class {
1216
1584
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_r_to_id ON refs(to_id)");
1217
1585
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_r_to_name ON refs(to_name)");
1218
1586
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_r_call_type ON refs(call_type)");
1219
- const versionRows = this.db.prepare("SELECT value FROM metadata WHERE key = ?").all("version");
1220
- if (!versionRows.length) {
1221
- this.db.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)").run("version", String(SCHEMA_VERSION));
1587
+ try {
1588
+ this.db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(text, tokenize = 'unicode61')");
1589
+ this.ftsAvailable = true;
1590
+ } catch {
1591
+ this.ftsAvailable = false;
1222
1592
  }
1223
1593
  }
1224
1594
  // ─── Symbol CRUD ─────────────────────────────────────────────────────────────
1225
1595
  insertSymbols(symbols, nextId) {
1226
- const stmt = this.db.prepare(
1227
- `INSERT INTO symbols(id, lang, kind, name, file, line, col, signature, doc_comment, scope, text, file_fk)
1228
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1229
- );
1230
- let id = nextId;
1231
- for (const s of symbols) {
1232
- stmt.run(
1233
- id++,
1234
- s.lang,
1235
- s.kind,
1236
- s.name,
1237
- s.file,
1238
- s.line,
1239
- s.col,
1240
- s.signature,
1241
- s.docComment,
1242
- s.scope,
1243
- s.text,
1244
- s.file
1596
+ return this.runWithRetry(() => {
1597
+ const stmt = this.db.prepare(
1598
+ `INSERT INTO symbols(id, lang, kind, name, file, line, col, signature, doc_comment, scope, text, file_fk)
1599
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1245
1600
  );
1246
- }
1247
- return id;
1601
+ const ftsStmt = this.ftsAvailable ? this.db.prepare("INSERT INTO symbols_fts(rowid, text) VALUES (?, ?)") : null;
1602
+ let id = nextId;
1603
+ for (const s of symbols) {
1604
+ stmt.run(
1605
+ id,
1606
+ s.lang,
1607
+ s.kind,
1608
+ s.name,
1609
+ s.file,
1610
+ s.line,
1611
+ s.col,
1612
+ s.signature,
1613
+ s.docComment,
1614
+ s.scope,
1615
+ s.text,
1616
+ s.file
1617
+ );
1618
+ ftsStmt?.run(id, buildIndexableText(s.name, s.signature, s.docComment));
1619
+ id++;
1620
+ }
1621
+ return id;
1622
+ });
1248
1623
  }
1249
1624
  deleteSymbolsForFile(file) {
1250
- this.db.prepare("DELETE FROM symbols WHERE file_fk = ?").run(file);
1625
+ this.runWithRetry(() => {
1626
+ if (this.ftsAvailable) {
1627
+ this.db.prepare("DELETE FROM symbols_fts WHERE rowid IN (SELECT id FROM symbols WHERE file_fk = ?)").run(file);
1628
+ }
1629
+ this.db.prepare("DELETE FROM symbols WHERE file_fk = ?").run(file);
1630
+ });
1251
1631
  }
1632
+ /**
1633
+ * Remove every trace of a file (refs, symbols, FTS rows, file meta). Used
1634
+ * when a source file disappears between index runs — previously this only
1635
+ * dropped the `files` row, leaving its symbols orphaned but still searchable.
1636
+ */
1252
1637
  deleteFile(file) {
1253
- this.db.prepare("DELETE FROM files WHERE file = ?").run(file);
1638
+ this.runWithRetry(() => {
1639
+ this.deleteRefsForFile(file);
1640
+ this.deleteSymbolsForFile(file);
1641
+ this.db.prepare("DELETE FROM files WHERE file = ?").run(file);
1642
+ });
1254
1643
  }
1255
1644
  // ─── File metadata ──────────────────────────────────────────────────────────
1256
1645
  upsertFile(meta) {
1257
- this.db.prepare(
1258
- `INSERT INTO files(file, lang, mtime_ms, symbol_count, last_indexed)
1259
- VALUES (?, ?, ?, ?, ?)
1260
- ON CONFLICT(file) DO UPDATE SET
1261
- lang = excluded.lang,
1262
- mtime_ms = excluded.mtime_ms,
1263
- symbol_count = excluded.symbol_count,
1264
- last_indexed = excluded.last_indexed`
1265
- ).run(meta.file, meta.lang, meta.mtimeMs, meta.symbolCount, meta.lastIndexed);
1646
+ this.runWithRetry(() => {
1647
+ this.db.prepare(
1648
+ `INSERT INTO files(file, lang, mtime_ms, symbol_count, last_indexed)
1649
+ VALUES (?, ?, ?, ?, ?)
1650
+ ON CONFLICT(file) DO UPDATE SET
1651
+ lang = excluded.lang,
1652
+ mtime_ms = excluded.mtime_ms,
1653
+ symbol_count = excluded.symbol_count,
1654
+ last_indexed = excluded.last_indexed`
1655
+ ).run(meta.file, meta.lang, meta.mtimeMs, meta.symbolCount, meta.lastIndexed);
1656
+ });
1266
1657
  }
1267
1658
  getFileMeta(file) {
1268
1659
  const rows = this.db.prepare(
@@ -1329,6 +1720,94 @@ var IndexStore = class {
1329
1720
  lspKind: filter?.lspKind
1330
1721
  }));
1331
1722
  }
1723
+ /**
1724
+ * Ranked search — the one-stop query the codebase-search tool and plug-lsp
1725
+ * use. With FTS5 this is a single indexed `MATCH` ranked by SQLite's native
1726
+ * `bm25()` with a built-in `snippet()`; without FTS5 it falls back to the
1727
+ * legacy LIKE scan + in-process BM25 (identical semantics, slower).
1728
+ *
1729
+ * Tokens are matched as prefixes (`"tok"*`), mirroring the old
1730
+ * `LIKE '%tok%'` recall for the common symbol-search shapes ("user" finds
1731
+ * "users", camelCase-split text makes "complex" find "complexOperation").
1732
+ */
1733
+ searchRanked(query2, filter, limit) {
1734
+ const tokens = tokenise(query2);
1735
+ if (tokens.length === 0 || !this.ftsAvailable) {
1736
+ return this.searchRankedFallback(query2, filter, limit);
1737
+ }
1738
+ let effectiveKind = filter?.kind;
1739
+ if (filter?.lspKind !== void 0) {
1740
+ const mapped = lspKindToInternalKind(filter.lspKind);
1741
+ if (mapped === null) return { results: [], total: 0 };
1742
+ effectiveKind = mapped;
1743
+ }
1744
+ const match = tokens.map((t) => `"${t.replaceAll('"', "")}"*`).join(" OR ");
1745
+ const conditions = ["symbols_fts MATCH ?"];
1746
+ const values = [match];
1747
+ if (effectiveKind) {
1748
+ conditions.push("s.kind = ?");
1749
+ values.push(effectiveKind);
1750
+ }
1751
+ if (filter?.lang) {
1752
+ conditions.push("s.lang = ?");
1753
+ values.push(filter.lang);
1754
+ }
1755
+ if (filter?.file) {
1756
+ conditions.push("s.file LIKE ?");
1757
+ values.push(`%${filter.file}%`);
1758
+ }
1759
+ const where = conditions.join(" AND ");
1760
+ 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);
1761
+ const total = countRows[0] ? Number(countRows[0].n) : 0;
1762
+ if (total === 0) return { results: [], total: 0 };
1763
+ const rows = this.db.prepare(
1764
+ `SELECT s.id, s.lang, s.kind, s.name, s.file, s.line, s.col, s.signature, s.doc_comment,
1765
+ -bm25(symbols_fts) AS score,
1766
+ snippet(symbols_fts, 0, '', '', '\u2026', 12) AS snippet
1767
+ FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid
1768
+ WHERE ${where}
1769
+ ORDER BY bm25(symbols_fts)
1770
+ LIMIT ?`
1771
+ ).all(...values, limit);
1772
+ return {
1773
+ results: rows.map((r) => ({
1774
+ id: r.id,
1775
+ lang: r.lang,
1776
+ kind: r.kind,
1777
+ name: r.name,
1778
+ file: r.file,
1779
+ line: r.line,
1780
+ col: r.col,
1781
+ signature: r.signature,
1782
+ docComment: r.doc_comment,
1783
+ // bm25() is negative-is-better; negate so callers keep "higher is
1784
+ // better" and clamp so a match never reports a zero score.
1785
+ score: Math.max(1e-4, r.score),
1786
+ snippet: r.snippet,
1787
+ lspKind: filter?.lspKind
1788
+ })),
1789
+ total
1790
+ };
1791
+ }
1792
+ /** Legacy ranked path: LIKE candidates + in-process BM25 + JS snippets. */
1793
+ searchRankedFallback(query2, filter, limit) {
1794
+ const candidates = this.search(query2, filter);
1795
+ if (candidates.length === 0) return { results: [], total: 0 };
1796
+ if (!query2.trim()) {
1797
+ return { results: candidates.slice(0, limit), total: candidates.length };
1798
+ }
1799
+ const bm25 = buildBm25Index(
1800
+ candidates.map((c) => ({ id: c.id, text: buildIndexableText(c.name, c.signature, c.docComment) }))
1801
+ );
1802
+ const scored = bm25.score(query2, (id) => candidates.some((c) => c.id === id));
1803
+ scored.sort((a, b) => b.score - a.score);
1804
+ const qTokens = tokenise(query2);
1805
+ const results = scored.slice(0, limit).map(({ id, score }) => {
1806
+ const c = expectDefined(candidates.find((cand) => cand.id === id));
1807
+ return { ...c, score, snippet: bm25.extractSnippet(id, qTokens) };
1808
+ });
1809
+ return { results, total: candidates.length };
1810
+ }
1332
1811
  getAllIndexable() {
1333
1812
  return this.db.prepare("SELECT id, text FROM symbols").all().map(
1334
1813
  ({ id, text }) => ({ id, text })
@@ -1378,14 +1857,19 @@ var IndexStore = class {
1378
1857
  };
1379
1858
  }
1380
1859
  setLastIndexed(ts2) {
1381
- this.db.prepare(
1382
- "INSERT OR REPLACE INTO metadata(key, value) VALUES('last_indexed', ?)"
1383
- ).run(String(ts2));
1860
+ this.runWithRetry(() => {
1861
+ this.db.prepare(
1862
+ "INSERT OR REPLACE INTO metadata(key, value) VALUES('last_indexed', ?)"
1863
+ ).run(String(ts2));
1864
+ });
1384
1865
  }
1385
1866
  clearAll() {
1386
- this.db.exec("DELETE FROM symbols");
1387
- this.db.exec("DELETE FROM files");
1388
- this.db.exec("DELETE FROM refs");
1867
+ this.runWithRetry(() => {
1868
+ this.db.exec("DELETE FROM symbols");
1869
+ this.db.exec("DELETE FROM files");
1870
+ this.db.exec("DELETE FROM refs");
1871
+ if (this.ftsAvailable) this.db.exec("DELETE FROM symbols_fts");
1872
+ });
1389
1873
  }
1390
1874
  // ─── Ref CRUD ────────────────────────────────────────────────────────────────
1391
1875
  /**
@@ -1393,46 +1877,52 @@ var IndexStore = class {
1393
1877
  * Replaces any existing refs from the same source (idempotent on re-index).
1394
1878
  */
1395
1879
  insertRefs(fromId, refs) {
1396
- this.db.prepare("DELETE FROM refs WHERE from_id = ?").run(fromId);
1397
- if (refs.length === 0) return;
1398
- const stmt = this.db.prepare(
1399
- `INSERT INTO refs(from_id, to_name, to_id, call_type, line)
1400
- VALUES (?, ?, ?, ?, ?)`
1401
- );
1402
- for (const ref of refs) {
1403
- stmt.run(fromId, ref.toName, ref.toId ?? null, ref.callType, ref.line);
1404
- }
1880
+ this.runWithRetry(() => {
1881
+ this.db.prepare("DELETE FROM refs WHERE from_id = ?").run(fromId);
1882
+ if (refs.length === 0) return;
1883
+ const stmt = this.db.prepare(
1884
+ `INSERT INTO refs(from_id, to_name, to_id, call_type, line)
1885
+ VALUES (?, ?, ?, ?, ?)`
1886
+ );
1887
+ for (const ref of refs) {
1888
+ stmt.run(fromId, ref.toName, ref.toId ?? null, ref.callType, ref.line);
1889
+ }
1890
+ });
1405
1891
  }
1406
1892
  /**
1407
1893
  * Delete all refs whose source symbols are in a given file.
1408
1894
  * Used when re-indexing a file to clear stale refs.
1409
1895
  */
1410
1896
  deleteRefsForFile(file) {
1411
- const ids = this.db.prepare(
1412
- "SELECT id FROM symbols WHERE file = ?"
1413
- ).all(file);
1414
- if (!ids.length) return;
1415
- const placeholders = ids.map(() => "?").join(",");
1416
- this.db.prepare(`DELETE FROM refs WHERE from_id IN (${placeholders})`).run(...ids.map((r) => r.id));
1897
+ this.runWithRetry(() => {
1898
+ const ids = this.db.prepare(
1899
+ "SELECT id FROM symbols WHERE file = ?"
1900
+ ).all(file);
1901
+ if (!ids.length) return;
1902
+ const placeholders = ids.map(() => "?").join(",");
1903
+ this.db.prepare(`DELETE FROM refs WHERE from_id IN (${placeholders})`).run(...ids.map((r) => r.id));
1904
+ });
1417
1905
  }
1418
1906
  /**
1419
1907
  * Resolve `to_name` → `to_id` for all refs that have a name but no id.
1420
1908
  * Call this after all symbols have been inserted to fill in cross-references.
1421
1909
  */
1422
1910
  resolveRefs() {
1423
- const unresolved = this.db.prepare(
1424
- "SELECT id, to_name FROM refs WHERE to_id IS NULL AND to_name IS NOT NULL"
1425
- ).all();
1426
- let resolved = 0;
1427
- for (const row of unresolved) {
1428
- const target = this.db.prepare("SELECT id FROM symbols WHERE name = ? LIMIT 1").all(row.to_name);
1429
- const first = target[0];
1430
- if (first) {
1431
- this.db.prepare("UPDATE refs SET to_id = ? WHERE id = ?").run(first.id, row.id);
1432
- resolved++;
1911
+ return this.runWithRetry(() => {
1912
+ const unresolved = this.db.prepare(
1913
+ "SELECT id, to_name FROM refs WHERE to_id IS NULL AND to_name IS NOT NULL"
1914
+ ).all();
1915
+ let resolved = 0;
1916
+ for (const row of unresolved) {
1917
+ const target = this.db.prepare("SELECT id FROM symbols WHERE name = ? LIMIT 1").all(row.to_name);
1918
+ const first = target[0];
1919
+ if (first) {
1920
+ this.db.prepare("UPDATE refs SET to_id = ? WHERE id = ?").run(first.id, row.id);
1921
+ resolved++;
1922
+ }
1433
1923
  }
1434
- }
1435
- return resolved;
1924
+ return resolved;
1925
+ });
1436
1926
  }
1437
1927
  /**
1438
1928
  * Find all references TO a given symbol (who calls / uses this symbol?).
@@ -2193,7 +2683,7 @@ function parseSymbols4(opts) {
2193
2683
  }
2194
2684
  function checkNativeParser() {
2195
2685
  try {
2196
- execFileSync("rustc", ["--version"], { stdio: "pipe" });
2686
+ execFileSync("rustc", ["--version"], { stdio: "pipe", windowsHide: true });
2197
2687
  const toolsDir = path2.join(process.cwd(), "tools");
2198
2688
  try {
2199
2689
  execFileSync(
@@ -2206,7 +2696,7 @@ function checkNativeParser() {
2206
2696
  "--manifest-path",
2207
2697
  path2.join(toolsDir, "Cargo.toml")
2208
2698
  ],
2209
- { stdio: "pipe" }
2699
+ { stdio: "pipe", windowsHide: true }
2210
2700
  );
2211
2701
  return true;
2212
2702
  } catch {
@@ -2229,7 +2719,8 @@ function tryNativeParse(file, content) {
2229
2719
  cwd: process.cwd(),
2230
2720
  encoding: "utf8",
2231
2721
  timeout: 15e3,
2232
- stdio: ["pipe", "pipe", "pipe"]
2722
+ stdio: ["pipe", "pipe", "pipe"],
2723
+ windowsHide: true
2233
2724
  }
2234
2725
  );
2235
2726
  if (result.status === 0 && result.stdout) {
@@ -2643,10 +3134,6 @@ function isScalar(value) {
2643
3134
  if (/^'[^']*'$/.test(value) || /^"[^"]*"$/.test(value)) return true;
2644
3135
  return false;
2645
3136
  }
2646
- function truncate(s, max) {
2647
- if (s.length <= max) return s;
2648
- return s.slice(0, max) + "...";
2649
- }
2650
3137
  function makeSymbol2(opts) {
2651
3138
  return {
2652
3139
  id: 0,
@@ -2706,43 +3193,13 @@ function compileGitignore(lines) {
2706
3193
  async function loadGitignoreMatcher(projectRoot) {
2707
3194
  let lines = [];
2708
3195
  try {
2709
- const raw = await fs13.readFile(path2.join(projectRoot, ".gitignore"), "utf8");
3196
+ const raw = await fs14.readFile(path2.join(projectRoot, ".gitignore"), "utf8");
2710
3197
  lines = raw.split("\n");
2711
3198
  } catch {
2712
3199
  }
2713
3200
  return compileGitignore(lines);
2714
3201
  }
2715
3202
 
2716
- // src/codebase-index/background-indexer.ts
2717
- var _ready = false;
2718
- var _indexing = false;
2719
- var _currentFile = 0;
2720
- var _totalFiles = 0;
2721
- var _lastError = null;
2722
- function setIndexReady() {
2723
- _ready = true;
2724
- }
2725
- function getIndexState() {
2726
- return {
2727
- ready: _ready,
2728
- indexing: _indexing,
2729
- currentFile: _currentFile,
2730
- totalFiles: _totalFiles,
2731
- lastError: _lastError
2732
- };
2733
- }
2734
- var _listeners = [];
2735
- function emitState() {
2736
- const state = getIndexState();
2737
- for (const l of _listeners) l(state);
2738
- }
2739
- function _setIndexProgress(current, total) {
2740
- _currentFile = current;
2741
- _totalFiles = total;
2742
- emitState();
2743
- }
2744
- Promise.resolve();
2745
-
2746
3203
  // src/codebase-index/indexer.ts
2747
3204
  var YIELD_EVERY_N = 50;
2748
3205
  function yieldEventLoop() {
@@ -2793,7 +3250,7 @@ async function findSourceFiles(projectRoot, ignore, isGitIgnored, signal) {
2793
3250
  }
2794
3251
  let entries;
2795
3252
  try {
2796
- entries = await fs13.readdir(dir, { withFileTypes: true });
3253
+ entries = await fs14.readdir(dir, { withFileTypes: true });
2797
3254
  } catch {
2798
3255
  return;
2799
3256
  }
@@ -2842,8 +3299,18 @@ async function parseFile(file, content, lang) {
2842
3299
  }
2843
3300
  }
2844
3301
  async function runIndexer(_ctx, opts) {
2845
- const { projectRoot, force = false, langs, ignore = [], indexDir, signal } = opts;
2846
- const store = new IndexStore(projectRoot, { indexDir });
3302
+ const store = new IndexStore(opts.projectRoot, { indexDir: opts.indexDir });
3303
+ try {
3304
+ return await runIndexerWithStore(store, opts);
3305
+ } finally {
3306
+ try {
3307
+ store.close();
3308
+ } catch {
3309
+ }
3310
+ }
3311
+ }
3312
+ async function runIndexerWithStore(store, opts) {
3313
+ const { projectRoot, force = false, langs, ignore = [], signal } = opts;
2847
3314
  const startMs = Date.now();
2848
3315
  const errors = [];
2849
3316
  const langStats = {};
@@ -2870,7 +3337,7 @@ async function runIndexer(_ctx, opts) {
2870
3337
  }
2871
3338
  for (let fi = 0; fi < files.length; fi++) {
2872
3339
  const file = expectDefined(files[fi]);
2873
- _setIndexProgress(fi + 1, files.length);
3340
+ opts.onProgress?.(fi + 1, files.length);
2874
3341
  if (fi > 0 && fi % YIELD_EVERY_N === 0) {
2875
3342
  await yieldEventLoop();
2876
3343
  throwIfAborted(signal);
@@ -2878,7 +3345,7 @@ async function runIndexer(_ctx, opts) {
2878
3345
  let stat10;
2879
3346
  try {
2880
3347
  const statOpts = signal ? { signal } : {};
2881
- stat10 = await fs13.stat(file, statOpts);
3348
+ stat10 = await fs14.stat(file, statOpts);
2882
3349
  } catch (e) {
2883
3350
  if (isAbortError(e)) throw e;
2884
3351
  store.deleteFile(file);
@@ -2898,7 +3365,7 @@ async function runIndexer(_ctx, opts) {
2898
3365
  store.deleteSymbolsForFile(file);
2899
3366
  let content;
2900
3367
  try {
2901
- content = await fs13.readFile(file, { encoding: "utf8", signal });
3368
+ content = await fs14.readFile(file, { encoding: "utf8", signal });
2902
3369
  } catch (e) {
2903
3370
  if (isAbortError(e)) throw e;
2904
3371
  errors.push(`read error: ${file}: ${e instanceof Error ? e.message : String(e)}`);
@@ -2949,14 +3416,13 @@ async function runIndexer(_ctx, opts) {
2949
3416
  }
2950
3417
  for (const [file_] of existingMeta) {
2951
3418
  try {
2952
- await fs13.stat(file_);
3419
+ await fs14.stat(file_);
2953
3420
  } catch {
2954
3421
  store.deleteFile(file_);
2955
3422
  }
2956
3423
  }
2957
3424
  const durationMs = Date.now() - startMs;
2958
3425
  store.setLastIndexed(Date.now());
2959
- store.close();
2960
3426
  return {
2961
3427
  filesIndexed,
2962
3428
  symbolsIndexed,
@@ -2966,128 +3432,343 @@ async function runIndexer(_ctx, opts) {
2966
3432
  };
2967
3433
  }
2968
3434
 
2969
- // src/codebase-index/codebase-index-tool.ts
2970
- var codebaseIndexTool = {
2971
- name: "codebase-index",
2972
- category: "Project",
2973
- description: "Build or incrementally update the project-wide symbol index. This powers fast codebase search and understanding. By default it only processes files that have changed since the last indexing run.",
2974
- usageHint: "IMPORTANT FOR LARGE CODEBASES:\n\n- First run (or after major changes): consider `force: true` for a clean rebuild.\n- Normal usage: call without arguments for fast incremental updates.\n- Use `langs` to restrict to specific languages if you only care about certain parts of the project.\nThis tool is relatively expensive \u2014 do not call it on every turn. Use it when the index is stale or before heavy codebase-search sessions.",
2975
- permission: "confirm",
2976
- mutating: true,
2977
- capabilities: ["fs.write.outside-project"],
2978
- timeoutMs: 12e4,
2979
- inputSchema: {
2980
- type: "object",
2981
- properties: {
2982
- force: {
2983
- type: "boolean",
2984
- description: "Force a full reindex \u2014 clears the index first and reindexes all files."
3435
+ // src/codebase-index/index-service.ts
3436
+ function stubCtx(projectRoot) {
3437
+ return {
3438
+ projectRoot,
3439
+ cwd: projectRoot,
3440
+ messages: [],
3441
+ todos: [],
3442
+ readFiles: /* @__PURE__ */ new Set(),
3443
+ fileMtimes: /* @__PURE__ */ new Map()
3444
+ };
3445
+ }
3446
+ async function indexService(args, hooks = {}) {
3447
+ return runIndexer(stubCtx(args.projectRoot), {
3448
+ projectRoot: args.projectRoot,
3449
+ indexDir: args.indexDir,
3450
+ files: args.files,
3451
+ force: args.force,
3452
+ langs: args.langs,
3453
+ ignore: args.ignore,
3454
+ signal: hooks.signal,
3455
+ onProgress: hooks.onProgress
3456
+ });
3457
+ }
3458
+ function searchService(args) {
3459
+ const store = new IndexStore(args.projectRoot, { indexDir: args.indexDir });
3460
+ try {
3461
+ return store.searchRanked(
3462
+ args.query,
3463
+ {
3464
+ kind: args.kind,
3465
+ lang: args.lang,
3466
+ file: args.file,
3467
+ lspKind: args.lspKind
2985
3468
  },
2986
- langs: {
2987
- type: "array",
2988
- items: { type: "string" },
2989
- description: "Limit reindex to specific languages: ts, tsx, js, jsx, go, py, rs"
2990
- }
2991
- }
2992
- },
2993
- async execute(input, ctx, execOpts) {
2994
- const result = await runIndexer(ctx, {
2995
- projectRoot: ctx.projectRoot,
2996
- force: input.force ?? false,
2997
- langs: input.langs,
2998
- indexDir: codebaseIndexDirOverride(ctx),
2999
- signal: execOpts?.signal
3000
- });
3001
- setIndexReady();
3002
- return result;
3469
+ args.limit
3470
+ );
3471
+ } finally {
3472
+ store.close();
3003
3473
  }
3004
- };
3474
+ }
3475
+ function statsService(args) {
3476
+ const store = new IndexStore(args.projectRoot, { indexDir: args.indexDir });
3477
+ try {
3478
+ return store.getStats();
3479
+ } finally {
3480
+ store.close();
3481
+ }
3482
+ }
3005
3483
 
3006
- // src/codebase-index/bm25.ts
3007
- var K1 = 1.5;
3008
- var B = 0.75;
3009
- function tokenise(text) {
3010
- const sanitised = text.replace(/[^\p{L}\p{N}$'_]/gu, " ").replace(/_/g, " ");
3011
- return sanitised.toLowerCase().split(" ").filter(Boolean);
3484
+ // src/codebase-index/background-indexer.ts
3485
+ var DEFAULT_FULL_INDEX_TIMEOUT_MS = 12e4;
3486
+ var DEFAULT_QUERY_TIMEOUT_MS = 8e3;
3487
+ var _ready = false;
3488
+ var _indexing = false;
3489
+ var _currentFile = 0;
3490
+ var _totalFiles = 0;
3491
+ var _lastError = null;
3492
+ function isIndexing() {
3493
+ return _indexing;
3012
3494
  }
3013
- function splitName(name) {
3014
- return name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
3495
+ function getIndexState() {
3496
+ return {
3497
+ ready: _ready,
3498
+ indexing: _indexing,
3499
+ currentFile: _currentFile,
3500
+ totalFiles: _totalFiles,
3501
+ lastError: _lastError,
3502
+ circuit: indexCircuitBreaker.snapshot()
3503
+ };
3015
3504
  }
3016
- function buildIndexableText(name, signature, docComment) {
3017
- return [splitName(name), name, signature, docComment].filter(Boolean).join(" ");
3505
+ var _listeners = [];
3506
+ function emitState() {
3507
+ const state = getIndexState();
3508
+ for (const l of _listeners) l(state);
3018
3509
  }
3019
- function buildBm25Index(docs) {
3020
- const documents = docs.map((d) => {
3021
- const tokens = tokenise(d.text);
3022
- return { id: d.id, tokens, raw: d.text, len: tokens.length };
3023
- });
3024
- const df = {};
3025
- for (const doc of documents) {
3026
- const seen = /* @__PURE__ */ new Set();
3027
- for (const t of doc.tokens) {
3028
- if (!seen.has(t)) {
3029
- df[t] = (df[t] ?? 0) + 1;
3030
- seen.add(t);
3031
- }
3510
+ function setIndexProgress(current, total) {
3511
+ _currentFile = current;
3512
+ _totalFiles = total;
3513
+ emitState();
3514
+ }
3515
+ var worker = null;
3516
+ var workerUnavailable = false;
3517
+ var nextRpcId = 1;
3518
+ var pending = /* @__PURE__ */ new Map();
3519
+ function resolveWorkerUrl() {
3520
+ if (process.env["WRONGSTACK_INDEX_INLINE"]) return null;
3521
+ for (const rel of ["./worker.js", "./codebase-index/worker.js"]) {
3522
+ try {
3523
+ const url = new URL(rel, import.meta.url);
3524
+ if (url.protocol === "file:" && fs.existsSync(fileURLToPath(url))) return url;
3525
+ } catch {
3032
3526
  }
3033
3527
  }
3034
- const N = documents.length;
3035
- const totalLen = documents.reduce((sum, d) => sum + d.len, 0);
3036
- const avgLen = N === 0 ? 0 : totalLen / N;
3037
- return new Bm25Index(documents, df, N, avgLen);
3528
+ return null;
3038
3529
  }
3039
- var Bm25Index = class {
3040
- constructor(documents, df, N, avgLen) {
3041
- this.documents = documents;
3042
- this.df = df;
3043
- this.N = N;
3044
- this.safeAvgLen = avgLen === 0 ? 1 : avgLen;
3530
+ function failAllPending(err) {
3531
+ const entries = [...pending.values()];
3532
+ pending.clear();
3533
+ for (const p of entries) p.reject(err);
3534
+ }
3535
+ function ensureWorker() {
3536
+ if (worker) return worker;
3537
+ if (workerUnavailable) return null;
3538
+ const url = resolveWorkerUrl();
3539
+ if (!url) {
3540
+ workerUnavailable = true;
3541
+ return null;
3045
3542
  }
3046
- documents;
3047
- df;
3048
- N;
3049
- safeAvgLen;
3050
- score(query2, filter) {
3051
- const qTokens = tokenise(query2);
3052
- if (qTokens.length === 0) return [];
3053
- const results = [];
3054
- for (const doc of this.documents) {
3055
- if (filter && !filter(doc.id)) continue;
3056
- let docScore = 0;
3057
- for (const qTerm of qTokens) {
3058
- let tf = 0;
3059
- for (const t of doc.tokens) {
3060
- if (t === qTerm) tf++;
3543
+ try {
3544
+ const w = new Worker(url, { name: "wstack-codebase-index" });
3545
+ w.unref();
3546
+ w.on("message", (msg) => {
3547
+ if (msg.type === "progress") {
3548
+ pending.get(msg.id)?.onProgress?.(msg.current, msg.total);
3549
+ return;
3550
+ }
3551
+ const entry = pending.get(msg.id);
3552
+ if (!entry) return;
3553
+ pending.delete(msg.id);
3554
+ if (msg.ok) entry.resolve(msg.result);
3555
+ else entry.reject(new Error(msg.error));
3556
+ });
3557
+ w.on("error", (err) => {
3558
+ worker = null;
3559
+ failAllPending(err);
3560
+ });
3561
+ w.on("exit", () => {
3562
+ if (worker === w) worker = null;
3563
+ failAllPending(new Error("codebase-index worker exited"));
3564
+ });
3565
+ worker = w;
3566
+ return w;
3567
+ } catch {
3568
+ workerUnavailable = true;
3569
+ return null;
3570
+ }
3571
+ }
3572
+ function terminateWorker(reason) {
3573
+ const w = worker;
3574
+ worker = null;
3575
+ failAllPending(reason);
3576
+ if (w) void w.terminate().catch(() => {
3577
+ });
3578
+ }
3579
+ function callIndexOp(op, args, opts) {
3580
+ const w = ensureWorker();
3581
+ if (!w) return callInline(op, args, opts);
3582
+ return new Promise((resolve7, reject) => {
3583
+ const id = nextRpcId++;
3584
+ const timer = setTimeout(() => {
3585
+ pending.delete(id);
3586
+ const err = new IndexTimeoutError(
3587
+ `Index ${op} exceeded its ${opts.timeoutMs}ms watchdog timeout`
3588
+ );
3589
+ terminateWorker(err);
3590
+ reject(err);
3591
+ }, opts.timeoutMs);
3592
+ timer.unref?.();
3593
+ const onAbort = () => {
3594
+ w.postMessage({ type: "cancel", id });
3595
+ };
3596
+ if (opts.signal?.aborted) onAbort();
3597
+ else opts.signal?.addEventListener("abort", onAbort, { once: true });
3598
+ const cleanup = () => {
3599
+ clearTimeout(timer);
3600
+ opts.signal?.removeEventListener("abort", onAbort);
3601
+ };
3602
+ pending.set(id, {
3603
+ resolve: (v) => {
3604
+ cleanup();
3605
+ resolve7(v);
3606
+ },
3607
+ reject: (e) => {
3608
+ cleanup();
3609
+ reject(e);
3610
+ },
3611
+ onProgress: opts.onProgress
3612
+ });
3613
+ w.postMessage({ type: "request", id, op, args });
3614
+ });
3615
+ }
3616
+ async function callInline(op, args, opts) {
3617
+ const ac = new AbortController();
3618
+ const onOuterAbort = () => ac.abort(opts.signal?.reason ?? new Error("Indexing cancelled"));
3619
+ if (opts.signal?.aborted) onOuterAbort();
3620
+ else opts.signal?.addEventListener("abort", onOuterAbort, { once: true });
3621
+ let timer;
3622
+ const watchdog = new Promise((_, reject) => {
3623
+ timer = setTimeout(() => {
3624
+ const err = new IndexTimeoutError(
3625
+ `Index ${op} exceeded its ${opts.timeoutMs}ms watchdog timeout`
3626
+ );
3627
+ ac.abort(err);
3628
+ reject(err);
3629
+ }, opts.timeoutMs);
3630
+ timer.unref?.();
3631
+ });
3632
+ const job = async () => {
3633
+ switch (op) {
3634
+ case "index":
3635
+ return await indexService(args, {
3636
+ signal: ac.signal,
3637
+ onProgress: opts.onProgress
3638
+ });
3639
+ case "search":
3640
+ return searchService(args);
3641
+ case "stats":
3642
+ return statsService(args);
3643
+ default:
3644
+ throw new Error(`unknown index op: ${String(op)}`);
3645
+ }
3646
+ };
3647
+ try {
3648
+ return await Promise.race([job(), watchdog]);
3649
+ } finally {
3650
+ if (timer) clearTimeout(timer);
3651
+ opts.signal?.removeEventListener("abort", onOuterAbort);
3652
+ }
3653
+ }
3654
+ var chain = Promise.resolve();
3655
+ function withMutex(job) {
3656
+ const run = chain.then(job, job);
3657
+ chain = run.then(
3658
+ () => void 0,
3659
+ () => void 0
3660
+ );
3661
+ return run;
3662
+ }
3663
+ function circuitOpenError() {
3664
+ const c = indexCircuitBreaker.snapshot();
3665
+ return new CircuitOpenError(
3666
+ "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."
3667
+ );
3668
+ }
3669
+ async function runStartupIndex(opts) {
3670
+ if (!indexCircuitBreaker.allowRequest()) throw circuitOpenError();
3671
+ _indexing = true;
3672
+ emitState();
3673
+ try {
3674
+ const result = await withMutex(() => {
3675
+ _currentFile = 0;
3676
+ _totalFiles = 0;
3677
+ _lastError = null;
3678
+ return callIndexOp(
3679
+ "index",
3680
+ {
3681
+ projectRoot: opts.projectRoot,
3682
+ indexDir: opts.indexDir,
3683
+ force: opts.force,
3684
+ langs: opts.langs
3685
+ },
3686
+ {
3687
+ timeoutMs: opts.timeoutMs ?? DEFAULT_FULL_INDEX_TIMEOUT_MS,
3688
+ signal: opts.signal,
3689
+ onProgress: setIndexProgress
3061
3690
  }
3062
- if (tf === 0) continue;
3063
- const dfVal = this.df[qTerm] ?? 0;
3064
- if (dfVal === 0) continue;
3065
- const idf = Math.log((this.N - dfVal + 0.5) / (dfVal + 0.5) + 1);
3066
- const lenRatio = B * (doc.len / this.safeAvgLen);
3067
- const tfComponent = tf * (K1 + 1) / (tf + K1 * (1 - B + lenRatio));
3068
- docScore += idf * tfComponent;
3691
+ );
3692
+ });
3693
+ _ready = true;
3694
+ indexCircuitBreaker.recordSuccess();
3695
+ return result;
3696
+ } catch (err) {
3697
+ _lastError = err instanceof Error ? err.message : String(err);
3698
+ _ready = true;
3699
+ if (!opts.signal?.aborted) indexCircuitBreaker.recordFailure(err);
3700
+ throw err;
3701
+ } finally {
3702
+ _indexing = false;
3703
+ emitState();
3704
+ }
3705
+ }
3706
+ async function searchCodebaseIndex(args, opts = {}) {
3707
+ return callIndexOp("search", args, {
3708
+ timeoutMs: opts.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
3709
+ signal: opts.signal
3710
+ });
3711
+ }
3712
+ async function codebaseIndexStats(args, opts = {}) {
3713
+ return callIndexOp("stats", args, {
3714
+ timeoutMs: opts.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
3715
+ signal: opts.signal
3716
+ });
3717
+ }
3718
+
3719
+ // src/codebase-index/codebase-index-tool.ts
3720
+ var codebaseIndexTool = {
3721
+ name: "codebase-index",
3722
+ category: "Project",
3723
+ description: "Build or incrementally update the project-wide symbol index. This powers fast codebase search and understanding. By default it only processes files that have changed since the last indexing run.",
3724
+ usageHint: "IMPORTANT FOR LARGE CODEBASES:\n\n- First run (or after major changes): consider `force: true` for a clean rebuild.\n- Normal usage: call without arguments for fast incremental updates.\n- Use `langs` to restrict to specific languages if you only care about certain parts of the project.\nThis tool is relatively expensive \u2014 do not call it on every turn. Use it when the index is stale or before heavy codebase-search sessions.",
3725
+ permission: "confirm",
3726
+ mutating: true,
3727
+ capabilities: ["fs.write.outside-project"],
3728
+ timeoutMs: 12e4,
3729
+ inputSchema: {
3730
+ type: "object",
3731
+ properties: {
3732
+ force: {
3733
+ type: "boolean",
3734
+ description: "Force a full reindex \u2014 clears the index first and reindexes all files."
3735
+ },
3736
+ langs: {
3737
+ type: "array",
3738
+ items: { type: "string" },
3739
+ description: "Limit reindex to specific languages: ts, tsx, js, jsx, go, py, rs"
3069
3740
  }
3070
- if (docScore > 0) results.push({ id: doc.id, score: docScore });
3071
3741
  }
3072
- return results;
3073
- }
3074
- getDoc(id) {
3075
- return this.documents.find((d) => d.id === id);
3076
- }
3077
- extractSnippet(docId, queryTokens, radius = 40) {
3078
- const doc = this.getDoc(docId);
3079
- if (!doc) return "";
3080
- for (const tok of queryTokens) {
3081
- const idx = doc.raw.toLowerCase().indexOf(tok);
3082
- if (idx !== -1) {
3083
- const start = Math.max(0, idx - radius);
3084
- const end = Math.min(doc.raw.length, idx + tok.length + radius);
3085
- const excerpt = doc.raw.slice(start, end);
3086
- const ellipsis = "\u2026";
3087
- return (start > 0 ? ellipsis : "") + excerpt + (end < doc.raw.length ? ellipsis : "");
3088
- }
3742
+ },
3743
+ async execute(input, ctx, execOpts) {
3744
+ if (isIndexing()) {
3745
+ return {
3746
+ filesIndexed: 0,
3747
+ symbolsIndexed: 0,
3748
+ langStats: {},
3749
+ durationMs: 0,
3750
+ errors: [],
3751
+ note: "A full index is already in progress. Retry codebase-index after it completes (check codebase-stats)."
3752
+ };
3089
3753
  }
3090
- return doc.raw.slice(0, radius * 2) + (doc.raw.length > radius * 2 ? "\u2026" : "");
3754
+ const circuit = indexCircuitBreaker.snapshot();
3755
+ if (circuit.state === "open" && circuit.cooldownRemainingMs > 0) {
3756
+ return {
3757
+ filesIndexed: 0,
3758
+ symbolsIndexed: 0,
3759
+ langStats: {},
3760
+ durationMs: 0,
3761
+ errors: [],
3762
+ 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.`
3763
+ };
3764
+ }
3765
+ return await runStartupIndex({
3766
+ projectRoot: ctx.projectRoot,
3767
+ force: input.force ?? false,
3768
+ langs: input.langs,
3769
+ indexDir: codebaseIndexDirOverride(ctx),
3770
+ signal: execOpts?.signal
3771
+ });
3091
3772
  }
3092
3773
  };
3093
3774
 
@@ -3133,7 +3814,7 @@ var codebaseSearchTool = {
3133
3814
  },
3134
3815
  required: ["query"]
3135
3816
  },
3136
- async execute(input, ctx) {
3817
+ async execute(input, ctx, execOpts) {
3137
3818
  const state = getIndexState();
3138
3819
  if (!state.ready) {
3139
3820
  return {
@@ -3152,51 +3833,30 @@ var codebaseSearchTool = {
3152
3833
  };
3153
3834
  }
3154
3835
  if (state.lastError) {
3836
+ const circuit = state.circuit;
3837
+ 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.";
3155
3838
  return {
3156
3839
  results: [],
3157
3840
  total: 0,
3158
3841
  query: input.query,
3159
- indexStatus: `Index build failed: ${state.lastError}. Try /codebase-reindex.`
3842
+ indexStatus: `Index build failed: ${state.lastError}. ${retryHint}`
3160
3843
  };
3161
3844
  }
3162
- const store = new IndexStore(ctx.projectRoot, { indexDir: codebaseIndexDirOverride(ctx) });
3163
- try {
3164
- const limit = Math.min(input.limit ?? 20, 100);
3165
- const candidates = store.search(input.query, {
3845
+ const limit = Math.min(input.limit ?? 20, 100);
3846
+ const { results, total } = await searchCodebaseIndex(
3847
+ {
3848
+ projectRoot: ctx.projectRoot,
3849
+ indexDir: codebaseIndexDirOverride(ctx),
3850
+ query: input.query,
3166
3851
  kind: input.kind,
3167
3852
  lang: input.lang,
3168
3853
  file: input.file,
3169
- lspKind: input.lspKind
3170
- });
3171
- if (candidates.length === 0) {
3172
- return { results: [], total: 0, query: input.query };
3173
- }
3174
- const indexable = candidates.map((c) => ({
3175
- id: c.id,
3176
- text: buildIndexableText(c.name, c.signature, c.docComment)
3177
- }));
3178
- const bm25 = buildBm25Index(indexable);
3179
- const scored = bm25.score(input.query, (id) => candidates.some((c) => c.id === id));
3180
- scored.sort((a, b) => b.score - a.score);
3181
- const top = scored.slice(0, limit);
3182
- const qTokens = tokenise(input.query);
3183
- const results = top.map(({ id, score }) => {
3184
- const c = expectDefined(candidates.find((c2) => c2.id === id));
3185
- const snippet = bm25.extractSnippet(id, qTokens);
3186
- return {
3187
- ...c,
3188
- score,
3189
- snippet
3190
- };
3191
- });
3192
- return {
3193
- results,
3194
- total: candidates.length,
3195
- query: input.query
3196
- };
3197
- } finally {
3198
- store.close();
3199
- }
3854
+ lspKind: input.lspKind,
3855
+ limit
3856
+ },
3857
+ { signal: execOpts?.signal }
3858
+ );
3859
+ return { results, total, query: input.query };
3200
3860
  }
3201
3861
  };
3202
3862
 
@@ -3215,7 +3875,7 @@ var codebaseStatsTool = {
3215
3875
  properties: {},
3216
3876
  additionalProperties: false
3217
3877
  },
3218
- async execute(_input, ctx) {
3878
+ async execute(_input, ctx, execOpts) {
3219
3879
  const idxState = getIndexState();
3220
3880
  if (!idxState.ready) {
3221
3881
  return {
@@ -3230,34 +3890,30 @@ var codebaseStatsTool = {
3230
3890
  indexStatus: idxState.indexing ? `Indexing in progress (${idxState.currentFile}/${idxState.totalFiles} files).` : "Index not yet built."
3231
3891
  };
3232
3892
  }
3893
+ const stats = await codebaseIndexStats(
3894
+ { projectRoot: ctx.projectRoot, indexDir: codebaseIndexDirOverride(ctx) },
3895
+ { signal: execOpts?.signal }
3896
+ );
3233
3897
  if (idxState.indexing) {
3234
- const store2 = new IndexStore(ctx.projectRoot, { indexDir: codebaseIndexDirOverride(ctx) });
3235
- try {
3236
- const stats = store2.getStats();
3237
- return {
3238
- ...stats,
3239
- indexStatus: `Index refresh in progress (${idxState.currentFile}/${idxState.totalFiles} files). Stats may be incomplete.`
3240
- };
3241
- } finally {
3242
- store2.close();
3243
- }
3244
- }
3245
- const store = new IndexStore(ctx.projectRoot, { indexDir: codebaseIndexDirOverride(ctx) });
3246
- try {
3247
- const stats = store.getStats();
3248
3898
  return {
3249
- totalSymbols: stats.totalSymbols,
3250
- totalFiles: stats.totalFiles,
3251
- byLang: stats.byLang,
3252
- byKind: stats.byKind,
3253
- lastIndexed: stats.lastIndexed,
3254
- sizeBytes: stats.sizeBytes,
3255
- indexPath: stats.indexPath,
3256
- version: stats.version
3899
+ ...stats,
3900
+ indexStatus: `Index refresh in progress (${idxState.currentFile}/${idxState.totalFiles} files). Stats may be incomplete.`
3257
3901
  };
3258
- } finally {
3259
- store.close();
3260
3902
  }
3903
+ const circuit = idxState.circuit;
3904
+ return {
3905
+ totalSymbols: stats.totalSymbols,
3906
+ totalFiles: stats.totalFiles,
3907
+ byLang: stats.byLang,
3908
+ byKind: stats.byKind,
3909
+ lastIndexed: stats.lastIndexed,
3910
+ sizeBytes: stats.sizeBytes,
3911
+ indexPath: stats.indexPath,
3912
+ version: stats.version,
3913
+ ...circuit.state === "open" ? {
3914
+ 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.`
3915
+ } : {}
3916
+ };
3261
3917
  }
3262
3918
  };
3263
3919
  var diffTool = {
@@ -3359,7 +4015,8 @@ function runGit(args, cwd, signal) {
3359
4015
  cwd,
3360
4016
  signal,
3361
4017
  env: buildChildEnv(),
3362
- stdio: ["ignore", "pipe", "pipe"]
4018
+ stdio: ["ignore", "pipe", "pipe"],
4019
+ windowsHide: true
3363
4020
  });
3364
4021
  child.stdout?.on("data", (c) => {
3365
4022
  stdout += c.toString();
@@ -3385,9 +4042,9 @@ async function fileDiff(input, ctx, _signal) {
3385
4042
  const results = [];
3386
4043
  for (const file of files) {
3387
4044
  const absPath = safeResolve(file, ctx);
3388
- const stat10 = await fs13.stat(absPath).catch(() => null);
4045
+ const stat10 = await fs14.stat(absPath).catch(() => null);
3389
4046
  if (!stat10?.isFile()) continue;
3390
- const content = await fs13.readFile(absPath, "utf8");
4047
+ const content = await fs14.readFile(absPath, "utf8");
3391
4048
  const lines = content.split(/\r?\n/);
3392
4049
  results.push(formatWithLineNumbers(file, lines));
3393
4050
  }
@@ -3449,7 +4106,7 @@ var documentTool = {
3449
4106
  const fileList = input.files ? await resolveFiles(Array.isArray(input.files) ? input.files.join(",") : input.files, cwd) : input.path ? [safeResolve(input.path, ctx)] : [];
3450
4107
  for (const absPath of fileList) {
3451
4108
  try {
3452
- const content = await fs13.readFile(absPath, "utf8");
4109
+ const content = await fs14.readFile(absPath, "utf8");
3453
4110
  filesProcessed++;
3454
4111
  const processed = processFile(
3455
4112
  content,
@@ -3485,7 +4142,7 @@ async function resolveFiles(filesInput, cwd) {
3485
4142
  for (const f of files) {
3486
4143
  const absPath = f.trim().startsWith("/") ? f.trim() : `${cwd}/${f.trim()}`;
3487
4144
  try {
3488
- const stat10 = await fs13.stat(absPath);
4145
+ const stat10 = await fs14.stat(absPath);
3489
4146
  if (stat10.isFile()) resolved.push(absPath);
3490
4147
  } catch {
3491
4148
  }
@@ -3577,7 +4234,7 @@ var editTool = {
3577
4234
  if (input.new_string === void 0) throw new Error("edit: new_string is required");
3578
4235
  if (input.old_string === "") throw new Error("edit: old_string cannot be empty");
3579
4236
  const absPath = await safeResolveReal(input.path, ctx);
3580
- const stat10 = await fs13.stat(absPath).catch((err) => {
4237
+ const stat10 = await fs14.stat(absPath).catch((err) => {
3581
4238
  if (err.code === "ENOENT") {
3582
4239
  throw new Error(`edit: file "${input.path}" does not exist. Use \`write\` instead.`);
3583
4240
  }
@@ -3587,8 +4244,8 @@ var editTool = {
3587
4244
  if (!ctx.hasRead(absPath)) {
3588
4245
  throw new Error(`edit: file "${input.path}" was not read in this session. Read it first.`);
3589
4246
  }
3590
- const original = await fs13.readFile(absPath, "utf8");
3591
- const updated = await fs13.stat(absPath);
4247
+ const original = await fs14.readFile(absPath, "utf8");
4248
+ const updated = await fs14.stat(absPath);
3592
4249
  const mtimeTolerance = process.platform === "win32" ? 2e3 : 1;
3593
4250
  const lastReadMtime = ctx.lastReadMtime(absPath);
3594
4251
  if (lastReadMtime !== void 0 && updated.mtimeMs > lastReadMtime + mtimeTolerance) {
@@ -3628,7 +4285,7 @@ var editTool = {
3628
4285
  const newFileLf = input.replace_all ? fileLf.split(oldLf).join(newLf) : fileLf.replace(oldLf, newLf);
3629
4286
  const newFile = toStyle(newFileLf, style);
3630
4287
  await atomicWrite(absPath, newFile, { mode: updated.mode & 511 });
3631
- const written = await fs13.stat(absPath);
4288
+ const written = await fs14.stat(absPath);
3632
4289
  ctx.recordRead(absPath, written.mtimeMs);
3633
4290
  ctx.session.recordFileChange({
3634
4291
  path: absPath,
@@ -3878,12 +4535,14 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
3878
4535
  let killed = false;
3879
4536
  const startedAt = Date.now();
3880
4537
  const resolved = resolveWin32Command(cmd);
3881
- const needsShell = process.platform === "win32" && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
4538
+ const isWin = process.platform === "win32";
4539
+ const needsShell = isWin && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
3882
4540
  const child = spawn(resolved, args, {
3883
4541
  cwd,
3884
- signal,
3885
4542
  env: buildChildEnv(sessionId),
3886
4543
  stdio: ["ignore", "pipe", "pipe"],
4544
+ windowsHide: true,
4545
+ ...isWin ? {} : { signal },
3887
4546
  ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {}
3888
4547
  });
3889
4548
  const registry = getProcessRegistry();
@@ -3897,6 +4556,15 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
3897
4556
  if (typeof pid === "number") registry.kill(pid);
3898
4557
  else child.kill("SIGTERM");
3899
4558
  }, timeout);
4559
+ const onAbort = () => {
4560
+ killed = true;
4561
+ if (typeof pid === "number") registry.kill(pid, { force: true });
4562
+ else child.kill("SIGTERM");
4563
+ };
4564
+ if (isWin) {
4565
+ if (signal.aborted) onAbort();
4566
+ else signal.addEventListener("abort", onAbort, { once: true });
4567
+ }
3900
4568
  child.stdout?.on("data", (chunk) => {
3901
4569
  if (stdout.length < MAX_OUTPUT2) stdout += chunk.toString();
3902
4570
  });
@@ -3905,6 +4573,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
3905
4573
  });
3906
4574
  child.on("close", (code) => {
3907
4575
  clearTimeout(timer);
4576
+ if (isWin) signal.removeEventListener("abort", onAbort);
3908
4577
  if (typeof pid === "number") registry.unregister(pid);
3909
4578
  const durationMs = Date.now() - startedAt;
3910
4579
  const exitCode = killed ? 124 : code ?? 1;
@@ -3921,6 +4590,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
3921
4590
  });
3922
4591
  child.on("error", (err) => {
3923
4592
  clearTimeout(timer);
4593
+ if (isWin) signal.removeEventListener("abort", onAbort);
3924
4594
  if (typeof pid === "number") registry.unregister(pid);
3925
4595
  registry.afterCall(Date.now() - startedAt, true);
3926
4596
  resolve7({
@@ -4591,7 +5261,8 @@ function runGit2(args, cwd, signal) {
4591
5261
  cwd,
4592
5262
  signal,
4593
5263
  env: buildChildEnv(),
4594
- stdio: ["ignore", "pipe", "pipe"]
5264
+ stdio: ["ignore", "pipe", "pipe"],
5265
+ windowsHide: true
4595
5266
  });
4596
5267
  child.stdout?.on("data", (chunk) => {
4597
5268
  if (stdout.length < MAX_OUTPUT3) {
@@ -4667,7 +5338,7 @@ var globTool = {
4667
5338
  }
4668
5339
  let entries;
4669
5340
  try {
4670
- entries = await fs13.readdir(dir, { withFileTypes: true });
5341
+ entries = await fs14.readdir(dir, { withFileTypes: true });
4671
5342
  } catch {
4672
5343
  return;
4673
5344
  }
@@ -4683,7 +5354,7 @@ var globTool = {
4683
5354
  } else if (e.isFile()) {
4684
5355
  if (re.test(rel) || re.test(name)) {
4685
5356
  try {
4686
- const st = await fs13.stat(full);
5357
+ const st = await fs14.stat(full);
4687
5358
  results.push({ rel: full, mtime: st.mtimeMs });
4688
5359
  if (results.length >= limit) {
4689
5360
  truncated = true;
@@ -4702,7 +5373,7 @@ var globTool = {
4702
5373
  };
4703
5374
  async function readGitignore(dir) {
4704
5375
  try {
4705
- const raw = await fs13.readFile(path2.join(dir, ".gitignore"), "utf8");
5376
+ const raw = await fs14.readFile(path2.join(dir, ".gitignore"), "utf8");
4706
5377
  return raw.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
4707
5378
  } catch {
4708
5379
  return [];
@@ -4836,7 +5507,7 @@ var grepTool = {
4836
5507
  async function detectRg(signal) {
4837
5508
  return new Promise((resolve7) => {
4838
5509
  try {
4839
- const p = spawn("rg", ["--version"], { env: buildChildEnv(), stdio: "ignore", signal });
5510
+ const p = spawn("rg", ["--version"], { env: buildChildEnv(), stdio: "ignore", signal, windowsHide: true });
4840
5511
  p.on("error", () => resolve7(false));
4841
5512
  p.on("close", (code) => resolve7(code === 0));
4842
5513
  } catch {
@@ -4866,7 +5537,7 @@ async function* runRgStream(input, base, mode, limit, signal) {
4866
5537
  const FLUSH_AT = 16;
4867
5538
  const MAX_BUF_BYTES = 1e6;
4868
5539
  let bufOverflow = false;
4869
- const child = spawn("rg", args, { signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"] });
5540
+ const child = spawn("rg", args, { signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
4870
5541
  const queue = [];
4871
5542
  let waiter;
4872
5543
  const wake = () => {
@@ -4986,7 +5657,7 @@ async function runNative(input, base, mode, limit, signal) {
4986
5657
  if (stopped || signal.aborted) return;
4987
5658
  let entries;
4988
5659
  try {
4989
- entries = await fs13.readdir(dir, { withFileTypes: true });
5660
+ entries = await fs14.readdir(dir, { withFileTypes: true });
4990
5661
  } catch {
4991
5662
  return;
4992
5663
  }
@@ -5001,9 +5672,9 @@ async function runNative(input, base, mode, limit, signal) {
5001
5672
  if (globRe && !globRe.test(e.name) && !globRe.test(full)) continue;
5002
5673
  if (globRe) globRe.lastIndex = 0;
5003
5674
  try {
5004
- const stat10 = await fs13.stat(full);
5675
+ const stat10 = await fs14.stat(full);
5005
5676
  if (stat10.size > 1e6) continue;
5006
- const head = await fs13.readFile(full);
5677
+ const head = await fs14.readFile(full);
5007
5678
  if (isBinaryBuffer(head)) continue;
5008
5679
  const text = head.toString("utf8");
5009
5680
  const lines = text.split(/\r?\n/);
@@ -5042,8 +5713,6 @@ async function runNative(input, base, mode, limit, signal) {
5042
5713
  used: "native"
5043
5714
  };
5044
5715
  }
5045
-
5046
- // src/install.ts
5047
5716
  var installTool = {
5048
5717
  name: "install",
5049
5718
  category: "Package Management",
@@ -5138,18 +5807,48 @@ var installTool = {
5138
5807
  signal: opts.signal,
5139
5808
  maxBytes: 1e5
5140
5809
  });
5141
- yield {
5142
- type: "final",
5143
- output: {
5144
- packages: pkgList,
5145
- exit_code: result.exitCode,
5146
- output: normalizeCommandOutput(result.stdout || result.stderr || result.error || ""),
5147
- dry_run: args.includes("--dry-run"),
5148
- truncated: result.truncated
5149
- }
5810
+ const output = {
5811
+ packages: pkgList,
5812
+ exit_code: result.exitCode,
5813
+ output: normalizeCommandOutput(result.stdout || result.stderr || result.error || ""),
5814
+ dry_run: args.includes("--dry-run"),
5815
+ truncated: result.truncated
5150
5816
  };
5817
+ const isSuccess = result.exitCode === 0 && !output.dry_run && !input.global;
5818
+ if (isSuccess && pkgList.length > 0) {
5819
+ const trackerOpts = ctx.meta?.["packageTrackerOpts"];
5820
+ if (trackerOpts) {
5821
+ const manifestPath = resolveManifestPath(cwd, pkgManager);
5822
+ for (const pkg of pkgList) {
5823
+ try {
5824
+ await recordPackageAction(trackerOpts, {
5825
+ manifestPath,
5826
+ packageName: pkg,
5827
+ versionSpec: "latest",
5828
+ // exact version resolved by package manager at install time
5829
+ ecosystem: detectPackageEcosystem(manifestPath),
5830
+ agentId: ctx.agentId,
5831
+ agentName: ctx.agentName,
5832
+ sessionId: ctx.session.id
5833
+ });
5834
+ } catch {
5835
+ }
5836
+ }
5837
+ }
5838
+ }
5839
+ yield { type: "final", output };
5151
5840
  }
5152
5841
  };
5842
+ function resolveManifestPath(cwd, pkgManager) {
5843
+ switch (pkgManager) {
5844
+ case "pnpm":
5845
+ case "yarn":
5846
+ case "npm":
5847
+ return join(cwd, "package.json");
5848
+ default:
5849
+ return join(cwd, "package.json");
5850
+ }
5851
+ }
5153
5852
  var jsonTool = {
5154
5853
  name: "json",
5155
5854
  category: "Data",
@@ -5184,7 +5883,7 @@ var jsonTool = {
5184
5883
  let raw;
5185
5884
  if (input.file) {
5186
5885
  try {
5187
- raw = await fs13.readFile(input.file, "utf8");
5886
+ raw = await fs14.readFile(input.file, "utf8");
5188
5887
  } catch {
5189
5888
  return { data: null, formatted: "", type: "unknown", error: `Could not read file` };
5190
5889
  }
@@ -5463,7 +6162,7 @@ async function dockerLogs(service, lines, filterRe, cwd, signal, since) {
5463
6162
  clearTimeout(timer);
5464
6163
  resolve7(result);
5465
6164
  };
5466
- const child = spawn("docker", args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"] });
6165
+ const child = spawn("docker", args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
5467
6166
  const timer = setTimeout(() => {
5468
6167
  child.kill("SIGTERM");
5469
6168
  finish(empty());
@@ -5609,7 +6308,7 @@ function runOutdated(manager, args, cwd, signal) {
5609
6308
  const MAX = 1e5;
5610
6309
  const resolved = resolveWin32Command(manager);
5611
6310
  const needsShell = process.platform === "win32" && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
5612
- const child = spawn(resolved, args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {} });
6311
+ const child = spawn(resolved, args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true, ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {} });
5613
6312
  child.stdout?.on("data", (c) => {
5614
6313
  if (stdout.length < MAX) stdout += c.toString();
5615
6314
  });
@@ -5705,12 +6404,12 @@ var patchTool = {
5705
6404
  };
5706
6405
  }
5707
6406
  }
5708
- const tmpDir = await fs13.mkdtemp(path2.join(os.tmpdir(), ".wstack_patch_"));
6407
+ const tmpDir = await fs14.mkdtemp(path2.join(os.tmpdir(), ".wstack_patch_"));
5709
6408
  try {
5710
- await fs13.chmod(tmpDir, 448).catch(() => {
6409
+ await fs14.chmod(tmpDir, 448).catch(() => {
5711
6410
  });
5712
6411
  const patchFile = path2.join(tmpDir, "in.diff");
5713
- await fs13.writeFile(patchFile, input.patch, { mode: 384 });
6412
+ await fs14.writeFile(patchFile, input.patch, { mode: 384 });
5714
6413
  const args = [`-p${strip}`, "--merge", ...dryRun ? ["--dry-run"] : [], "-i", patchFile];
5715
6414
  const result = await runPatch(args, dir, opts.signal);
5716
6415
  if (result.exitCode !== 0 && !dryRun) {
@@ -5731,7 +6430,7 @@ var patchTool = {
5731
6430
  message: result.stdout || "patch applied"
5732
6431
  };
5733
6432
  } finally {
5734
- await fs13.rm(tmpDir, { recursive: true, force: true }).catch(() => {
6433
+ await fs14.rm(tmpDir, { recursive: true, force: true }).catch(() => {
5735
6434
  });
5736
6435
  }
5737
6436
  }
@@ -5756,7 +6455,7 @@ function runPatch(args, cwd, signal) {
5756
6455
  let stdout = "";
5757
6456
  let stderr = "";
5758
6457
  const env = { ...buildChildEnv(), LANG: "C", LC_ALL: "C" };
5759
- const child = spawn("patch", args, { cwd, signal, env, stdio: ["pipe", "pipe", "pipe"] });
6458
+ const child = spawn("patch", args, { cwd, signal, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
5760
6459
  child.stdout?.on("data", (c) => {
5761
6460
  stdout += c.toString();
5762
6461
  });
@@ -5779,7 +6478,7 @@ var planTool = {
5779
6478
  name: "plan",
5780
6479
  category: "Session",
5781
6480
  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.",
5782
- 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.',
6481
+ 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.',
5783
6482
  permission: "confirm",
5784
6483
  mutating: true,
5785
6484
  capabilities: ["fs.write"],
@@ -5796,9 +6495,9 @@ var planTool = {
5796
6495
  "done",
5797
6496
  "remove",
5798
6497
  "promote",
5799
- "derive",
5800
6498
  "template_use",
5801
- "clear"
6499
+ "clear",
6500
+ "taskify"
5802
6501
  ],
5803
6502
  description: "The operation to perform on the plan board."
5804
6503
  },
@@ -5817,7 +6516,7 @@ var planTool = {
5817
6516
  subtasks: {
5818
6517
  type: "array",
5819
6518
  items: { type: "string" },
5820
- description: "List of subtask titles. Used with promote or derive to break a plan item into multiple todos."
6519
+ description: "List of subtask titles. Used with promote to break a plan item into multiple todos."
5821
6520
  },
5822
6521
  template: {
5823
6522
  type: "string",
@@ -5838,92 +6537,151 @@ var planTool = {
5838
6537
  };
5839
6538
  }
5840
6539
  const sessionId = ctx.session?.id ?? "unknown";
5841
- let plan = await loadPlan(planPath) ?? emptyPlan(sessionId);
5842
- switch (input.action) {
5843
- case "show":
5844
- break;
5845
- case "add": {
5846
- const title = input.title?.trim();
5847
- if (!title) {
5848
- return mkResult(plan, false, "add requires `title`.");
5849
- }
5850
- ({ plan } = addPlanItem(plan, title, input.details?.trim() || void 0));
5851
- await savePlan(planPath, plan);
5852
- break;
5853
- }
5854
- case "start":
5855
- case "done": {
5856
- if (!input.target) {
5857
- return mkResult(plan, false, `${input.action} requires \`target\` (id|index|substring).`);
6540
+ let early = null;
6541
+ const taskifyMeta = { title: "", details: "" };
6542
+ let didTaskify = false;
6543
+ const plan = await mutatePlan(planPath, sessionId, async (p) => {
6544
+ switch (input.action) {
6545
+ case "show":
6546
+ break;
6547
+ case "add": {
6548
+ const title = input.title?.trim();
6549
+ if (!title) {
6550
+ early = mkResult(p, false, "add requires `title`.");
6551
+ return p;
6552
+ }
6553
+ const { plan: updated } = addPlanItem(p, title, input.details?.trim() || void 0);
6554
+ return updated;
5858
6555
  }
5859
- const next = setPlanItemStatus(
5860
- plan,
5861
- input.target,
5862
- input.action === "start" ? "in_progress" : "done"
5863
- );
5864
- if (next === plan) {
5865
- return mkResult(plan, false, `No plan item matched "${input.target}".`);
6556
+ case "start":
6557
+ case "done": {
6558
+ if (!input.target) {
6559
+ early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
6560
+ return p;
6561
+ }
6562
+ const next = setPlanItemStatus(
6563
+ p,
6564
+ input.target,
6565
+ input.action === "start" ? "in_progress" : "done"
6566
+ );
6567
+ if (next === p) {
6568
+ early = mkResult(p, false, `No plan item matched "${input.target}".`);
6569
+ return p;
6570
+ }
6571
+ return next;
5866
6572
  }
5867
- plan = next;
5868
- await savePlan(planPath, plan);
5869
- break;
5870
- }
5871
- case "remove": {
5872
- if (!input.target) {
5873
- return mkResult(plan, false, "remove requires `target` (id|index|substring).");
6573
+ case "remove": {
6574
+ if (!input.target) {
6575
+ early = mkResult(p, false, "remove requires `target` (id|index|substring).");
6576
+ return p;
6577
+ }
6578
+ const next = removePlanItem(p, input.target);
6579
+ if (next === p) {
6580
+ early = mkResult(p, false, `No plan item matched "${input.target}".`);
6581
+ return p;
6582
+ }
6583
+ return next;
5874
6584
  }
5875
- const next = removePlanItem(plan, input.target);
5876
- if (next === plan) {
5877
- return mkResult(plan, false, `No plan item matched "${input.target}".`);
6585
+ case "promote": {
6586
+ if (!input.target) {
6587
+ early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
6588
+ return p;
6589
+ }
6590
+ const derived = deriveTodosFromPlanItem(p, input.target, input.subtasks);
6591
+ if (!derived) {
6592
+ early = mkResult(p, false, `No plan item matched "${input.target}".`);
6593
+ return p;
6594
+ }
6595
+ ctx.state.replaceTodos(derived.todos);
6596
+ early = mkResult(
6597
+ derived.plan,
6598
+ true,
6599
+ `${input.action} ok \u2014 ${derived.todos.length} todo(s) created.`,
6600
+ derived.todos
6601
+ );
6602
+ return derived.plan;
5878
6603
  }
5879
- plan = next;
5880
- await savePlan(planPath, plan);
5881
- break;
5882
- }
5883
- case "promote":
5884
- case "derive": {
5885
- if (!input.target) {
5886
- return mkResult(plan, false, `${input.action} requires \`target\` (id|index|substring).`);
6604
+ case "template_use": {
6605
+ const templateName = input.template?.trim();
6606
+ if (!templateName) {
6607
+ early = mkResult(p, false, "template_use requires `template` name.");
6608
+ return p;
6609
+ }
6610
+ const template = getPlanTemplate(templateName);
6611
+ if (!template) {
6612
+ early = mkResult(p, false, `Unknown template "${templateName}".`);
6613
+ return p;
6614
+ }
6615
+ let updated = p;
6616
+ for (const item of template.items) {
6617
+ ({ plan: updated } = addPlanItem(updated, item.title, item.details));
6618
+ }
6619
+ early = mkResult(
6620
+ updated,
6621
+ true,
6622
+ `Applied template "${template.name}" \u2014 ${template.items.length} items added.`
6623
+ );
6624
+ return updated;
5887
6625
  }
5888
- const derived = deriveTodosFromPlanItem(plan, input.target, input.subtasks);
5889
- if (!derived) {
5890
- return mkResult(plan, false, `No plan item matched "${input.target}".`);
6626
+ case "clear":
6627
+ return clearPlan(p);
6628
+ case "taskify": {
6629
+ if (!input.target) {
6630
+ early = mkResult(p, false, "taskify requires `target` (plan item id|index|substring).");
6631
+ return p;
6632
+ }
6633
+ let itemIdx = -1;
6634
+ const asNum = Number.parseInt(input.target, 10);
6635
+ if (!Number.isNaN(asNum) && asNum >= 1 && asNum <= p.items.length) {
6636
+ itemIdx = asNum - 1;
6637
+ } else {
6638
+ itemIdx = p.items.findIndex((it) => it.id === input.target);
6639
+ if (itemIdx === -1) {
6640
+ const lower = input.target.toLowerCase();
6641
+ itemIdx = p.items.findIndex((it) => it.title.toLowerCase().includes(lower));
6642
+ }
6643
+ }
6644
+ if (itemIdx === -1 || !p.items[itemIdx]) {
6645
+ early = mkResult(p, false, `No plan item matched "${input.target}".`);
6646
+ return p;
6647
+ }
6648
+ const item = p.items[itemIdx];
6649
+ taskifyMeta.title = item.title;
6650
+ taskifyMeta.details = item.details ?? "";
6651
+ didTaskify = true;
6652
+ break;
5891
6653
  }
5892
- plan = derived.plan;
5893
- await savePlan(planPath, plan);
5894
- ctx.state.replaceTodos(derived.todos);
5895
- return mkResult(
5896
- plan,
5897
- true,
5898
- `${input.action} ok \u2014 ${derived.todos.length} todo(s) created.`,
5899
- derived.todos
5900
- );
6654
+ default:
6655
+ early = mkResult(p, false, `Unknown action "${input.action}".`);
6656
+ return p;
5901
6657
  }
5902
- case "template_use": {
5903
- const templateName = input.template?.trim();
5904
- if (!templateName) {
5905
- return mkResult(plan, false, "template_use requires `template` name.");
5906
- }
5907
- const template = getPlanTemplate(templateName);
5908
- if (!template) {
5909
- return mkResult(plan, false, `Unknown template "${templateName}".`);
5910
- }
5911
- for (const item of template.items) {
5912
- ({ plan } = addPlanItem(plan, item.title, item.details));
5913
- }
5914
- await savePlan(planPath, plan);
5915
- return mkResult(
5916
- plan,
5917
- true,
5918
- `Applied template "${template.name}" \u2014 ${template.items.length} items added.`
5919
- );
6658
+ return p;
6659
+ });
6660
+ if (early) return early;
6661
+ if (didTaskify) {
6662
+ const taskPath = ctx.meta["task.path"];
6663
+ if (typeof taskPath !== "string" || !taskPath) {
6664
+ return mkResult(plan, false, "Task storage path not configured \u2014 cannot taskify.");
5920
6665
  }
5921
- case "clear":
5922
- plan = clearPlan(plan);
5923
- await savePlan(planPath, plan);
5924
- break;
5925
- default:
5926
- return mkResult(plan, false, `Unknown action "${input.action}".`);
6666
+ const taskFile = await loadTasks(taskPath) ?? emptyTaskFile(sessionId);
6667
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6668
+ taskFile.tasks.push({
6669
+ id: `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
6670
+ title: taskifyMeta.title,
6671
+ description: taskifyMeta.details || void 0,
6672
+ type: "feature",
6673
+ priority: "medium",
6674
+ status: "pending",
6675
+ createdAt: now,
6676
+ updatedAt: now
6677
+ });
6678
+ await saveTasks(taskPath, taskFile);
6679
+ return mkResult(
6680
+ plan,
6681
+ true,
6682
+ `taskify ok \u2014 added "${taskifyMeta.title}" to tasks.
6683
+ ${formatTaskList(taskFile.tasks)}`
6684
+ );
5927
6685
  }
5928
6686
  return mkResult(plan, true, `Plan ${input.action} ok.`);
5929
6687
  }
@@ -5974,7 +6732,7 @@ var readTool = {
5974
6732
  const absPath = await safeResolveReal(input.path, ctx);
5975
6733
  let stat10;
5976
6734
  try {
5977
- stat10 = await fs13.stat(absPath);
6735
+ stat10 = await fs14.stat(absPath);
5978
6736
  } catch (err) {
5979
6737
  const code = err.code;
5980
6738
  if (code === "ENOENT") throw new Error(`read: file not found "${input.path}"`);
@@ -5986,7 +6744,7 @@ var readTool = {
5986
6744
  if (stat10.size > MAX_BYTES2) {
5987
6745
  throw new Error(`read: file too large (${stat10.size} bytes, limit ${MAX_BYTES2})`);
5988
6746
  }
5989
- const buf = await fs13.readFile(absPath);
6747
+ const buf = await fs14.readFile(absPath);
5990
6748
  if (isBinaryBuffer(buf)) {
5991
6749
  throw new Error(`read: "${input.path}" appears to be binary`);
5992
6750
  }
@@ -6054,11 +6812,11 @@ var replaceTool = {
6054
6812
  const dryRun = input.dry_run ?? false;
6055
6813
  const filesInput = Array.isArray(input.files) ? input.files.join(",") : input.files;
6056
6814
  const fileList = await resolveFiles2(filesInput, ctx, globRe);
6057
- const realRoot = await fs13.realpath(ctx.projectRoot).catch(() => ctx.projectRoot);
6815
+ const realRoot = await fs14.realpath(ctx.projectRoot).catch(() => ctx.projectRoot);
6058
6816
  const results = [];
6059
6817
  let totalReplacements = 0;
6060
6818
  for (const absPath of fileList) {
6061
- const lstat2 = await fs13.lstat(absPath).catch((err) => {
6819
+ const lstat2 = await fs14.lstat(absPath).catch((err) => {
6062
6820
  if (err.code === "ENOENT") return null;
6063
6821
  throw err;
6064
6822
  });
@@ -6066,17 +6824,17 @@ var replaceTool = {
6066
6824
  if (lstat2.isSymbolicLink()) continue;
6067
6825
  let realPath;
6068
6826
  try {
6069
- realPath = await fs13.realpath(absPath);
6827
+ realPath = await fs14.realpath(absPath);
6070
6828
  } catch {
6071
6829
  continue;
6072
6830
  }
6073
6831
  const rel = path2.relative(realRoot, realPath);
6074
6832
  if (rel.startsWith("..") || path2.isAbsolute(rel)) continue;
6075
- const stat10 = await fs13.stat(realPath).catch(() => null);
6833
+ const stat10 = await fs14.stat(realPath).catch(() => null);
6076
6834
  if (!stat10 || !stat10.isFile()) continue;
6077
6835
  let content;
6078
6836
  try {
6079
- const buf = await fs13.readFile(realPath);
6837
+ const buf = await fs14.readFile(realPath);
6080
6838
  if (isBinaryBuffer(buf)) continue;
6081
6839
  content = buf.toString("utf8");
6082
6840
  } catch {
@@ -6128,7 +6886,7 @@ async function resolveFiles2(filesInput, ctx, extraGlob) {
6128
6886
  const resolved = [];
6129
6887
  for (const p of parts) {
6130
6888
  const absPath = safeResolve(p, ctx);
6131
- const stat10 = await fs13.stat(absPath).catch(() => null);
6889
+ const stat10 = await fs14.stat(absPath).catch(() => null);
6132
6890
  if (stat10?.isFile()) {
6133
6891
  resolved.push(absPath);
6134
6892
  }
@@ -6149,7 +6907,7 @@ async function globFiles(pattern, base, extraGlob) {
6149
6907
  function checkRg() {
6150
6908
  return new Promise((resolve7) => {
6151
6909
  try {
6152
- const p = spawn("rg", ["--version"], { env: buildChildEnv(), stdio: "ignore" });
6910
+ const p = spawn("rg", ["--version"], { env: buildChildEnv(), stdio: "ignore", windowsHide: true });
6153
6911
  p.on("error", () => resolve7(false));
6154
6912
  p.on("close", (code) => resolve7(code === 0));
6155
6913
  } catch {
@@ -6162,7 +6920,8 @@ function spawnRgFind(pattern, base) {
6162
6920
  const child = spawn("rg", args, {
6163
6921
  signal: AbortSignal.timeout(3e4),
6164
6922
  env: buildChildEnv(),
6165
- stdio: ["ignore", "pipe", "pipe"]
6923
+ stdio: ["ignore", "pipe", "pipe"],
6924
+ windowsHide: true
6166
6925
  });
6167
6926
  let buf = "";
6168
6927
  child.stdout?.on("data", (chunk) => {
@@ -6183,7 +6942,7 @@ async function globNative(pattern, base, extraGlob) {
6183
6942
  const walk = async (dir) => {
6184
6943
  let entries;
6185
6944
  try {
6186
- entries = await fs13.readdir(dir, { withFileTypes: true });
6945
+ entries = await fs14.readdir(dir, { withFileTypes: true });
6187
6946
  } catch {
6188
6947
  return;
6189
6948
  }
@@ -6191,7 +6950,7 @@ async function globNative(pattern, base, extraGlob) {
6191
6950
  if (DEFAULT_IGNORE4.includes(e.name)) continue;
6192
6951
  const full = path2.join(dir, e.name);
6193
6952
  try {
6194
- const stat10 = await fs13.lstat(full);
6953
+ const stat10 = await fs14.lstat(full);
6195
6954
  if (stat10.isSymbolicLink()) continue;
6196
6955
  } catch {
6197
6956
  continue;
@@ -6368,7 +7127,7 @@ async function handleBuiltIn(name, templateFiles, cwd, ctx, dryRun, vars) {
6368
7127
  }
6369
7128
  const fullPath = target;
6370
7129
  if (!dryRun) {
6371
- await fs13.mkdir(path2.dirname(fullPath), { recursive: true });
7130
+ await fs14.mkdir(path2.dirname(fullPath), { recursive: true });
6372
7131
  await atomicWrite(fullPath, substituteVars(content, name, vars));
6373
7132
  }
6374
7133
  files.push(resolvedPath);
@@ -6469,13 +7228,24 @@ var searchTool = {
6469
7228
  async function duckduckgoSearch(query2, num, signal) {
6470
7229
  const encoded = encodeURIComponent(query2);
6471
7230
  const url = `https://lite.duckduckgo.com/lite/?q=${encoded}&kd=-1&kl=wt-wt`;
6472
- const results = await fetchWithTimeout(url, signal, TIMEOUT_MS3).then((r) => r.text()).then((html) => parseDuckDuckGo(html, num)).catch(() => [{ title: "Search unavailable", url: "", snippet: "Could not reach DuckDuckGo" }]);
6473
- return {
6474
- query: query2,
6475
- results,
6476
- source: "duckduckgo",
6477
- truncated: results.length >= num
6478
- };
7231
+ try {
7232
+ const response = await fetchWithTimeout(url, signal, TIMEOUT_MS3);
7233
+ const html = await response.text();
7234
+ const results = parseDuckDuckGo(html, num);
7235
+ return {
7236
+ query: query2,
7237
+ results,
7238
+ source: "duckduckgo",
7239
+ truncated: results.length >= num
7240
+ };
7241
+ } catch {
7242
+ return {
7243
+ query: query2,
7244
+ results: [{ title: "Search unavailable", url: "", snippet: "Could not reach DuckDuckGo" }],
7245
+ source: "duckduckgo",
7246
+ truncated: false
7247
+ };
7248
+ }
6479
7249
  }
6480
7250
  function takeFrom(iter, max) {
6481
7251
  const out = [];
@@ -6594,34 +7364,92 @@ async function fetchWithTimeout(url, signal, timeoutMs) {
6594
7364
  }
6595
7365
  }
6596
7366
  function anySignal(...signals) {
6597
- const controller = new AbortController();
6598
- for (const s of signals) {
6599
- if (s.aborted) {
6600
- controller.abort();
6601
- break;
6602
- }
6603
- s.addEventListener("abort", () => controller.abort());
6604
- }
6605
- return controller.signal;
7367
+ return AbortSignal.any(signals);
6606
7368
  }
6607
7369
  function stripTags2(html) {
6608
7370
  return html.replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").trim();
6609
7371
  }
7372
+ var setWorkingDirTool = {
7373
+ name: "set_working_dir",
7374
+ category: "Context",
7375
+ 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.",
7376
+ 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.",
7377
+ permission: "confirm",
7378
+ mutating: true,
7379
+ capabilities: ["fs.read"],
7380
+ timeoutMs: 5e3,
7381
+ inputSchema: {
7382
+ type: "object",
7383
+ properties: {
7384
+ path: {
7385
+ type: "string",
7386
+ description: "Directory to navigate to. Can be relative (to projectRoot) or absolute. If omitted, returns the current working directory without changing it."
7387
+ }
7388
+ }
7389
+ },
7390
+ async execute(input, ctx, _opts) {
7391
+ if (!input.path) {
7392
+ return {
7393
+ current: ctx.workingDir,
7394
+ message: `Current working directory is ${ctx.workingDir}`
7395
+ };
7396
+ }
7397
+ const previous = ctx.workingDir;
7398
+ let resolved;
7399
+ try {
7400
+ resolved = ctx.setWorkingDir(input.path);
7401
+ } catch (err) {
7402
+ return {
7403
+ current: ctx.workingDir,
7404
+ error: err instanceof Error ? err.message : String(err)
7405
+ };
7406
+ }
7407
+ try {
7408
+ await fs14.access(resolved);
7409
+ } catch {
7410
+ try {
7411
+ ctx.setWorkingDir(previous);
7412
+ } catch {
7413
+ }
7414
+ return {
7415
+ current: ctx.workingDir,
7416
+ error: `Directory does not exist: ${resolved}`
7417
+ };
7418
+ }
7419
+ return {
7420
+ current: resolved,
7421
+ previous,
7422
+ message: `Working directory changed to ${resolved}`
7423
+ };
7424
+ }
7425
+ };
7426
+ function findTaskIndex(tasks, query2) {
7427
+ const asNum = Number.parseInt(query2, 10);
7428
+ if (!Number.isNaN(asNum)) {
7429
+ const idx = asNum - 1;
7430
+ if (tasks[idx]) return idx;
7431
+ }
7432
+ const byId = tasks.findIndex((t) => t.id === query2);
7433
+ if (byId >= 0) return byId;
7434
+ const lower = query2.toLowerCase();
7435
+ return tasks.findIndex((t) => t.title.toLowerCase().includes(lower));
7436
+ }
6610
7437
  var taskTool = {
6611
7438
  name: "task",
6612
7439
  category: "Session",
6613
7440
  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.",
6614
- 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',
6615
- permission: "auto",
6616
- mutating: false,
7441
+ 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',
7442
+ permission: "confirm",
7443
+ mutating: true,
7444
+ capabilities: ["fs.write"],
6617
7445
  timeoutMs: 2e3,
6618
7446
  inputSchema: {
6619
7447
  type: "object",
6620
7448
  properties: {
6621
7449
  action: {
6622
7450
  type: "string",
6623
- enum: ["replace", "add", "status", "show"],
6624
- description: "replace = set full list, add = append, status = update task status, show = view only."
7451
+ enum: ["replace", "add", "status", "show", "promote", "planify"],
7452
+ 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."
6625
7453
  },
6626
7454
  tasks: {
6627
7455
  type: "array",
@@ -6665,11 +7493,20 @@ var taskTool = {
6665
7493
  required: ["title", "type", "priority"],
6666
7494
  description: "Single task to append (id/createdAt/updatedAt auto-generated)."
6667
7495
  },
6668
- id: { type: "string", description: "Task id for action=status." },
7496
+ id: { type: "string", description: "Task id for action=status or target for action=promote." },
6669
7497
  status: {
6670
7498
  type: "string",
6671
7499
  enum: ["pending", "in_progress", "blocked", "failed", "review", "completed"],
6672
7500
  description: "New status for action=status."
7501
+ },
7502
+ target: {
7503
+ type: "string",
7504
+ description: "Target task identifier (id, 1-based index, or title substring) for action=promote."
7505
+ },
7506
+ subtasks: {
7507
+ type: "array",
7508
+ items: { type: "string" },
7509
+ description: "Optional subtask titles for action=promote. Each becomes a pending todo."
6673
7510
  }
6674
7511
  },
6675
7512
  required: ["action"]
@@ -6680,65 +7517,196 @@ var taskTool = {
6680
7517
  return { ok: false, message: "Task storage path not configured.", count: 0, completed: 0, inProgress: 0 };
6681
7518
  }
6682
7519
  const sessionId = ctx.session?.id ?? "unknown";
6683
- const file = await loadTasks(taskPath) ?? emptyTaskFile(sessionId);
6684
- switch (input.action) {
6685
- case "show":
6686
- break;
6687
- case "replace": {
6688
- if (!Array.isArray(input.tasks)) {
6689
- return { ok: false, message: "action=replace requires `tasks` array.", count: 0, completed: 0, inProgress: 0 };
7520
+ let early = null;
7521
+ const promoteMeta = { count: 0, title: "" };
7522
+ const planifyMeta = { title: "", details: "" };
7523
+ let didPlanify = false;
7524
+ const file = await mutateTasks(taskPath, sessionId, async (f) => {
7525
+ switch (input.action) {
7526
+ case "show":
7527
+ break;
7528
+ case "replace": {
7529
+ if (!Array.isArray(input.tasks)) {
7530
+ early = { ok: false, message: "action=replace requires `tasks` array.", count: 0, completed: 0, inProgress: 0 };
7531
+ return f;
7532
+ }
7533
+ const newIds = new Set(input.tasks.map((t) => t.id));
7534
+ for (const t of input.tasks) {
7535
+ if (t.dependsOn && t.dependsOn.length > 0) {
7536
+ const missing = t.dependsOn.filter((d) => !newIds.has(d));
7537
+ if (missing.length > 0) {
7538
+ early = {
7539
+ ok: false,
7540
+ message: `dependsOn validation failed: task "${t.id}" references unknown IDs: ${missing.join(", ")}`,
7541
+ count: 0,
7542
+ completed: 0,
7543
+ inProgress: 0
7544
+ };
7545
+ return f;
7546
+ }
7547
+ }
7548
+ }
7549
+ const now = (/* @__PURE__ */ new Date()).toISOString();
7550
+ f.tasks = input.tasks.map((t) => ({
7551
+ ...t,
7552
+ createdAt: t.createdAt || now,
7553
+ updatedAt: now
7554
+ }));
7555
+ break;
6690
7556
  }
6691
- const now = (/* @__PURE__ */ new Date()).toISOString();
6692
- file.tasks = input.tasks.map((t) => ({
6693
- ...t,
6694
- createdAt: t.createdAt || now,
6695
- updatedAt: now
6696
- }));
6697
- await saveTasks(taskPath, file);
6698
- break;
6699
- }
6700
- case "add": {
6701
- const t = input.task;
6702
- if (!t || !t.title) {
6703
- return { ok: false, message: "action=add requires `task` with at least `title`.", count: 0, completed: 0, inProgress: 0 };
7557
+ case "add": {
7558
+ const t = input.task;
7559
+ if (!t || !t.title) {
7560
+ early = { ok: false, message: "action=add requires `task` with at least `title`.", count: 0, completed: 0, inProgress: 0 };
7561
+ return f;
7562
+ }
7563
+ if (t.dependsOn && t.dependsOn.length > 0) {
7564
+ const existingIds = new Set(f.tasks.map((e) => e.id));
7565
+ const missing = t.dependsOn.filter((d) => !existingIds.has(d));
7566
+ if (missing.length > 0) {
7567
+ early = {
7568
+ ok: false,
7569
+ message: `dependsOn validation failed: unknown task IDs: ${missing.join(", ")}`,
7570
+ count: 0,
7571
+ completed: 0,
7572
+ inProgress: 0
7573
+ };
7574
+ return f;
7575
+ }
7576
+ }
7577
+ const now = (/* @__PURE__ */ new Date()).toISOString();
7578
+ const newTask = {
7579
+ id: `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
7580
+ title: t.title,
7581
+ description: t.description,
7582
+ type: t.type || "feature",
7583
+ priority: t.priority || "medium",
7584
+ status: t.status || "pending",
7585
+ dependsOn: t.dependsOn,
7586
+ assignee: t.assignee,
7587
+ estimateHours: t.estimateHours,
7588
+ tags: t.tags,
7589
+ createdAt: now,
7590
+ updatedAt: now
7591
+ };
7592
+ f.tasks.push(newTask);
7593
+ break;
6704
7594
  }
6705
- const now = (/* @__PURE__ */ new Date()).toISOString();
6706
- const newTask = {
6707
- id: `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
6708
- title: t.title,
6709
- description: t.description,
6710
- type: t.type || "feature",
6711
- priority: t.priority || "medium",
6712
- status: t.status || "pending",
6713
- dependsOn: t.dependsOn,
6714
- assignee: t.assignee,
6715
- estimateHours: t.estimateHours,
6716
- tags: t.tags,
6717
- createdAt: now,
6718
- updatedAt: now
6719
- };
6720
- file.tasks.push(newTask);
6721
- await saveTasks(taskPath, file);
6722
- break;
6723
- }
6724
- case "status": {
6725
- if (!input.id || !input.status) {
6726
- return { ok: false, message: "action=status requires `id` and `status`.", count: 0, completed: 0, inProgress: 0 };
7595
+ case "status": {
7596
+ if (!input.id || !input.status) {
7597
+ early = { ok: false, message: "action=status requires `id` and `status`.", count: 0, completed: 0, inProgress: 0 };
7598
+ return f;
7599
+ }
7600
+ const task = f.tasks.find((t) => t.id === input.id);
7601
+ if (!task) {
7602
+ early = { ok: false, message: `Task "${input.id}" not found.`, count: 0, completed: 0, inProgress: 0 };
7603
+ return f;
7604
+ }
7605
+ task.status = input.status;
7606
+ task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
7607
+ break;
7608
+ }
7609
+ case "promote": {
7610
+ const target = input.target?.trim();
7611
+ if (!target) {
7612
+ early = { ok: false, message: "action=promote requires `target` (task id, index, or title substring).", count: 0, completed: 0, inProgress: 0 };
7613
+ return f;
7614
+ }
7615
+ const idx = findTaskIndex(f.tasks, target);
7616
+ if (idx === -1) {
7617
+ early = { ok: false, message: `No task matched "${target}".`, count: 0, completed: 0, inProgress: 0 };
7618
+ return f;
7619
+ }
7620
+ const match = f.tasks[idx];
7621
+ if (!match) {
7622
+ early = { ok: false, message: `No task matched "${target}".`, count: 0, completed: 0, inProgress: 0 };
7623
+ return f;
7624
+ }
7625
+ if (match.status !== "completed" && match.status !== "failed") {
7626
+ match.status = "in_progress";
7627
+ match.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
7628
+ }
7629
+ const todos = [];
7630
+ const ts2 = Date.now();
7631
+ todos.push({
7632
+ id: `todo_${ts2}_task`,
7633
+ content: match.title,
7634
+ status: "in_progress",
7635
+ activeForm: match.title,
7636
+ promotedFromTask: match.id
7637
+ });
7638
+ if (match.description) {
7639
+ todos.push({
7640
+ id: `todo_${ts2}_${randomUUID().slice(0, 6)}`,
7641
+ content: match.description.slice(0, 200),
7642
+ status: "pending",
7643
+ promotedFromTask: match.id
7644
+ });
7645
+ }
7646
+ if (input.subtasks && input.subtasks.length > 0) {
7647
+ for (const st of input.subtasks) {
7648
+ todos.push({
7649
+ id: `todo_${ts2}_${randomUUID().slice(0, 6)}`,
7650
+ content: st,
7651
+ status: "pending",
7652
+ promotedFromTask: match.id
7653
+ });
7654
+ }
7655
+ }
7656
+ ctx.state.replaceTodos(todos);
7657
+ promoteMeta.count = todos.length;
7658
+ promoteMeta.title = match.title;
7659
+ break;
6727
7660
  }
6728
- const task = file.tasks.find((t) => t.id === input.id);
6729
- if (!task) {
6730
- return { ok: false, message: `Task "${input.id}" not found.`, count: 0, completed: 0, inProgress: 0 };
7661
+ case "planify": {
7662
+ const target = input.target?.trim();
7663
+ if (!target) {
7664
+ early = { ok: false, message: "action=planify requires `target` (task id, index, or title substring).", count: 0, completed: 0, inProgress: 0 };
7665
+ return f;
7666
+ }
7667
+ const idx = findTaskIndex(f.tasks, target);
7668
+ if (idx === -1) {
7669
+ early = { ok: false, message: `No task matched "${target}".`, count: 0, completed: 0, inProgress: 0 };
7670
+ return f;
7671
+ }
7672
+ const match = f.tasks[idx];
7673
+ if (!match) {
7674
+ early = { ok: false, message: `No task matched "${target}".`, count: 0, completed: 0, inProgress: 0 };
7675
+ return f;
7676
+ }
7677
+ planifyMeta.title = match.title;
7678
+ planifyMeta.details = match.description ?? "";
7679
+ didPlanify = true;
7680
+ break;
6731
7681
  }
6732
- task.status = input.status;
6733
- task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
6734
- await saveTasks(taskPath, file);
6735
- break;
7682
+ default:
7683
+ early = { ok: false, message: `Unknown action "${input.action}". Use replace | add | status | show | promote | planify.`, count: 0, completed: 0, inProgress: 0 };
7684
+ return f;
6736
7685
  }
6737
- default:
6738
- return { ok: false, message: `Unknown action "${input.action}". Use replace | add | status | show.`, count: 0, completed: 0, inProgress: 0 };
7686
+ return f;
7687
+ });
7688
+ if (early) return early;
7689
+ if (didPlanify) {
7690
+ const { title, details } = planifyMeta;
7691
+ const planPath = ctx.meta["plan.path"];
7692
+ if (typeof planPath === "string" && planPath) {
7693
+ const planCfg = await loadPlan(planPath) ?? emptyPlan(sessionId);
7694
+ const { plan: updated } = addPlanItem(planCfg, title, details || void 0);
7695
+ await savePlan(planPath, updated);
7696
+ return {
7697
+ ok: true,
7698
+ message: `planify ok \u2014 added "${title}" to plan.
7699
+ ${formatPlan(updated)}`,
7700
+ count: file.tasks.length,
7701
+ completed: computeTaskItemProgress(file.tasks).completed,
7702
+ inProgress: computeTaskItemProgress(file.tasks).inProgress
7703
+ };
7704
+ }
7705
+ return { ok: false, message: "Plan storage path not configured \u2014 cannot planify.", count: 0, completed: 0, inProgress: 0 };
6739
7706
  }
6740
7707
  const p = computeTaskItemProgress(file.tasks);
6741
- const summary = file.tasks.length > 0 ? formatTaskList(file.tasks) : "No tasks.";
7708
+ const summary = promoteMeta.count > 0 ? `promote ok \u2014 ${promoteMeta.count} todo(s) created from "${promoteMeta.title}".
7709
+ ${formatTaskList(file.tasks)}` : file.tasks.length > 0 ? formatTaskList(file.tasks) : "No tasks.";
6742
7710
  return {
6743
7711
  ok: true,
6744
7712
  message: summary,
@@ -6896,8 +7864,6 @@ function parseResult(runner, result, duration) {
6896
7864
  truncated: result.truncated
6897
7865
  };
6898
7866
  }
6899
-
6900
- // src/todo.ts
6901
7867
  var todoTool = {
6902
7868
  name: "todo",
6903
7869
  category: "Session",
@@ -6956,6 +7922,48 @@ var todoTool = {
6956
7922
  }
6957
7923
  }
6958
7924
  ctx.state.replaceTodos(items);
7925
+ const completedPlanIds = /* @__PURE__ */ new Set();
7926
+ const completedTaskIds = /* @__PURE__ */ new Set();
7927
+ const pendingPlanIds = /* @__PURE__ */ new Set();
7928
+ const pendingTaskIds = /* @__PURE__ */ new Set();
7929
+ for (const item of items) {
7930
+ if (item.promotedFromPlan) {
7931
+ (item.status === "completed" ? completedPlanIds : pendingPlanIds).add(item.promotedFromPlan);
7932
+ }
7933
+ if (item.promotedFromTask) {
7934
+ (item.status === "completed" ? completedTaskIds : pendingTaskIds).add(item.promotedFromTask);
7935
+ }
7936
+ }
7937
+ for (const planId of completedPlanIds) {
7938
+ if (pendingPlanIds.has(planId)) continue;
7939
+ const planPath = ctx.meta["plan.path"];
7940
+ if (typeof planPath !== "string" || !planPath) continue;
7941
+ try {
7942
+ const plan = await loadPlan(planPath);
7943
+ if (plan) {
7944
+ const updated = setPlanItemStatus(plan, planId, "done");
7945
+ await savePlan(planPath, updated);
7946
+ }
7947
+ } catch {
7948
+ }
7949
+ }
7950
+ for (const taskId of completedTaskIds) {
7951
+ if (pendingTaskIds.has(taskId)) continue;
7952
+ const taskPath = ctx.meta["task.path"];
7953
+ if (typeof taskPath !== "string" || !taskPath) continue;
7954
+ try {
7955
+ const file = await loadTasks(taskPath);
7956
+ if (file) {
7957
+ const task = file.tasks.find((t) => t.id === taskId);
7958
+ if (task && task.status !== "completed") {
7959
+ task.status = "completed";
7960
+ task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
7961
+ await saveTasks(taskPath, file);
7962
+ }
7963
+ }
7964
+ } catch {
7965
+ }
7966
+ }
6959
7967
  return {
6960
7968
  count: items.length,
6961
7969
  in_progress: items.filter((t) => t.status === "in_progress").length
@@ -7370,7 +8378,7 @@ var treeTool = {
7370
8378
  }
7371
8379
  };
7372
8380
  async function walkDir(dir, depth, opts) {
7373
- const entries = await fs13.readdir(dir, { withFileTypes: true }).catch(() => []);
8381
+ const entries = await fs14.readdir(dir, { withFileTypes: true }).catch(() => []);
7374
8382
  const filtered = entries.filter((e) => {
7375
8383
  if (!opts.showHidden && e.name.startsWith(".")) return false;
7376
8384
  if (opts.exclude.has(e.name)) return false;
@@ -7520,14 +8528,14 @@ var writeTool = {
7520
8528
  let existed = false;
7521
8529
  let prev = "";
7522
8530
  try {
7523
- const stat11 = await fs13.stat(absPath);
8531
+ const stat11 = await fs14.stat(absPath);
7524
8532
  existed = stat11.isFile();
7525
8533
  if (existed) {
7526
8534
  if (!ctx.hasRead(absPath)) {
7527
- prev = await fs13.readFile(absPath, "utf8");
8535
+ prev = await fs14.readFile(absPath, "utf8");
7528
8536
  ctx.recordRead(absPath, stat11.mtimeMs);
7529
8537
  } else {
7530
- prev = await fs13.readFile(absPath, "utf8");
8538
+ prev = await fs14.readFile(absPath, "utf8");
7531
8539
  }
7532
8540
  }
7533
8541
  } catch (err) {
@@ -7538,7 +8546,7 @@ var writeTool = {
7538
8546
  await atomicWrite(absPath, input.content);
7539
8547
  const diff = existed ? unifiedDiff(prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
7540
8548
  + (new file, ${input.content.split("\n").length} lines)`;
7541
- const stat10 = await fs13.stat(absPath);
8549
+ const stat10 = await fs14.stat(absPath);
7542
8550
  ctx.recordRead(absPath, stat10.mtimeMs);
7543
8551
  ctx.session.recordFileChange({
7544
8552
  path: absPath,
@@ -7591,7 +8599,8 @@ var builtinTools = [
7591
8599
  toolHelpTool,
7592
8600
  codebaseIndexTool,
7593
8601
  codebaseSearchTool,
7594
- codebaseStatsTool
8602
+ codebaseStatsTool,
8603
+ setWorkingDirTool
7595
8604
  ];
7596
8605
 
7597
8606
  export { builtinTools };