@wrongstack/tools 0.236.0 → 0.255.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/audit.js +591 -48
  2. package/dist/audit.js.map +1 -1
  3. package/dist/background-indexer-CJ5JiV5i.d.ts +365 -0
  4. package/dist/bash.js +135 -20
  5. package/dist/bash.js.map +1 -1
  6. package/dist/builtin.js +1840 -1109
  7. package/dist/builtin.js.map +1 -1
  8. package/dist/codebase-index/index.d.ts +53 -2
  9. package/dist/codebase-index/index.js +870 -364
  10. package/dist/codebase-index/index.js.map +1 -1
  11. package/dist/codebase-index/worker.d.ts +2 -0
  12. package/dist/codebase-index/worker.js +2326 -0
  13. package/dist/codebase-index/worker.js.map +1 -0
  14. package/dist/diff.js +2 -1
  15. package/dist/diff.js.map +1 -1
  16. package/dist/exec.js +116 -5
  17. package/dist/exec.js.map +1 -1
  18. package/dist/format.js +591 -48
  19. package/dist/format.js.map +1 -1
  20. package/dist/git.js +2 -1
  21. package/dist/git.js.map +1 -1
  22. package/dist/grep.js +2 -2
  23. package/dist/grep.js.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +1189 -496
  26. package/dist/index.js.map +1 -1
  27. package/dist/install.js +591 -48
  28. package/dist/install.js.map +1 -1
  29. package/dist/lint.js +590 -47
  30. package/dist/lint.js.map +1 -1
  31. package/dist/logs.js +1 -1
  32. package/dist/logs.js.map +1 -1
  33. package/dist/outdated.js +1 -1
  34. package/dist/outdated.js.map +1 -1
  35. package/dist/pack.js +1840 -1109
  36. package/dist/pack.js.map +1 -1
  37. package/dist/patch.js +1 -1
  38. package/dist/patch.js.map +1 -1
  39. package/dist/replace.js +3 -2
  40. package/dist/replace.js.map +1 -1
  41. package/dist/test.d.ts +1 -0
  42. package/dist/test.js +605 -55
  43. package/dist/test.js.map +1 -1
  44. package/dist/typecheck.js +591 -48
  45. package/dist/typecheck.js.map +1 -1
  46. package/package.json +3 -3
  47. package/dist/background-indexer-CtbgPExj.d.ts +0 -228
package/dist/pack.js CHANGED
@@ -1,13 +1,15 @@
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, recordPackageAction, detectPackageEcosystem, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, setPlanItemStatus, loadTasks, emptyTaskFile, saveTasks, formatTaskList, formatPlan, mutateTasks, loadPlan, emptyPlan, savePlan, computeTaskItemProgress, 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, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
4
4
  import * as fs from 'node:fs';
5
- import { statSync, writeFileSync, mkdirSync } from 'node:fs';
6
- import * as path2 from 'node:path';
5
+ import { statSync, mkdirSync, createWriteStream, writeFileSync } from 'node:fs';
6
+ import * as fs14 from 'node:fs/promises';
7
+ import * as path3 from 'node:path';
7
8
  import { resolve, sep, dirname, join } from 'node:path';
8
- import * as fs13 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';
@@ -15,751 +17,909 @@ import { Agent } from 'undici';
15
17
  import { randomUUID } from 'node:crypto';
16
18
 
17
19
  // src/_spawn-stream.ts
18
- function resolveWin32Command(cmd) {
19
- if (process.platform !== "win32") return cmd;
20
- if (cmd.includes("/") || cmd.includes("\\") || path2.extname(cmd)) {
21
- return cmd;
22
- }
23
- const pathext = (process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.WS;.MSC").toLowerCase().split(";");
24
- const pathDirs = (process.env["PATH"] ?? "").split(path2.delimiter);
25
- for (const dir of pathDirs) {
26
- const base = path2.join(dir, cmd);
27
- for (const ext of pathext) {
28
- const full = `${base}${ext}`;
29
- try {
30
- fs.accessSync(full, fs.constants.X_OK);
31
- return full;
32
- } catch {
20
+ var SPOOL_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
21
+ var SPOOL_WRITE_HWM_BYTES = 4 * 1024 * 1024;
22
+ var sweepStarted = false;
23
+ function toolOutputDir() {
24
+ return path3.join(wstackGlobalRoot(), "tool-output");
25
+ }
26
+ function sweepOldSpoolFiles(dir) {
27
+ if (sweepStarted) return;
28
+ sweepStarted = true;
29
+ void (async () => {
30
+ try {
31
+ const now = Date.now();
32
+ for (const name of await fs14.readdir(dir)) {
33
+ if (!name.endsWith(".log")) continue;
34
+ const p = path3.join(dir, name);
35
+ try {
36
+ const st = await fs14.stat(p);
37
+ if (now - st.mtimeMs > SPOOL_RETENTION_MS) await fs14.unlink(p);
38
+ } catch {
39
+ }
33
40
  }
41
+ } catch {
34
42
  }
35
- }
36
- return cmd;
37
- }
38
-
39
- // src/_spawn-stream.ts
40
- async function* spawnStream(opts) {
41
- const max = opts.maxBytes ?? 2e5;
42
- const flushAt = opts.flushBytes ?? 4 * 1024;
43
- const maxQueue = opts.maxQueueSize ?? 500;
44
- let stdout = "";
45
- let stderr = "";
46
- let pending = "";
47
- let error;
48
- const cmd = resolveWin32Command(opts.cmd);
49
- const needsShell = process.platform === "win32" && (cmd.endsWith(".cmd") || cmd.endsWith(".bat"));
50
- const child = spawn(cmd, opts.args, {
51
- cwd: opts.cwd,
52
- signal: opts.signal,
53
- env: buildChildEnv(),
54
- stdio: ["ignore", "pipe", "pipe"],
55
- ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {}
56
- });
57
- const queue = [];
58
- let waiter;
59
- let paused = false;
60
- const wake = () => {
61
- if (waiter) {
62
- const w = waiter;
63
- waiter = void 0;
64
- w();
43
+ })();
44
+ }
45
+ function spoolNote(info) {
46
+ const dropped = info.droppedBytes > 0 ? `, ~${info.droppedBytes} bytes dropped under backpressure` : "";
47
+ return `
48
+ [output truncated \u2014 full ${info.bytes} bytes at ${info.path}${dropped}; read/grep that file selectively instead of re-running with more output]`;
49
+ }
50
+ function createOutputSpool(opts) {
51
+ const threshold = opts.thresholdBytes ?? 32768;
52
+ const safeTool = opts.tool.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 40) || "tool";
53
+ let head = "";
54
+ let headBytes = 0;
55
+ let totalBytes = 0;
56
+ let droppedBytes = 0;
57
+ let stream = null;
58
+ let filePath = null;
59
+ let failed = false;
60
+ let finalized = false;
61
+ const open = () => {
62
+ if (stream || failed) return;
63
+ try {
64
+ const dir = toolOutputDir();
65
+ mkdirSync(dir, { recursive: true });
66
+ sweepOldSpoolFiles(dir);
67
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
68
+ const rand = Math.random().toString(36).slice(2, 6);
69
+ filePath = path3.join(dir, `${stamp}-${safeTool}-${rand}.log`);
70
+ stream = createWriteStream(filePath, { flags: "w", encoding: "utf8" });
71
+ stream.on("error", () => {
72
+ failed = true;
73
+ stream = null;
74
+ filePath = null;
75
+ });
76
+ stream.write(head);
77
+ } catch {
78
+ failed = true;
79
+ stream = null;
80
+ filePath = null;
65
81
  }
66
82
  };
67
- const resume = () => {
68
- if (paused && queue.length < maxQueue) {
69
- paused = false;
70
- child.stdout?.resume();
71
- child.stderr?.resume();
83
+ return {
84
+ write(text) {
85
+ if (finalized || !text) return;
86
+ totalBytes += Buffer.byteLength(text, "utf8");
87
+ if (!stream && !failed) {
88
+ if (headBytes + text.length <= threshold) {
89
+ head += text;
90
+ headBytes += text.length;
91
+ return;
92
+ }
93
+ head += text;
94
+ open();
95
+ head = "";
96
+ return;
97
+ }
98
+ if (stream) {
99
+ if (stream.writableLength > SPOOL_WRITE_HWM_BYTES) {
100
+ droppedBytes += Buffer.byteLength(text, "utf8");
101
+ return;
102
+ }
103
+ stream.write(text);
104
+ }
105
+ },
106
+ finalize() {
107
+ if (finalized) {
108
+ return filePath ? { path: filePath, bytes: totalBytes, droppedBytes } : null;
109
+ }
110
+ finalized = true;
111
+ head = "";
112
+ if (!stream || !filePath) return null;
113
+ try {
114
+ stream.end();
115
+ } catch {
116
+ }
117
+ return { path: filePath, bytes: totalBytes, droppedBytes };
72
118
  }
73
119
  };
74
- child.stdout?.on("data", (c) => {
75
- const s = c.toString();
76
- if (stdout.length < max) stdout += s;
77
- queue.push({ kind: "out", data: s });
78
- wake();
79
- if (!paused && queue.length >= maxQueue) {
80
- paused = true;
81
- child.stdout?.pause();
82
- child.stderr?.pause();
83
- }
84
- });
85
- child.stderr?.on("data", (c) => {
86
- const s = c.toString();
87
- if (stderr.length < max) stderr += s;
88
- queue.push({ kind: "err", data: s });
89
- wake();
90
- if (!paused && queue.length >= maxQueue) {
91
- paused = true;
92
- child.stdout?.pause();
93
- child.stderr?.pause();
120
+ }
121
+
122
+ // src/circuit-breaker.ts
123
+ var DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
124
+ var DEFAULT_SLOW_CALL_THRESHOLD_MS = 18e4;
125
+ var DEFAULT_MAX_SLOW_CALLS = 3;
126
+ var DEFAULT_WINDOW_MS = 6e4;
127
+ var DEFAULT_MAX_CALLS_PER_WINDOW = 30;
128
+ var DEFAULT_COOLDOWN_MS = 3e4;
129
+ var CircuitBreaker = class {
130
+ maxConsecutiveFailures;
131
+ slowCallThresholdMs;
132
+ maxSlowCalls;
133
+ windowMs;
134
+ maxCallsPerWindow;
135
+ cooldownMs;
136
+ state = "closed";
137
+ consecutiveFailures = 0;
138
+ window = [];
139
+ lastFailureAt = null;
140
+ lastSlowAt = null;
141
+ /** Timestamp when the breaker was opened (for cooldown calculation). */
142
+ openedAt = null;
143
+ constructor(config = {}) {
144
+ this.maxConsecutiveFailures = config.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES;
145
+ this.slowCallThresholdMs = config.slowCallThresholdMs ?? DEFAULT_SLOW_CALL_THRESHOLD_MS;
146
+ this.maxSlowCalls = config.maxSlowCalls ?? DEFAULT_MAX_SLOW_CALLS;
147
+ this.windowMs = config.windowMs ?? DEFAULT_WINDOW_MS;
148
+ this.maxCallsPerWindow = config.maxCallsPerWindow ?? DEFAULT_MAX_CALLS_PER_WINDOW;
149
+ this.cooldownMs = config.cooldownMs ?? DEFAULT_COOLDOWN_MS;
150
+ }
151
+ /**
152
+ * Returns true if the circuit allows a new call to proceed.
153
+ * When false, callers should abort the tool call and return a
154
+ * circuit-breaker error instead of spawning a process.
155
+ */
156
+ get canProceed() {
157
+ this._checkStateTransition();
158
+ return this.state !== "open";
159
+ }
160
+ /**
161
+ * Snapshot of the current breaker state for observability (`/kill`).
162
+ */
163
+ snapshot() {
164
+ this._checkStateTransition();
165
+ const now = Date.now();
166
+ let cooldownRemaining = null;
167
+ if (this.openedAt !== null && this.state === "open") {
168
+ const elapsed = now - this.openedAt;
169
+ cooldownRemaining = Math.max(0, this.cooldownMs - elapsed);
94
170
  }
95
- });
96
- child.on("error", (e) => {
97
- error = e.message;
98
- queue.push({ kind: "error", data: e.message });
99
- wake();
100
- });
101
- child.on("close", (code) => {
102
- queue.push({ kind: "close", data: "", code: code ?? 0 });
103
- wake();
104
- });
105
- let exitCode = 0;
106
- let spawnFailed = false;
107
- for (; ; ) {
108
- while (queue.length === 0) {
109
- await new Promise((resolve7) => {
110
- waiter = resolve7;
111
- });
171
+ return {
172
+ state: this.state,
173
+ consecutiveFailures: this.consecutiveFailures,
174
+ slowCallsInWindow: this.window.filter((c) => c.slow).length,
175
+ callsInWindow: this.window.length,
176
+ windowMs: this.windowMs,
177
+ cooldownRemainingMs: cooldownRemaining,
178
+ lastFailureAt: this.lastFailureAt,
179
+ lastSlowAt: this.lastSlowAt
180
+ };
181
+ }
182
+ /**
183
+ * Call this BEFORE spawning a bash/exec process.
184
+ * Returns true if the call is allowed; false if the breaker is open.
185
+ * When false, callers MUST NOT spawn a process.
186
+ *
187
+ * @param bypass - If true, skip the circuit breaker check entirely.
188
+ * Use for background/fire-and-forget processes that should
189
+ * not affect breaker state.
190
+ */
191
+ beforeCall(bypass = false) {
192
+ if (bypass) return true;
193
+ this._checkStateTransition();
194
+ if (this.state === "open") return false;
195
+ return true;
196
+ }
197
+ /**
198
+ * Call this AFTER a bash/exec process finishes (success or failure).
199
+ * `durationMs` is the wall-clock time the process ran.
200
+ * `failed` is true when the process returned a non-zero exit code or
201
+ * threw an exception before spawning.
202
+ *
203
+ * @param bypass - If true, do not update breaker state.
204
+ * Use for background/fire-and-forget processes.
205
+ */
206
+ afterCall(durationMs, failed, bypass = false) {
207
+ if (bypass) return;
208
+ const now = Date.now();
209
+ if (this.state === "half-open") {
210
+ if (failed) {
211
+ this._trip();
212
+ return;
213
+ }
214
+ this._reset();
215
+ return;
112
216
  }
113
- const chunk = queue.shift();
114
- resume();
115
- if (chunk.kind === "close") {
116
- if (!spawnFailed) exitCode = chunk.code ?? 0;
117
- break;
217
+ this._pruneWindow(now);
218
+ const slow = durationMs >= this.slowCallThresholdMs;
219
+ this.window.push({ at: now, failed, slow });
220
+ if (failed) {
221
+ this.consecutiveFailures++;
222
+ this.lastFailureAt = now;
223
+ if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
224
+ this._trip();
225
+ }
226
+ return;
118
227
  }
119
- if (chunk.kind === "error") {
120
- spawnFailed = true;
121
- exitCode = 1;
122
- continue;
228
+ this.consecutiveFailures = 0;
229
+ if (slow) {
230
+ this.lastSlowAt = now;
231
+ const slowCount = this.window.filter((c) => c.slow).length;
232
+ if (slowCount >= this.maxSlowCalls) {
233
+ this._trip();
234
+ }
123
235
  }
124
- pending += chunk.data;
125
- if (pending.length >= flushAt) {
126
- yield { type: "partial_output", text: pending };
127
- pending = "";
236
+ const callCount = this.window.length;
237
+ if (callCount >= this.maxCallsPerWindow) {
238
+ this._trip();
128
239
  }
129
240
  }
130
- if (pending.length > 0) {
131
- yield { type: "partial_output", text: pending };
241
+ /** Force the breaker open. Used by /kill force and Ctrl+C. */
242
+ forceOpen() {
243
+ this._trip();
132
244
  }
133
- return {
134
- stdout,
135
- stderr,
136
- exitCode,
137
- truncated: stdout.length >= max || stderr.length >= max,
138
- error
139
- };
140
- }
141
- async function detectPackageManager(cwd) {
142
- const { stat: stat10 } = await import('node:fs/promises');
143
- try {
144
- await stat10(`${cwd}/pnpm-lock.yaml`);
145
- return "pnpm";
146
- } catch {
245
+ /** Force a reset to closed. Used by tests and /kill reset. */
246
+ forceReset() {
247
+ this._reset();
147
248
  }
148
- try {
149
- await stat10(`${cwd}/yarn.lock`);
150
- return "yarn";
151
- } catch {
249
+ _trip() {
250
+ if (this.state === "open") return;
251
+ this.state = "open";
252
+ this.openedAt = Date.now();
152
253
  }
153
- return "npm";
154
- }
155
- function resolvePath(input, ctx) {
156
- return path2.isAbsolute(input) ? path2.normalize(input) : path2.resolve(ctx.workingDir ?? ctx.cwd, input);
157
- }
158
- function ensureInsideRoot(absPath, ctx) {
159
- const root = path2.resolve(ctx.projectRoot);
160
- const target = path2.resolve(absPath);
161
- const rel = path2.relative(root, target);
162
- if (rel.startsWith("..") || path2.isAbsolute(rel)) {
163
- throw new Error(`Path "${absPath}" is outside project root "${root}"`);
254
+ _reset() {
255
+ this.state = "closed";
256
+ this.consecutiveFailures = 0;
257
+ this.window = [];
258
+ this.openedAt = null;
164
259
  }
165
- return target;
166
- }
167
- function safeResolve(input, ctx) {
168
- return ensureInsideRoot(resolvePath(input, ctx), ctx);
169
- }
170
- async function assertRealInsideRoot(absPath, ctx) {
171
- const realRoot = await fs13.realpath(ctx.projectRoot).catch(() => path2.resolve(ctx.projectRoot));
172
- let probe = absPath;
173
- for (; ; ) {
174
- let real;
175
- try {
176
- real = await fs13.realpath(probe);
177
- } catch (err) {
178
- if (err.code === "ENOENT") {
179
- const parent = path2.dirname(probe);
180
- if (parent === probe) return;
181
- probe = parent;
182
- continue;
183
- }
184
- throw err;
185
- }
186
- const rel = path2.relative(realRoot, real);
187
- if (rel.startsWith("..") || path2.isAbsolute(rel)) {
188
- throw new Error(
189
- `Path "${absPath}" resolves through a symlink outside project root "${realRoot}"`
190
- );
260
+ /** Transition from open → half-open when cooldown elapses. */
261
+ _checkStateTransition() {
262
+ if (this.state !== "open" || this.openedAt === null) return;
263
+ const elapsed = Date.now() - this.openedAt;
264
+ if (elapsed >= this.cooldownMs) {
265
+ this.state = "half-open";
266
+ this.openedAt = null;
191
267
  }
192
- return;
193
268
  }
194
- }
195
- async function safeResolveReal(input, ctx) {
196
- const abs = safeResolve(input, ctx);
197
- await assertRealInsideRoot(abs, ctx);
198
- return abs;
199
- }
200
- function truncateMiddle(s, max) {
201
- if (Buffer.byteLength(s, "utf8") <= max) return s;
202
- const half = Math.floor(max / 2);
203
- return s.slice(0, half) + `
204
- \u2026[truncated ${Buffer.byteLength(s, "utf8") - max} bytes from middle]\u2026
205
- ` + s.slice(-half);
206
- }
207
- function isBinaryBuffer(buf) {
208
- const len = Math.min(buf.length, 8192);
209
- for (let i = 0; i < len; i++) {
210
- if (buf[i] === 0) return true;
269
+ _pruneWindow(now) {
270
+ const cutoff = now - this.windowMs;
271
+ this.window = this.window.filter((c) => c.at >= cutoff);
211
272
  }
212
- return false;
213
- }
214
- var COMMAND_OUTPUT_MAX_BYTES = 32768;
215
- var REPEAT_RUN_THRESHOLD = 3;
216
- function collapseCarriageReturns(text) {
217
- const lf = text.replace(/\r\n/g, "\n");
218
- if (!lf.includes("\r")) return lf;
219
- return lf.split("\n").map((line) => line.includes("\r") ? line.slice(line.lastIndexOf("\r") + 1) : line).join("\n");
220
- }
221
- function collapseConsecutiveDuplicates(text, minRun = REPEAT_RUN_THRESHOLD) {
222
- const lines = text.split("\n");
223
- const out = [];
224
- let i = 0;
225
- while (i < lines.length) {
226
- let j = i + 1;
227
- while (j < lines.length && lines[j] === lines[i]) j++;
228
- const run = j - i;
229
- if (run >= minRun) {
230
- out.push(lines[i], `\u2026 \u27E8repeated ${run}\xD7\u27E9`);
231
- } else {
232
- for (let k = i; k < j; k++) out.push(lines[k]);
233
- }
234
- i = j;
273
+ };
274
+
275
+ // src/process-registry.ts
276
+ var SENSITIVE_FLAG_PATTERNS = [
277
+ // --flag=value or --flag "value" (value captured up to next space or comma)
278
+ /--(?:token|password|passwd|pwd|secret|api[-_]?key|api[-_]?secret|auth|credential|private[-_]?key|access[-_]?key|github[-_]?token|gh[-_]?token|bearer|jwt|oauth|pin|pincode|passphrase|access[-_]?token)(?:[=\s,][^\s]*)?/gi,
279
+ // -f "value" style short flags
280
+ /(?<!\w)-t(?:\s+|\s*=\s*)[^\s,]+/g,
281
+ /(?<!\w)-p(?:ssword)?(?:\s+|\s*=\s*)[^\s,]+/gi,
282
+ // env var–style secrets: TOKEN=x, API_KEY=y, etc.
283
+ /(?:TOKEN|API_KEY|API_SECRET|AUTH_TOKEN|GITHUB_TOKEN|GH_TOKEN|BEARER|JWT|OAUTH|CREDENTIAL|SECRET|PRIVATE_KEY|PASSWORD|PASSWD)\s*[=:]\s*[^\s,]+/gi,
284
+ // Generic high-entropy look: base64 strings >32 chars or hex strings >32 digits — but only
285
+ // when preceded by a flag name (e.g. --github-token=EyJ...).
286
+ /--\w*(?:token|key|secret|password|passwd|auth|credential)\w*[=\s,][A-Za-z0-9+/=]{32,}/
287
+ ];
288
+ function redactCommand(cmd) {
289
+ let result = cmd;
290
+ for (const pattern of SENSITIVE_FLAG_PATTERNS) {
291
+ result = result.replace(pattern, (match) => {
292
+ const eq = match.indexOf("=");
293
+ const sp = match.search(/\s/);
294
+ const delim = eq !== -1 ? "=" : sp !== -1 ? match[sp] : null;
295
+ if (delim !== null) {
296
+ const flag = match.slice(0, match.indexOf(expectDefined(delim)) + 1);
297
+ return `${flag}[REDACTED]`;
298
+ }
299
+ const flagEnd = match.match(/^--?[a-zA-Z][a-zA-Z0-9_-]*/)?.[0] ?? match;
300
+ return `${flagEnd}=**redacted**`;
301
+ });
235
302
  }
236
- return out.join("\n");
303
+ return result;
237
304
  }
238
- function takeHeadBytes(s, maxBytes) {
239
- if (maxBytes <= 0) return "";
240
- if (Buffer.byteLength(s, "utf8") <= maxBytes) return s;
241
- let lo = 0;
242
- let hi = s.length;
243
- while (lo < hi) {
244
- const mid = Math.ceil((lo + hi) / 2);
245
- if (Buffer.byteLength(s.slice(0, mid), "utf8") <= maxBytes) lo = mid;
246
- else hi = mid - 1;
305
+ var DEFAULT_GRACE_MS = 2e3;
306
+ function killWin32Tree(pid) {
307
+ try {
308
+ spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
309
+ stdio: "ignore",
310
+ windowsHide: true
311
+ }).unref();
312
+ return true;
313
+ } catch {
314
+ return false;
247
315
  }
248
- return s.slice(0, lo);
249
316
  }
250
- function takeTailBytes(s, maxBytes) {
251
- if (maxBytes <= 0) return "";
252
- if (Buffer.byteLength(s, "utf8") <= maxBytes) return s;
253
- let lo = 0;
254
- let hi = s.length;
255
- while (lo < hi) {
256
- const mid = Math.ceil((lo + hi) / 2);
257
- if (Buffer.byteLength(s.slice(s.length - mid), "utf8") <= maxBytes) lo = mid;
258
- else hi = mid - 1;
317
+ var ProcessRegistryImpl = class {
318
+ processes = /* @__PURE__ */ new Map();
319
+ breaker;
320
+ constructor(breakerConfig) {
321
+ this.breaker = new CircuitBreaker(breakerConfig);
259
322
  }
260
- return s.slice(s.length - lo);
261
- }
262
- function truncateHeadTail(s, maxBytes) {
263
- const total = Buffer.byteLength(s, "utf8");
264
- if (total <= maxBytes) return s;
265
- const MARKER_RESERVE = 64;
266
- const avail = Math.max(0, maxBytes - MARKER_RESERVE);
267
- const headBudget = Math.floor(avail * 0.45);
268
- const head = takeHeadBytes(s, headBudget);
269
- const tail = takeTailBytes(s, avail - Buffer.byteLength(head, "utf8"));
270
- const kept = Buffer.byteLength(head, "utf8") + Buffer.byteLength(tail, "utf8");
271
- return `${head}
272
- \u2026[truncated ${total - kept} bytes]\u2026
273
- ${tail}`;
274
- }
275
- function normalizeCommandOutput(raw, opts = {}) {
276
- if (!raw) return raw;
277
- let text = Core.stripAnsi(raw);
278
- text = collapseCarriageReturns(text);
279
- text = text.replace(/[ \t]+$/gm, "");
280
- text = collapseConsecutiveDuplicates(text);
281
- text = text.replace(/\n{3,}/g, "\n\n");
282
- return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);
283
- }
284
-
285
- // src/audit.ts
286
- var auditTool = {
287
- name: "audit",
288
- category: "Package Management",
289
- description: "Run a security audit against project dependencies (using pnpm/npm audit). Reports known vulnerabilities with severity.",
290
- usageHint: "CRITICAL SECURITY TOOL:\n\n- Run regularly and especially before any release.\n- Use `level` to focus on high/critical issues.\n- `fix` can attempt automatic remediation for some vulnerabilities.\nThis is one of the most important tools for supply chain security.",
291
- permission: "confirm",
292
- mutating: false,
293
- timeoutMs: 6e4,
294
- inputSchema: {
295
- type: "object",
296
- properties: {
297
- cwd: { type: "string", description: "Working directory (default: cwd)" },
298
- level: {
299
- type: "string",
300
- enum: ["low", "moderate", "high", "critical"],
301
- description: "Minimum severity level to report"
302
- },
303
- fix: { type: "boolean", description: "Attempt to fix vulnerabilities (default: false)" },
304
- packages: { type: "string", description: "Specific package(s) to audit (comma-separated)" }
305
- }
306
- },
307
- async execute(input, ctx, opts) {
308
- let final;
309
- const executeStream = auditTool.executeStream;
310
- if (!executeStream) throw new Error("auditTool: stream execution unavailable");
311
- for await (const ev of executeStream(input, ctx, opts)) {
312
- if (ev.type === "final") final = ev.output;
313
- }
314
- if (!final) throw new Error("audit: stream ended without final event");
315
- return final;
316
- },
317
- async *executeStream(input, ctx, opts) {
318
- const cwd = input.cwd ? safeResolve(input.cwd, ctx) : ctx.cwd;
319
- const manager = await detectPackageManager(cwd);
320
- yield { type: "log", text: `Auditing with ${manager}\u2026`, data: { manager } };
321
- const args = ["audit", "--json"];
322
- if (input.fix) args.push("--fix");
323
- if (input.packages) {
324
- const pkgs = Array.isArray(input.packages) ? input.packages : input.packages.split(",");
325
- args.push(...pkgs.map((p) => p.trim()));
323
+ register(info) {
324
+ this.processes.set(info.pid, { ...info, killed: false, protected: info.protected ?? false });
325
+ }
326
+ /** Unregister a process by PID. Called on 'close' / 'exit' events. */
327
+ unregister(pid) {
328
+ this.processes.delete(pid);
329
+ }
330
+ /** Get a single process by PID. */
331
+ get(pid) {
332
+ return this.processes.get(pid);
333
+ }
334
+ /** Get all tracked processes. */
335
+ list() {
336
+ return Array.from(this.processes.values());
337
+ }
338
+ /** Get processes filtered by name (e.g. 'bash', 'exec'). */
339
+ byName(name) {
340
+ return this.list().filter((p) => p.name === name);
341
+ }
342
+ /** Get processes filtered by session. */
343
+ bySession(sessionId) {
344
+ return this.list().filter((p) => p.sessionId === sessionId);
345
+ }
346
+ /** Count of active (non-killed) processes. */
347
+ get activeCount() {
348
+ let n = 0;
349
+ for (const p of this.processes.values()) {
350
+ if (!p.killed) n++;
326
351
  }
327
- const result = yield* spawnStream({
328
- cmd: manager,
329
- args,
330
- cwd,
331
- signal: opts.signal,
332
- maxBytes: 1e5
333
- });
334
- yield { type: "final", output: parseAuditOutput(result.stdout, result.exitCode) };
352
+ return n;
335
353
  }
336
- };
337
- function parseAuditOutput(json, exitCode) {
338
- if (!json) {
354
+ /**
355
+ * Combined stats for observability — used by /ps and the TUI status bar.
356
+ */
357
+ stats() {
339
358
  return {
340
- exit_code: exitCode,
341
- vulnerabilities: [],
342
- total: 0,
343
- summary: exitCode === 0 ? "No vulnerabilities found" : "Audit failed",
344
- output: "",
345
- truncated: false
359
+ activeCount: this.activeCount,
360
+ totalCount: this.processes.size,
361
+ breaker: this.breaker.snapshot()
346
362
  };
347
363
  }
348
- try {
349
- const data = JSON.parse(json);
350
- const advisories = [];
351
- const ads = data.advisories ?? {};
352
- for (const id of Object.keys(ads)) {
353
- const adv = ads[id];
354
- advisories.push({
355
- severity: adv.severity ?? "unknown",
356
- package: adv.module_name ?? id,
357
- title: adv.title ?? "Unknown vulnerability",
358
- url: adv.url ?? ""
359
- });
360
- }
361
- const total = advisories.length;
362
- const summary = total === 0 ? "No vulnerabilities found" : `Found ${total} vulnerabilities: ${advisories.filter((a) => a.severity === "critical").length} critical, ${advisories.filter((a) => a.severity === "high").length} high`;
363
- return {
364
- exit_code: exitCode,
365
- vulnerabilities: advisories,
366
- total,
367
- summary,
368
- output: json,
369
- truncated: json.length >= 1e5
370
- };
371
- } catch {
372
- return {
373
- exit_code: exitCode,
374
- vulnerabilities: [],
375
- total: 0,
376
- summary: "Could not parse audit output",
377
- output: json,
378
- truncated: false
379
- };
380
- }
381
- }
382
-
383
- // src/circuit-breaker.ts
384
- var DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
385
- var DEFAULT_SLOW_CALL_THRESHOLD_MS = 18e4;
386
- var DEFAULT_MAX_SLOW_CALLS = 3;
387
- var DEFAULT_WINDOW_MS = 6e4;
388
- var DEFAULT_MAX_CALLS_PER_WINDOW = 30;
389
- var DEFAULT_COOLDOWN_MS = 3e4;
390
- var CircuitBreaker = class {
391
- maxConsecutiveFailures;
392
- slowCallThresholdMs;
393
- maxSlowCalls;
394
- windowMs;
395
- maxCallsPerWindow;
396
- cooldownMs;
397
- state = "closed";
398
- consecutiveFailures = 0;
399
- window = [];
400
- lastFailureAt = null;
401
- lastSlowAt = null;
402
- /** Timestamp when the breaker was opened (for cooldown calculation). */
403
- openedAt = null;
404
- constructor(config = {}) {
405
- this.maxConsecutiveFailures = config.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES;
406
- this.slowCallThresholdMs = config.slowCallThresholdMs ?? DEFAULT_SLOW_CALL_THRESHOLD_MS;
407
- this.maxSlowCalls = config.maxSlowCalls ?? DEFAULT_MAX_SLOW_CALLS;
408
- this.windowMs = config.windowMs ?? DEFAULT_WINDOW_MS;
409
- this.maxCallsPerWindow = config.maxCallsPerWindow ?? DEFAULT_MAX_CALLS_PER_WINDOW;
410
- this.cooldownMs = config.cooldownMs ?? DEFAULT_COOLDOWN_MS;
411
- }
412
364
  /**
413
- * Returns true if the circuit allows a new call to proceed.
414
- * When false, callers should abort the tool call and return a
415
- * circuit-breaker error instead of spawning a process.
365
+ * Returns true if the circuit allows a new bash/exec call to proceed.
366
+ * When false, callers MUST NOT spawn a process.
416
367
  */
417
368
  get canProceed() {
418
- this._checkStateTransition();
419
- return this.state !== "open";
420
- }
421
- /**
422
- * Snapshot of the current breaker state for observability (`/kill`).
423
- */
424
- snapshot() {
425
- this._checkStateTransition();
426
- const now = Date.now();
427
- let cooldownRemaining = null;
428
- if (this.openedAt !== null && this.state === "open") {
429
- const elapsed = now - this.openedAt;
430
- cooldownRemaining = Math.max(0, this.cooldownMs - elapsed);
431
- }
432
- return {
433
- state: this.state,
434
- consecutiveFailures: this.consecutiveFailures,
435
- slowCallsInWindow: this.window.filter((c) => c.slow).length,
436
- callsInWindow: this.window.length,
437
- windowMs: this.windowMs,
438
- cooldownRemainingMs: cooldownRemaining,
439
- lastFailureAt: this.lastFailureAt,
440
- lastSlowAt: this.lastSlowAt
441
- };
369
+ return this.breaker.canProceed;
442
370
  }
443
371
  /**
444
- * Call this BEFORE spawning a bash/exec process.
445
- * Returns true if the call is allowed; false if the breaker is open.
446
- * When false, callers MUST NOT spawn a process.
372
+ * Called before spawning a process. Returns true if allowed; false if
373
+ * the circuit breaker is open.
447
374
  *
448
- * @param bypass - If true, skip the circuit breaker check entirely.
449
- * Use for background/fire-and-forget processes that should
450
- * not affect breaker state.
375
+ * @param bypass - If true, skip circuit breaker check (for background processes).
451
376
  */
452
377
  beforeCall(bypass = false) {
453
- if (bypass) return true;
454
- this._checkStateTransition();
455
- if (this.state === "open") return false;
456
- return true;
378
+ return this.breaker.beforeCall(bypass);
457
379
  }
458
380
  /**
459
- * Call this AFTER a bash/exec process finishes (success or failure).
460
- * `durationMs` is the wall-clock time the process ran.
461
- * `failed` is true when the process returned a non-zero exit code or
462
- * threw an exception before spawning.
381
+ * Called after a process finishes. `durationMs` is wall-clock time;
382
+ * `failed` is true for non-zero exit codes.
463
383
  *
464
- * @param bypass - If true, do not update breaker state.
465
- * Use for background/fire-and-forget processes.
384
+ * @param bypass - If true, do not update circuit breaker state (for background processes).
466
385
  */
467
386
  afterCall(durationMs, failed, bypass = false) {
468
- if (bypass) return;
469
- const now = Date.now();
470
- if (this.state === "half-open") {
471
- if (failed) {
472
- this._trip();
473
- return;
387
+ this.breaker.afterCall(durationMs, failed, bypass);
388
+ }
389
+ /** Force-open the circuit breaker (Ctrl+C, /kill force). */
390
+ forceBreakerOpen() {
391
+ this.breaker.forceOpen();
392
+ }
393
+ /** Force-reset the circuit breaker to closed (/kill reset). */
394
+ forceBreakerReset() {
395
+ this.breaker.forceReset();
396
+ }
397
+ /** Kill a single process by PID.
398
+ *
399
+ * On POSIX: sends SIGTERM to the *process group* (-pid) so that
400
+ * runaway grandchild processes (`sleep 9999 & disown`) are also killed.
401
+ * After `graceMs` a SIGKILL is sent if the process hasn't exited.
402
+ *
403
+ * On Windows: `child.kill()` maps to TerminateProcess — process groups
404
+ * are not meaningfully supported. A second `force=true` call sends
405
+ * SIGKILL (which maps to TerminateProcess again — the distinction is
406
+ * in the exit code, not the signal).
407
+ *
408
+ * Returns true if the process was found and kill was attempted.
409
+ */
410
+ kill(pid, opts = {}) {
411
+ const p = this.processes.get(pid);
412
+ if (!p) return false;
413
+ if (p.killed) return true;
414
+ if (p.protected) return false;
415
+ const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;
416
+ const isWin = os.platform() === "win32";
417
+ if (isWin) {
418
+ const liveRealChild = p.child.exitCode === null && typeof p.child.pid === "number";
419
+ if (liveRealChild && killWin32Tree(pid)) {
420
+ const fallback = setTimeout(() => {
421
+ if (p.child.exitCode === null) {
422
+ try {
423
+ p.child.kill("SIGKILL");
424
+ } catch {
425
+ }
426
+ }
427
+ }, graceMs);
428
+ fallback.unref?.();
429
+ } else {
430
+ try {
431
+ p.child.kill(force ? "SIGKILL" : "SIGTERM");
432
+ } catch {
433
+ }
474
434
  }
475
- this._reset();
476
- return;
435
+ p.killed = true;
436
+ return true;
477
437
  }
478
- this._pruneWindow(now);
479
- const slow = durationMs >= this.slowCallThresholdMs;
480
- this.window.push({ at: now, failed, slow });
481
- if (failed) {
482
- this.consecutiveFailures++;
483
- this.lastFailureAt = now;
484
- if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
485
- this._trip();
438
+ try {
439
+ if (force) {
440
+ try {
441
+ process.kill(-pid, "SIGKILL");
442
+ } catch {
443
+ p.child.kill("SIGKILL");
444
+ }
445
+ } else {
446
+ try {
447
+ process.kill(-pid, "SIGTERM");
448
+ } catch {
449
+ p.child.kill("SIGTERM");
450
+ }
451
+ const timer = setTimeout(() => {
452
+ if (this.processes.has(pid) && !p.child.killed) {
453
+ try {
454
+ process.kill(-pid, "SIGKILL");
455
+ } catch {
456
+ try {
457
+ p.child.kill("SIGKILL");
458
+ } catch {
459
+ }
460
+ }
461
+ }
462
+ }, graceMs);
463
+ timer.unref?.();
486
464
  }
487
- return;
465
+ } catch {
488
466
  }
489
- this.consecutiveFailures = 0;
490
- if (slow) {
491
- this.lastSlowAt = now;
492
- const slowCount = this.window.filter((c) => c.slow).length;
493
- if (slowCount >= this.maxSlowCalls) {
494
- this._trip();
495
- }
467
+ p.killed = true;
468
+ return true;
469
+ }
470
+ /**
471
+ * Kill all tracked processes.
472
+ * Returns the PIDs that were kill()ed.
473
+ */
474
+ killAll(opts = {}) {
475
+ const pids = Array.from(this.processes.keys());
476
+ const killed = [];
477
+ for (const pid of pids) {
478
+ const p = this.processes.get(pid);
479
+ if (p && !p.protected && this.kill(pid, opts)) killed.push(pid);
496
480
  }
497
- const callCount = this.window.length;
498
- if (callCount >= this.maxCallsPerWindow) {
499
- this._trip();
481
+ return killed;
482
+ }
483
+ /**
484
+ * Kill all processes for a specific session.
485
+ * Returns the PIDs that were kill()ed.
486
+ */
487
+ killSession(sessionId, opts = {}) {
488
+ const pids = this.bySession(sessionId).map((p) => p.pid);
489
+ const killed = [];
490
+ for (const pid of pids) {
491
+ if (this.kill(pid, opts)) killed.push(pid);
500
492
  }
493
+ return killed;
501
494
  }
502
- /** Force the breaker open. Used by /kill force and Ctrl+C. */
503
- forceOpen() {
504
- this._trip();
495
+ };
496
+ var _registry;
497
+ function getProcessRegistry() {
498
+ if (!_registry) {
499
+ _registry = new ProcessRegistryImpl();
505
500
  }
506
- /** Force a reset to closed. Used by tests and /kill reset. */
507
- forceReset() {
508
- this._reset();
501
+ return _registry;
502
+ }
503
+ function resolveWin32Command(cmd) {
504
+ if (process.platform !== "win32") return cmd;
505
+ if (cmd.includes("/") || cmd.includes("\\") || path3.extname(cmd)) {
506
+ return cmd;
509
507
  }
510
- _trip() {
511
- if (this.state === "open") return;
512
- this.state = "open";
513
- this.openedAt = Date.now();
514
- }
515
- _reset() {
516
- this.state = "closed";
517
- this.consecutiveFailures = 0;
518
- this.window = [];
519
- this.openedAt = null;
520
- }
521
- /** Transition from open → half-open when cooldown elapses. */
522
- _checkStateTransition() {
523
- if (this.state !== "open" || this.openedAt === null) return;
524
- const elapsed = Date.now() - this.openedAt;
525
- if (elapsed >= this.cooldownMs) {
526
- this.state = "half-open";
527
- this.openedAt = null;
508
+ const pathext = (process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.WS;.MSC").toLowerCase().split(";");
509
+ const pathDirs = (process.env["PATH"] ?? "").split(path3.delimiter);
510
+ for (const dir of pathDirs) {
511
+ const base = path3.join(dir, cmd);
512
+ for (const ext of pathext) {
513
+ const full = `${base}${ext}`;
514
+ try {
515
+ fs.accessSync(full, fs.constants.X_OK);
516
+ return full;
517
+ } catch {
518
+ }
528
519
  }
529
520
  }
530
- _pruneWindow(now) {
531
- const cutoff = now - this.windowMs;
532
- this.window = this.window.filter((c) => c.at >= cutoff);
533
- }
534
- };
521
+ return cmd;
522
+ }
535
523
 
536
- // src/process-registry.ts
537
- var SENSITIVE_FLAG_PATTERNS = [
538
- // --flag=value or --flag "value" (value captured up to next space or comma)
539
- /--(?:token|password|passwd|pwd|secret|api[-_]?key|api[-_]?secret|auth|credential|private[-_]?key|access[-_]?key|github[-_]?token|gh[-_]?token|bearer|jwt|oauth|pin|pincode|passphrase|access[-_]?token)(?:[=\s,][^\s]*)?/gi,
540
- // -f "value" style short flags
541
- /(?<!\w)-t(?:\s+|\s*=\s*)[^\s,]+/g,
542
- /(?<!\w)-p(?:ssword)?(?:\s+|\s*=\s*)[^\s,]+/gi,
543
- // env var–style secrets: TOKEN=x, API_KEY=y, etc.
544
- /(?:TOKEN|API_KEY|API_SECRET|AUTH_TOKEN|GITHUB_TOKEN|GH_TOKEN|BEARER|JWT|OAUTH|CREDENTIAL|SECRET|PRIVATE_KEY|PASSWORD|PASSWD)\s*[=:]\s*[^\s,]+/gi,
545
- // Generic high-entropy look: base64 strings >32 chars or hex strings >32 digits — but only
546
- // when preceded by a flag name (e.g. --github-token=EyJ...).
547
- /--\w*(?:token|key|secret|password|passwd|auth|credential)\w*[=\s,][A-Za-z0-9+/=]{32,}/
548
- ];
549
- function redactCommand(cmd) {
550
- let result = cmd;
551
- for (const pattern of SENSITIVE_FLAG_PATTERNS) {
552
- result = result.replace(pattern, (match) => {
553
- const eq = match.indexOf("=");
554
- const sp = match.search(/\s/);
555
- const delim = eq !== -1 ? "=" : sp !== -1 ? match[sp] : null;
556
- if (delim !== null) {
557
- const flag = match.slice(0, match.indexOf(expectDefined(delim)) + 1);
558
- return `${flag}[REDACTED]`;
559
- }
560
- const flagEnd = match.match(/^--?[a-zA-Z][a-zA-Z0-9_-]*/)?.[0] ?? match;
561
- return `${flagEnd}=**redacted**`;
524
+ // src/_spawn-stream.ts
525
+ async function* spawnStream(opts) {
526
+ const max = opts.maxBytes ?? 2e5;
527
+ const flushAt = opts.flushBytes ?? 4 * 1024;
528
+ const maxQueue = opts.maxQueueSize ?? 500;
529
+ let stdout = "";
530
+ let stderr = "";
531
+ let pending2 = "";
532
+ let error;
533
+ const spool = createOutputSpool({ tool: opts.cmd, thresholdBytes: max });
534
+ const cmd = resolveWin32Command(opts.cmd);
535
+ const isWin = process.platform === "win32";
536
+ const needsShell = isWin && (cmd.endsWith(".cmd") || cmd.endsWith(".bat"));
537
+ const child = spawn(cmd, opts.args, {
538
+ cwd: opts.cwd,
539
+ env: buildChildEnv(),
540
+ stdio: ["ignore", "pipe", "pipe"],
541
+ windowsHide: true,
542
+ ...isWin ? {} : { signal: opts.signal },
543
+ ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {}
544
+ });
545
+ const registry = getProcessRegistry();
546
+ const pid = child.pid;
547
+ if (typeof pid === "number") {
548
+ registry.register({
549
+ pid,
550
+ name: opts.cmd,
551
+ command: redactCommand(`${opts.cmd} ${opts.args.join(" ")}`),
552
+ startedAt: Date.now(),
553
+ child
562
554
  });
563
555
  }
564
- return result;
565
- }
566
- var DEFAULT_GRACE_MS = 2e3;
567
- function killWin32Tree(pid) {
556
+ const queue = [];
557
+ let waiter;
558
+ let paused = false;
559
+ const wake = () => {
560
+ if (waiter) {
561
+ const w = waiter;
562
+ waiter = void 0;
563
+ w();
564
+ }
565
+ };
566
+ const resume = () => {
567
+ if (paused && queue.length < maxQueue) {
568
+ paused = false;
569
+ child.stdout?.resume();
570
+ child.stderr?.resume();
571
+ }
572
+ };
573
+ const onOut = (c) => {
574
+ const s = c.toString();
575
+ if (stdout.length < max) stdout += s;
576
+ spool.write(s);
577
+ queue.push({ kind: "out", data: s });
578
+ wake();
579
+ if (!paused && queue.length >= maxQueue) {
580
+ paused = true;
581
+ child.stdout?.pause();
582
+ child.stderr?.pause();
583
+ }
584
+ };
585
+ const onErr = (c) => {
586
+ const s = c.toString();
587
+ if (stderr.length < max) stderr += s;
588
+ spool.write(s);
589
+ queue.push({ kind: "err", data: s });
590
+ wake();
591
+ if (!paused && queue.length >= maxQueue) {
592
+ paused = true;
593
+ child.stdout?.pause();
594
+ child.stderr?.pause();
595
+ }
596
+ };
597
+ child.stdout?.on("data", onOut);
598
+ child.stderr?.on("data", onErr);
599
+ child.on("error", (e) => {
600
+ error = e.message;
601
+ queue.push({ kind: "error", data: e.message });
602
+ wake();
603
+ });
604
+ child.on("close", (code) => {
605
+ if (typeof pid === "number") registry.unregister(pid);
606
+ queue.push({ kind: "close", data: "", code: code ?? 0 });
607
+ wake();
608
+ });
609
+ const onAbort = () => {
610
+ if (typeof pid === "number") {
611
+ registry.kill(pid, { force: true });
612
+ } else {
613
+ try {
614
+ child.kill("SIGKILL");
615
+ } catch {
616
+ }
617
+ }
618
+ queue.push({ kind: "close", data: "", code: 124 });
619
+ wake();
620
+ };
621
+ if (opts.signal.aborted) onAbort();
622
+ else opts.signal.addEventListener("abort", onAbort, { once: true });
623
+ let exitCode = 0;
624
+ let spawnFailed = false;
568
625
  try {
569
- spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
570
- stdio: "ignore",
571
- windowsHide: true
572
- }).unref();
573
- return true;
574
- } catch {
575
- return false;
626
+ for (; ; ) {
627
+ while (queue.length === 0) {
628
+ await new Promise((resolve7) => {
629
+ waiter = resolve7;
630
+ });
631
+ }
632
+ const chunk = queue.shift();
633
+ resume();
634
+ if (chunk.kind === "close") {
635
+ if (!spawnFailed) exitCode = chunk.code ?? 0;
636
+ break;
637
+ }
638
+ if (chunk.kind === "error") {
639
+ spawnFailed = true;
640
+ exitCode = 1;
641
+ continue;
642
+ }
643
+ pending2 += chunk.data;
644
+ if (pending2.length >= flushAt) {
645
+ yield { type: "partial_output", text: pending2 };
646
+ pending2 = "";
647
+ }
648
+ }
649
+ if (pending2.length > 0) {
650
+ yield { type: "partial_output", text: pending2 };
651
+ }
652
+ const spooled = spool.finalize();
653
+ return {
654
+ // The marker rides on stdout's tail so every consumer's head+tail
655
+ // normalization keeps it without per-tool changes.
656
+ stdout: spooled ? stdout + spoolNote(spooled) : stdout,
657
+ stderr,
658
+ exitCode,
659
+ truncated: stdout.length >= max || stderr.length >= max,
660
+ error,
661
+ spoolPath: spooled?.path,
662
+ spoolBytes: spooled?.bytes
663
+ };
664
+ } finally {
665
+ spool.finalize();
666
+ opts.signal.removeEventListener("abort", onAbort);
667
+ child.stdout?.off("data", onOut);
668
+ child.stderr?.off("data", onErr);
669
+ child.stdout?.destroy();
670
+ child.stderr?.destroy();
671
+ if (child.exitCode === null && !child.killed) {
672
+ if (typeof pid === "number") {
673
+ registry.kill(pid, { force: true });
674
+ } else {
675
+ try {
676
+ child.kill("SIGKILL");
677
+ } catch {
678
+ }
679
+ }
680
+ }
576
681
  }
577
682
  }
578
- var ProcessRegistryImpl = class {
579
- processes = /* @__PURE__ */ new Map();
580
- breaker;
581
- constructor(breakerConfig) {
582
- this.breaker = new CircuitBreaker(breakerConfig);
583
- }
584
- register(info) {
585
- this.processes.set(info.pid, { ...info, killed: false, protected: info.protected ?? false });
586
- }
587
- /** Unregister a process by PID. Called on 'close' / 'exit' events. */
588
- unregister(pid) {
589
- this.processes.delete(pid);
590
- }
591
- /** Get a single process by PID. */
592
- get(pid) {
593
- return this.processes.get(pid);
594
- }
595
- /** Get all tracked processes. */
596
- list() {
597
- return Array.from(this.processes.values());
683
+ async function detectPackageManager(cwd) {
684
+ const { stat: stat11 } = await import('node:fs/promises');
685
+ try {
686
+ await stat11(`${cwd}/pnpm-lock.yaml`);
687
+ return "pnpm";
688
+ } catch {
598
689
  }
599
- /** Get processes filtered by name (e.g. 'bash', 'exec'). */
600
- byName(name) {
601
- return this.list().filter((p) => p.name === name);
690
+ try {
691
+ await stat11(`${cwd}/yarn.lock`);
692
+ return "yarn";
693
+ } catch {
602
694
  }
603
- /** Get processes filtered by session. */
604
- bySession(sessionId) {
605
- return this.list().filter((p) => p.sessionId === sessionId);
695
+ return "npm";
696
+ }
697
+ function resolvePath(input, ctx) {
698
+ return path3.isAbsolute(input) ? path3.normalize(input) : path3.resolve(ctx.workingDir ?? ctx.cwd, input);
699
+ }
700
+ function ensureInsideRoot(absPath, ctx) {
701
+ const root = path3.resolve(ctx.projectRoot);
702
+ const target = path3.resolve(absPath);
703
+ const rel = path3.relative(root, target);
704
+ if (rel.startsWith("..") || path3.isAbsolute(rel)) {
705
+ throw new Error(`Path "${absPath}" is outside project root "${root}"`);
606
706
  }
607
- /** Count of active (non-killed) processes. */
608
- get activeCount() {
609
- let n = 0;
610
- for (const p of this.processes.values()) {
611
- if (!p.killed) n++;
707
+ return target;
708
+ }
709
+ function safeResolve(input, ctx) {
710
+ return ensureInsideRoot(resolvePath(input, ctx), ctx);
711
+ }
712
+ async function assertRealInsideRoot(absPath, ctx) {
713
+ const realRoot = await fs14.realpath(ctx.projectRoot).catch(() => path3.resolve(ctx.projectRoot));
714
+ let probe = absPath;
715
+ for (; ; ) {
716
+ let real;
717
+ try {
718
+ real = await fs14.realpath(probe);
719
+ } catch (err) {
720
+ if (err.code === "ENOENT") {
721
+ const parent = path3.dirname(probe);
722
+ if (parent === probe) return;
723
+ probe = parent;
724
+ continue;
725
+ }
726
+ throw err;
612
727
  }
613
- return n;
728
+ const rel = path3.relative(realRoot, real);
729
+ if (rel.startsWith("..") || path3.isAbsolute(rel)) {
730
+ throw new Error(
731
+ `Path "${absPath}" resolves through a symlink outside project root "${realRoot}"`
732
+ );
733
+ }
734
+ return;
614
735
  }
615
- /**
616
- * Combined stats for observability — used by /ps and the TUI status bar.
617
- */
618
- stats() {
619
- return {
620
- activeCount: this.activeCount,
621
- totalCount: this.processes.size,
622
- breaker: this.breaker.snapshot()
623
- };
736
+ }
737
+ async function safeResolveReal(input, ctx) {
738
+ const abs = safeResolve(input, ctx);
739
+ await assertRealInsideRoot(abs, ctx);
740
+ return abs;
741
+ }
742
+ function truncateMiddle(s, max) {
743
+ if (Buffer.byteLength(s, "utf8") <= max) return s;
744
+ const half = Math.floor(max / 2);
745
+ return s.slice(0, half) + `
746
+ \u2026[truncated ${Buffer.byteLength(s, "utf8") - max} bytes from middle]\u2026
747
+ ` + s.slice(-half);
748
+ }
749
+ function isBinaryBuffer(buf) {
750
+ const len = Math.min(buf.length, 8192);
751
+ for (let i = 0; i < len; i++) {
752
+ if (buf[i] === 0) return true;
624
753
  }
625
- /**
626
- * Returns true if the circuit allows a new bash/exec call to proceed.
627
- * When false, callers MUST NOT spawn a process.
628
- */
629
- get canProceed() {
630
- return this.breaker.canProceed;
631
- }
632
- /**
633
- * Called before spawning a process. Returns true if allowed; false if
634
- * the circuit breaker is open.
635
- *
636
- * @param bypass - If true, skip circuit breaker check (for background processes).
637
- */
638
- beforeCall(bypass = false) {
639
- return this.breaker.beforeCall(bypass);
640
- }
641
- /**
642
- * Called after a process finishes. `durationMs` is wall-clock time;
643
- * `failed` is true for non-zero exit codes.
644
- *
645
- * @param bypass - If true, do not update circuit breaker state (for background processes).
646
- */
647
- afterCall(durationMs, failed, bypass = false) {
648
- this.breaker.afterCall(durationMs, failed, bypass);
754
+ return false;
755
+ }
756
+ var COMMAND_OUTPUT_MAX_BYTES = 32768;
757
+ var REPEAT_RUN_THRESHOLD = 3;
758
+ function collapseCarriageReturns(text) {
759
+ const lf = text.replace(/\r\n/g, "\n");
760
+ if (!lf.includes("\r")) return lf;
761
+ return lf.split("\n").map((line) => line.includes("\r") ? line.slice(line.lastIndexOf("\r") + 1) : line).join("\n");
762
+ }
763
+ function collapseConsecutiveDuplicates(text, minRun = REPEAT_RUN_THRESHOLD) {
764
+ const lines = text.split("\n");
765
+ const out = [];
766
+ let i = 0;
767
+ while (i < lines.length) {
768
+ let j = i + 1;
769
+ while (j < lines.length && lines[j] === lines[i]) j++;
770
+ const run = j - i;
771
+ if (run >= minRun) {
772
+ out.push(lines[i], `\u2026 \u27E8repeated ${run}\xD7\u27E9`);
773
+ } else {
774
+ for (let k = i; k < j; k++) out.push(lines[k]);
775
+ }
776
+ i = j;
649
777
  }
650
- /** Force-open the circuit breaker (Ctrl+C, /kill force). */
651
- forceBreakerOpen() {
652
- this.breaker.forceOpen();
778
+ return out.join("\n");
779
+ }
780
+ function takeHeadBytes(s, maxBytes) {
781
+ if (maxBytes <= 0) return "";
782
+ if (Buffer.byteLength(s, "utf8") <= maxBytes) return s;
783
+ let lo = 0;
784
+ let hi = s.length;
785
+ while (lo < hi) {
786
+ const mid = Math.ceil((lo + hi) / 2);
787
+ if (Buffer.byteLength(s.slice(0, mid), "utf8") <= maxBytes) lo = mid;
788
+ else hi = mid - 1;
653
789
  }
654
- /** Force-reset the circuit breaker to closed (/kill reset). */
655
- forceBreakerReset() {
656
- this.breaker.forceReset();
790
+ return s.slice(0, lo);
791
+ }
792
+ function takeTailBytes(s, maxBytes) {
793
+ if (maxBytes <= 0) return "";
794
+ if (Buffer.byteLength(s, "utf8") <= maxBytes) return s;
795
+ let lo = 0;
796
+ let hi = s.length;
797
+ while (lo < hi) {
798
+ const mid = Math.ceil((lo + hi) / 2);
799
+ if (Buffer.byteLength(s.slice(s.length - mid), "utf8") <= maxBytes) lo = mid;
800
+ else hi = mid - 1;
657
801
  }
658
- /** Kill a single process by PID.
659
- *
660
- * On POSIX: sends SIGTERM to the *process group* (-pid) so that
661
- * runaway grandchild processes (`sleep 9999 & disown`) are also killed.
662
- * After `graceMs` a SIGKILL is sent if the process hasn't exited.
663
- *
664
- * On Windows: `child.kill()` maps to TerminateProcess — process groups
665
- * are not meaningfully supported. A second `force=true` call sends
666
- * SIGKILL (which maps to TerminateProcess again — the distinction is
667
- * in the exit code, not the signal).
668
- *
669
- * Returns true if the process was found and kill was attempted.
670
- */
671
- kill(pid, opts = {}) {
672
- const p = this.processes.get(pid);
673
- if (!p) return false;
674
- if (p.killed) return true;
675
- if (p.protected) return false;
676
- const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;
677
- const isWin = os.platform() === "win32";
678
- if (isWin) {
679
- const liveRealChild = p.child.exitCode === null && typeof p.child.pid === "number";
680
- if (liveRealChild && killWin32Tree(pid)) {
681
- const fallback = setTimeout(() => {
682
- if (p.child.exitCode === null) {
683
- try {
684
- p.child.kill("SIGKILL");
685
- } catch {
686
- }
687
- }
688
- }, graceMs);
689
- fallback.unref?.();
690
- } else {
691
- try {
692
- p.child.kill(force ? "SIGKILL" : "SIGTERM");
693
- } catch {
694
- }
695
- }
696
- p.killed = true;
697
- return true;
698
- }
699
- try {
700
- if (force) {
701
- try {
702
- process.kill(-pid, "SIGKILL");
703
- } catch {
704
- p.child.kill("SIGKILL");
705
- }
706
- } else {
707
- try {
708
- process.kill(-pid, "SIGTERM");
709
- } catch {
710
- p.child.kill("SIGTERM");
711
- }
712
- const timer = setTimeout(() => {
713
- if (this.processes.has(pid) && !p.child.killed) {
714
- try {
715
- process.kill(-pid, "SIGKILL");
716
- } catch {
717
- try {
718
- p.child.kill("SIGKILL");
719
- } catch {
720
- }
721
- }
722
- }
723
- }, graceMs);
724
- timer.unref?.();
725
- }
726
- } catch {
802
+ return s.slice(s.length - lo);
803
+ }
804
+ function truncateHeadTail(s, maxBytes) {
805
+ const total = Buffer.byteLength(s, "utf8");
806
+ if (total <= maxBytes) return s;
807
+ const MARKER_RESERVE = 64;
808
+ const avail = Math.max(0, maxBytes - MARKER_RESERVE);
809
+ const headBudget = Math.floor(avail * 0.45);
810
+ const head = takeHeadBytes(s, headBudget);
811
+ const tail = takeTailBytes(s, avail - Buffer.byteLength(head, "utf8"));
812
+ const kept = Buffer.byteLength(head, "utf8") + Buffer.byteLength(tail, "utf8");
813
+ return `${head}
814
+ \u2026[truncated ${total - kept} bytes]\u2026
815
+ ${tail}`;
816
+ }
817
+ function normalizeCommandOutput(raw, opts = {}) {
818
+ if (!raw) return raw;
819
+ let text = Core.stripAnsi(raw);
820
+ text = collapseCarriageReturns(text);
821
+ text = text.replace(/[ \t]+$/gm, "");
822
+ text = collapseConsecutiveDuplicates(text);
823
+ text = text.replace(/\n{3,}/g, "\n\n");
824
+ return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);
825
+ }
826
+
827
+ // src/audit.ts
828
+ var auditTool = {
829
+ name: "audit",
830
+ category: "Package Management",
831
+ description: "Run a security audit against project dependencies (using pnpm/npm audit). Reports known vulnerabilities with severity.",
832
+ usageHint: "CRITICAL SECURITY TOOL:\n\n- Run regularly and especially before any release.\n- Use `level` to focus on high/critical issues.\n- `fix` can attempt automatic remediation for some vulnerabilities.\nThis is one of the most important tools for supply chain security.",
833
+ permission: "confirm",
834
+ mutating: false,
835
+ timeoutMs: 6e4,
836
+ inputSchema: {
837
+ type: "object",
838
+ properties: {
839
+ cwd: { type: "string", description: "Working directory (default: cwd)" },
840
+ level: {
841
+ type: "string",
842
+ enum: ["low", "moderate", "high", "critical"],
843
+ description: "Minimum severity level to report"
844
+ },
845
+ fix: { type: "boolean", description: "Attempt to fix vulnerabilities (default: false)" },
846
+ packages: { type: "string", description: "Specific package(s) to audit (comma-separated)" }
727
847
  }
728
- p.killed = true;
729
- return true;
730
- }
731
- /**
732
- * Kill all tracked processes.
733
- * Returns the PIDs that were kill()ed.
734
- */
735
- killAll(opts = {}) {
736
- const pids = Array.from(this.processes.keys());
737
- const killed = [];
738
- for (const pid of pids) {
739
- const p = this.processes.get(pid);
740
- if (p && !p.protected && this.kill(pid, opts)) killed.push(pid);
848
+ },
849
+ async execute(input, ctx, opts) {
850
+ let final;
851
+ const executeStream = auditTool.executeStream;
852
+ if (!executeStream) throw new Error("auditTool: stream execution unavailable");
853
+ for await (const ev of executeStream(input, ctx, opts)) {
854
+ if (ev.type === "final") final = ev.output;
741
855
  }
742
- return killed;
743
- }
744
- /**
745
- * Kill all processes for a specific session.
746
- * Returns the PIDs that were kill()ed.
747
- */
748
- killSession(sessionId, opts = {}) {
749
- const pids = this.bySession(sessionId).map((p) => p.pid);
750
- const killed = [];
751
- for (const pid of pids) {
752
- if (this.kill(pid, opts)) killed.push(pid);
856
+ if (!final) throw new Error("audit: stream ended without final event");
857
+ return final;
858
+ },
859
+ async *executeStream(input, ctx, opts) {
860
+ const cwd = input.cwd ? safeResolve(input.cwd, ctx) : ctx.cwd;
861
+ const manager = await detectPackageManager(cwd);
862
+ yield { type: "log", text: `Auditing with ${manager}\u2026`, data: { manager } };
863
+ const args = ["audit", "--json"];
864
+ if (input.fix) args.push("--fix");
865
+ if (input.packages) {
866
+ const pkgs = Array.isArray(input.packages) ? input.packages : input.packages.split(",");
867
+ args.push(...pkgs.map((p) => p.trim()));
753
868
  }
754
- return killed;
869
+ const result = yield* spawnStream({
870
+ cmd: manager,
871
+ args,
872
+ cwd,
873
+ signal: opts.signal,
874
+ maxBytes: 1e5
875
+ });
876
+ yield { type: "final", output: parseAuditOutput(result.stdout, result.exitCode) };
755
877
  }
756
878
  };
757
- var _registry;
758
- function getProcessRegistry() {
759
- if (!_registry) {
760
- _registry = new ProcessRegistryImpl();
879
+ function parseAuditOutput(json, exitCode) {
880
+ if (!json) {
881
+ return {
882
+ exit_code: exitCode,
883
+ vulnerabilities: [],
884
+ total: 0,
885
+ summary: exitCode === 0 ? "No vulnerabilities found" : "Audit failed",
886
+ output: "",
887
+ truncated: false
888
+ };
889
+ }
890
+ try {
891
+ const data = JSON.parse(json);
892
+ const advisories = [];
893
+ const ads = data.advisories ?? {};
894
+ for (const id of Object.keys(ads)) {
895
+ const adv = ads[id];
896
+ advisories.push({
897
+ severity: adv.severity ?? "unknown",
898
+ package: adv.module_name ?? id,
899
+ title: adv.title ?? "Unknown vulnerability",
900
+ url: adv.url ?? ""
901
+ });
902
+ }
903
+ const total = advisories.length;
904
+ const summary = total === 0 ? "No vulnerabilities found" : `Found ${total} vulnerabilities: ${advisories.filter((a) => a.severity === "critical").length} critical, ${advisories.filter((a) => a.severity === "high").length} high`;
905
+ return {
906
+ exit_code: exitCode,
907
+ vulnerabilities: advisories,
908
+ total,
909
+ summary,
910
+ output: json,
911
+ truncated: json.length >= 1e5
912
+ };
913
+ } catch {
914
+ return {
915
+ exit_code: exitCode,
916
+ vulnerabilities: [],
917
+ total: 0,
918
+ summary: "Could not parse audit output",
919
+ output: json,
920
+ truncated: false
921
+ };
761
922
  }
762
- return _registry;
763
923
  }
764
924
 
765
925
  // src/bash.ts
@@ -855,7 +1015,7 @@ var bashTool = {
855
1015
  })();
856
1016
  const args = isWin ? ["/c", input.command] : ["-c", input.command];
857
1017
  const env = buildChildEnv(ctx.session?.id);
858
- const detached = isWin ? !!input.background : true;
1018
+ const detached = !isWin;
859
1019
  const startedAt = Date.now();
860
1020
  if (input.background) {
861
1021
  let buf2 = "";
@@ -864,7 +1024,15 @@ var bashTool = {
864
1024
  cwd: ctx.projectRoot,
865
1025
  env,
866
1026
  stdio: ["ignore", "pipe", "pipe"],
867
- detached: true,
1027
+ // win32: CreateProcess IGNORES CREATE_NO_WINDOW (windowsHide) when
1028
+ // DETACHED_PROCESS (detached: true) is set, so the console-less
1029
+ // cmd.exe's grandchildren (node, dev servers) each allocate a fresh
1030
+ // VISIBLE console window. detached: false lets CREATE_NO_WINDOW
1031
+ // apply: the child gets a hidden console that grandchildren inherit.
1032
+ // Windows children survive parent exit either way. POSIX keeps
1033
+ // detached for the process-group kill semantics.
1034
+ detached: !isWin,
1035
+ windowsHide: true,
868
1036
  signal: opts.signal
869
1037
  });
870
1038
  const pid2 = child2.pid;
@@ -879,24 +1047,22 @@ var bashTool = {
879
1047
  });
880
1048
  child2.on("close", () => registry.unregister(pid2));
881
1049
  }
882
- child2.stdout?.on("data", (chunk) => {
883
- if (!truncated) {
884
- const remain = MAX_OUTPUT - buf2.length;
885
- if (remain > 0) {
886
- buf2 += chunk.toString().slice(0, remain);
887
- }
888
- if (buf2.length >= MAX_OUTPUT) truncated = true;
1050
+ const onBgData = (chunk) => {
1051
+ if (truncated) return;
1052
+ const remain = MAX_OUTPUT - buf2.length;
1053
+ if (remain > 0) {
1054
+ buf2 += chunk.toString().slice(0, remain);
889
1055
  }
890
- });
891
- child2.stderr?.on("data", (chunk) => {
892
- if (!truncated) {
893
- const remain = MAX_OUTPUT - buf2.length;
894
- if (remain > 0) {
895
- buf2 += chunk.toString().slice(0, remain);
896
- }
897
- if (buf2.length >= MAX_OUTPUT) truncated = true;
1056
+ if (buf2.length >= MAX_OUTPUT) {
1057
+ truncated = true;
1058
+ child2.stdout?.off("data", onBgData);
1059
+ child2.stderr?.off("data", onBgData);
898
1060
  }
899
- });
1061
+ };
1062
+ child2.stdout?.on("data", onBgData);
1063
+ child2.stderr?.on("data", onBgData);
1064
+ child2.stdout?.unref?.();
1065
+ child2.stderr?.unref?.();
900
1066
  child2.on("close", () => {
901
1067
  registry.afterCall(Date.now() - startedAt, false, bypassBreaker);
902
1068
  });
@@ -917,6 +1083,7 @@ var bashTool = {
917
1083
  env,
918
1084
  stdio: ["ignore", "pipe", "pipe"],
919
1085
  detached,
1086
+ windowsHide: true,
920
1087
  ...isWin ? {} : { signal: opts.signal }
921
1088
  });
922
1089
  const pid = child.pid;
@@ -931,9 +1098,10 @@ var bashTool = {
931
1098
  });
932
1099
  }
933
1100
  let buf = "";
934
- let pending = "";
1101
+ let pending2 = "";
935
1102
  let timedOut = false;
936
1103
  const timers = [];
1104
+ const spool = createOutputSpool({ tool: "bash", thresholdBytes: MAX_OUTPUT });
937
1105
  function killWithTimeout(child2, timeoutMs2) {
938
1106
  if (isWin) {
939
1107
  if (typeof child2.pid === "number" && child2.exitCode === null && killWin32Tree(child2.pid)) {
@@ -1013,9 +1181,9 @@ var bashTool = {
1013
1181
  });
1014
1182
  let lastFlush = Date.now();
1015
1183
  const flush = () => {
1016
- if (pending.length === 0) return null;
1017
- const text = pending;
1018
- pending = "";
1184
+ if (pending2.length === 0) return null;
1185
+ const text = pending2;
1186
+ pending2 = "";
1019
1187
  lastFlush = Date.now();
1020
1188
  return text;
1021
1189
  };
@@ -1039,7 +1207,8 @@ var bashTool = {
1039
1207
  if (buf.length < MAX_OUTPUT) {
1040
1208
  buf += text.slice(0, MAX_OUTPUT - buf.length);
1041
1209
  }
1042
- pending += text;
1210
+ spool.write(text);
1211
+ pending2 += text;
1043
1212
  push({ kind: "data", text });
1044
1213
  pauseIfFlooded();
1045
1214
  };
@@ -1066,10 +1235,11 @@ var bashTool = {
1066
1235
  if (remainder !== null) {
1067
1236
  yield { type: "partial_output", text: remainder };
1068
1237
  }
1238
+ const spooled = spool.finalize();
1069
1239
  yield {
1070
1240
  type: "final",
1071
1241
  output: {
1072
- output: normalizeCommandOutput(buf),
1242
+ output: normalizeCommandOutput(buf) + (spooled ? spoolNote(spooled) : ""),
1073
1243
  exit_code: c.code,
1074
1244
  timed_out: timedOut
1075
1245
  }
@@ -1077,13 +1247,14 @@ var bashTool = {
1077
1247
  return;
1078
1248
  }
1079
1249
  const now = Date.now();
1080
- if (pending.length >= STREAM_FLUSH_BYTES || now - lastFlush >= STREAM_FLUSH_INTERVAL_MS) {
1250
+ if (pending2.length >= STREAM_FLUSH_BYTES || now - lastFlush >= STREAM_FLUSH_INTERVAL_MS) {
1081
1251
  const text = flush();
1082
1252
  if (text) yield { type: "partial_output", text };
1083
1253
  }
1084
1254
  }
1085
1255
  } finally {
1086
1256
  for (const t of timers) clearTimeout(t);
1257
+ spool.finalize();
1087
1258
  if (isWin) opts.signal.removeEventListener("abort", onAbort);
1088
1259
  child.stdout?.off("data", onData);
1089
1260
  child.stderr?.off("data", onData);
@@ -1201,8 +1372,88 @@ async function executeSingle(call, ctx, opts) {
1201
1372
  }
1202
1373
  }
1203
1374
 
1375
+ // src/codebase-index/circuit-breaker.ts
1376
+ var CircuitOpenError = class extends Error {
1377
+ name = "CircuitOpenError";
1378
+ };
1379
+ var IndexTimeoutError = class extends Error {
1380
+ name = "IndexTimeoutError";
1381
+ };
1382
+ var LockError = class extends Error {
1383
+ name = "LockError";
1384
+ };
1385
+ var IndexCircuitBreaker = class {
1386
+ failureThreshold;
1387
+ cooldownMs;
1388
+ now;
1389
+ state = "closed";
1390
+ consecutiveFailures = 0;
1391
+ openedAt = 0;
1392
+ lastFailure = null;
1393
+ probeInFlight = false;
1394
+ constructor(opts = {}) {
1395
+ this.failureThreshold = opts.failureThreshold ?? 3;
1396
+ this.cooldownMs = opts.cooldownMs ?? 6e4;
1397
+ this.now = opts.now ?? Date.now;
1398
+ }
1399
+ /**
1400
+ * True when a run may proceed. An open circuit transitions to half-open once
1401
+ * the cooldown has elapsed, admitting exactly one probe; further requests
1402
+ * are rejected until that probe settles via recordSuccess/recordFailure.
1403
+ */
1404
+ allowRequest() {
1405
+ if (this.state === "closed") return true;
1406
+ if (this.state === "open") {
1407
+ if (this.now() - this.openedAt < this.cooldownMs) return false;
1408
+ this.state = "half-open";
1409
+ this.probeInFlight = true;
1410
+ return true;
1411
+ }
1412
+ if (this.probeInFlight) return false;
1413
+ this.probeInFlight = true;
1414
+ return true;
1415
+ }
1416
+ recordSuccess() {
1417
+ this.state = "closed";
1418
+ this.consecutiveFailures = 0;
1419
+ this.lastFailure = null;
1420
+ this.probeInFlight = false;
1421
+ }
1422
+ recordFailure(err) {
1423
+ if (err instanceof LockError) {
1424
+ this.lastFailure = `[transient/lock] ${err.message}`;
1425
+ this.probeInFlight = false;
1426
+ return;
1427
+ }
1428
+ this.lastFailure = err instanceof Error ? err.message : String(err);
1429
+ this.probeInFlight = false;
1430
+ this.consecutiveFailures++;
1431
+ if (this.state === "half-open" || this.consecutiveFailures >= this.failureThreshold) {
1432
+ this.state = "open";
1433
+ this.openedAt = this.now();
1434
+ }
1435
+ }
1436
+ /** Force-close the circuit (manual recovery: `/codebase-reindex`). */
1437
+ reset() {
1438
+ this.state = "closed";
1439
+ this.consecutiveFailures = 0;
1440
+ this.lastFailure = null;
1441
+ this.probeInFlight = false;
1442
+ this.openedAt = 0;
1443
+ }
1444
+ snapshot() {
1445
+ return {
1446
+ state: this.state,
1447
+ consecutiveFailures: this.consecutiveFailures,
1448
+ lastFailure: this.lastFailure,
1449
+ cooldownRemainingMs: this.state === "open" ? Math.max(0, this.cooldownMs - (this.now() - this.openedAt)) : 0
1450
+ };
1451
+ }
1452
+ };
1453
+ var indexCircuitBreaker = new IndexCircuitBreaker();
1454
+
1204
1455
  // src/codebase-index/schema.ts
1205
- var SCHEMA_VERSION = 1;
1456
+ var SCHEMA_VERSION = 2;
1206
1457
 
1207
1458
  // src/codebase-index/lsp-kind.ts
1208
1459
  function lspKindToInternalKind(k) {
@@ -1237,6 +1488,94 @@ function lspKindToInternalKind(k) {
1237
1488
  }
1238
1489
  }
1239
1490
 
1491
+ // src/codebase-index/bm25.ts
1492
+ var K1 = 1.5;
1493
+ var B = 0.75;
1494
+ function tokenise(text) {
1495
+ const sanitised = text.replace(/[^\p{L}\p{N}$'_]/gu, " ").replace(/_/g, " ");
1496
+ return sanitised.toLowerCase().split(" ").filter(Boolean);
1497
+ }
1498
+ function splitName(name) {
1499
+ return name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
1500
+ }
1501
+ function buildIndexableText(name, signature, docComment) {
1502
+ return [splitName(name), name, signature, docComment].filter(Boolean).join(" ");
1503
+ }
1504
+ function buildBm25Index(docs) {
1505
+ const documents = docs.map((d) => {
1506
+ const tokens = tokenise(d.text);
1507
+ return { id: d.id, tokens, raw: d.text, len: tokens.length };
1508
+ });
1509
+ const df = {};
1510
+ for (const doc of documents) {
1511
+ const seen = /* @__PURE__ */ new Set();
1512
+ for (const t of doc.tokens) {
1513
+ if (!seen.has(t)) {
1514
+ df[t] = (df[t] ?? 0) + 1;
1515
+ seen.add(t);
1516
+ }
1517
+ }
1518
+ }
1519
+ const N = documents.length;
1520
+ const totalLen = documents.reduce((sum, d) => sum + d.len, 0);
1521
+ const avgLen = N === 0 ? 0 : totalLen / N;
1522
+ return new Bm25Index(documents, df, N, avgLen);
1523
+ }
1524
+ var Bm25Index = class {
1525
+ constructor(documents, df, N, avgLen) {
1526
+ this.documents = documents;
1527
+ this.df = df;
1528
+ this.N = N;
1529
+ this.safeAvgLen = avgLen === 0 ? 1 : avgLen;
1530
+ }
1531
+ documents;
1532
+ df;
1533
+ N;
1534
+ safeAvgLen;
1535
+ score(query2, filter) {
1536
+ const qTokens = tokenise(query2);
1537
+ if (qTokens.length === 0) return [];
1538
+ const results = [];
1539
+ for (const doc of this.documents) {
1540
+ if (filter && !filter(doc.id)) continue;
1541
+ let docScore = 0;
1542
+ for (const qTerm of qTokens) {
1543
+ let tf = 0;
1544
+ for (const t of doc.tokens) {
1545
+ if (t === qTerm) tf++;
1546
+ }
1547
+ if (tf === 0) continue;
1548
+ const dfVal = this.df[qTerm] ?? 0;
1549
+ if (dfVal === 0) continue;
1550
+ const idf = Math.log((this.N - dfVal + 0.5) / (dfVal + 0.5) + 1);
1551
+ const lenRatio = B * (doc.len / this.safeAvgLen);
1552
+ const tfComponent = tf * (K1 + 1) / (tf + K1 * (1 - B + lenRatio));
1553
+ docScore += idf * tfComponent;
1554
+ }
1555
+ if (docScore > 0) results.push({ id: doc.id, score: docScore });
1556
+ }
1557
+ return results;
1558
+ }
1559
+ getDoc(id) {
1560
+ return this.documents.find((d) => d.id === id);
1561
+ }
1562
+ extractSnippet(docId, queryTokens, radius = 40) {
1563
+ const doc = this.getDoc(docId);
1564
+ if (!doc) return "";
1565
+ for (const tok of queryTokens) {
1566
+ const idx = doc.raw.toLowerCase().indexOf(tok);
1567
+ if (idx !== -1) {
1568
+ const start = Math.max(0, idx - radius);
1569
+ const end = Math.min(doc.raw.length, idx + tok.length + radius);
1570
+ const excerpt = doc.raw.slice(start, end);
1571
+ const ellipsis = "\u2026";
1572
+ return (start > 0 ? ellipsis : "") + excerpt + (end < doc.raw.length ? ellipsis : "");
1573
+ }
1574
+ }
1575
+ return doc.raw.slice(0, radius * 2) + (doc.raw.length > radius * 2 ? "\u2026" : "");
1576
+ }
1577
+ };
1578
+
1240
1579
  // src/codebase-index/writer.ts
1241
1580
  var DB_FILE = "index.db";
1242
1581
  function resolveIndexDir(projectRoot, override) {
@@ -1272,15 +1611,79 @@ function loadDatabaseSync() {
1272
1611
  }
1273
1612
  return DatabaseSyncCtor;
1274
1613
  }
1614
+ var MAX_LOCK_RETRIES = 3;
1615
+ var LOCK_RETRY_BASE_DELAY_MS = 50;
1616
+ var LOCK_RETRY_MAX_DELAY_MS = 500;
1617
+ function isLockError(err) {
1618
+ if (!(err instanceof Error)) return false;
1619
+ const e = err;
1620
+ const code = e.code ?? e.sqliteCode;
1621
+ if (typeof code === "string" && /SQLITE_(BUSY|LOCKED)/.test(code)) return true;
1622
+ if (typeof code === "number" && (code === 5 || code === 6)) return true;
1623
+ if (/SQLITE_(BUSY|LOCKED)/.test(err.message)) return true;
1624
+ return false;
1625
+ }
1626
+ function sleepSync(ms) {
1627
+ try {
1628
+ const sab = new SharedArrayBuffer(4);
1629
+ const view = new Int32Array(sab);
1630
+ Atomics.wait(view, 0, 0, ms);
1631
+ } catch {
1632
+ }
1633
+ }
1275
1634
  var IndexStore = class {
1276
1635
  db;
1277
1636
  /** Absolute path to this project's index directory. */
1278
1637
  indexDir;
1638
+ /**
1639
+ * True when the SQLite build provides FTS5 (Node's bundled SQLite does).
1640
+ * When false, ranked search falls back to the LIKE + in-process BM25 path.
1641
+ */
1642
+ ftsAvailable = false;
1643
+ /**
1644
+ * Execute a SQLite write operation with automatic retry on lock conflicts.
1645
+ *
1646
+ * When another wstack process is holding the write lock the statement first
1647
+ * waits up to `busy_timeout` ms, then throws SQLITE_BUSY. This wrapper catches
1648
+ * that error and retries (up to MAX_LOCK_RETRIES) with exponential backoff,
1649
+ * giving the competing writer time to finish and release the lock.
1650
+ *
1651
+ * @param fn The write operation to execute. Can return a value which is
1652
+ * returned to the caller on success.
1653
+ * @throws {@link LockError} when all retries are exhausted on a lock conflict
1654
+ * (non-lock errors always propagate on the first attempt).
1655
+ */
1656
+ runWithRetry(fn) {
1657
+ let lastError;
1658
+ for (let attempt = 0; attempt <= MAX_LOCK_RETRIES; attempt++) {
1659
+ try {
1660
+ return fn();
1661
+ } catch (err) {
1662
+ lastError = err;
1663
+ if (!isLockError(err)) throw err;
1664
+ if (attempt === MAX_LOCK_RETRIES) {
1665
+ const msg = lastError instanceof Error ? lastError.message : String(lastError);
1666
+ throw new LockError(`SQLite lock conflict after ${MAX_LOCK_RETRIES} retries: ${msg}`);
1667
+ }
1668
+ const delay = Math.min(
1669
+ LOCK_RETRY_BASE_DELAY_MS * Math.pow(2, attempt),
1670
+ LOCK_RETRY_MAX_DELAY_MS
1671
+ );
1672
+ sleepSync(delay);
1673
+ }
1674
+ }
1675
+ throw lastError;
1676
+ }
1279
1677
  constructor(projectRoot, opts = {}) {
1280
1678
  this.indexDir = resolveIndexDir(projectRoot, opts.indexDir);
1281
1679
  fs.mkdirSync(this.indexDir, { recursive: true });
1282
1680
  const Database = loadDatabaseSync();
1283
- this.db = new Database(path2.join(this.indexDir, DB_FILE));
1681
+ this.db = new Database(path3.join(this.indexDir, DB_FILE));
1682
+ try {
1683
+ this.db.exec("PRAGMA journal_mode = WAL");
1684
+ this.db.exec("PRAGMA busy_timeout = 5000");
1685
+ } catch {
1686
+ }
1284
1687
  this.initSchema();
1285
1688
  }
1286
1689
  initSchema() {
@@ -1289,6 +1692,21 @@ var IndexStore = class {
1289
1692
  key TEXT PRIMARY KEY,
1290
1693
  value TEXT NOT NULL
1291
1694
  );
1695
+ `);
1696
+ const storedRows = this.db.prepare("SELECT value FROM metadata WHERE key = ?").all("version");
1697
+ const storedVersion = storedRows.length ? Number(storedRows[0]?.value) : null;
1698
+ if (storedVersion !== null && storedVersion !== SCHEMA_VERSION) {
1699
+ this.db.exec(`
1700
+ DROP TABLE IF EXISTS symbols;
1701
+ DROP TABLE IF EXISTS files;
1702
+ DROP TABLE IF EXISTS refs;
1703
+ `);
1704
+ this.db.exec("DROP TABLE IF EXISTS symbols_fts");
1705
+ this.db.prepare("UPDATE metadata SET value = ? WHERE key = ?").run(String(SCHEMA_VERSION), "version");
1706
+ } else if (storedVersion === null) {
1707
+ this.db.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)").run("version", String(SCHEMA_VERSION));
1708
+ }
1709
+ this.db.exec(`
1292
1710
  CREATE TABLE IF NOT EXISTS files (
1293
1711
  file TEXT PRIMARY KEY,
1294
1712
  lang TEXT NOT NULL,
@@ -1329,53 +1747,76 @@ var IndexStore = class {
1329
1747
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_r_to_id ON refs(to_id)");
1330
1748
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_r_to_name ON refs(to_name)");
1331
1749
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_r_call_type ON refs(call_type)");
1332
- const versionRows = this.db.prepare("SELECT value FROM metadata WHERE key = ?").all("version");
1333
- if (!versionRows.length) {
1334
- this.db.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)").run("version", String(SCHEMA_VERSION));
1750
+ try {
1751
+ this.db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(text, tokenize = 'unicode61')");
1752
+ this.ftsAvailable = true;
1753
+ } catch {
1754
+ this.ftsAvailable = false;
1335
1755
  }
1336
1756
  }
1337
1757
  // ─── Symbol CRUD ─────────────────────────────────────────────────────────────
1338
1758
  insertSymbols(symbols, nextId) {
1339
- const stmt = this.db.prepare(
1340
- `INSERT INTO symbols(id, lang, kind, name, file, line, col, signature, doc_comment, scope, text, file_fk)
1341
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1342
- );
1343
- let id = nextId;
1344
- for (const s of symbols) {
1345
- stmt.run(
1346
- id++,
1347
- s.lang,
1348
- s.kind,
1349
- s.name,
1350
- s.file,
1351
- s.line,
1352
- s.col,
1353
- s.signature,
1354
- s.docComment,
1355
- s.scope,
1356
- s.text,
1357
- s.file
1759
+ return this.runWithRetry(() => {
1760
+ const stmt = this.db.prepare(
1761
+ `INSERT INTO symbols(id, lang, kind, name, file, line, col, signature, doc_comment, scope, text, file_fk)
1762
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1358
1763
  );
1359
- }
1360
- return id;
1764
+ const ftsStmt = this.ftsAvailable ? this.db.prepare("INSERT INTO symbols_fts(rowid, text) VALUES (?, ?)") : null;
1765
+ let id = nextId;
1766
+ for (const s of symbols) {
1767
+ stmt.run(
1768
+ id,
1769
+ s.lang,
1770
+ s.kind,
1771
+ s.name,
1772
+ s.file,
1773
+ s.line,
1774
+ s.col,
1775
+ s.signature,
1776
+ s.docComment,
1777
+ s.scope,
1778
+ s.text,
1779
+ s.file
1780
+ );
1781
+ ftsStmt?.run(id, buildIndexableText(s.name, s.signature, s.docComment));
1782
+ id++;
1783
+ }
1784
+ return id;
1785
+ });
1361
1786
  }
1362
1787
  deleteSymbolsForFile(file) {
1363
- this.db.prepare("DELETE FROM symbols WHERE file_fk = ?").run(file);
1788
+ this.runWithRetry(() => {
1789
+ if (this.ftsAvailable) {
1790
+ this.db.prepare("DELETE FROM symbols_fts WHERE rowid IN (SELECT id FROM symbols WHERE file_fk = ?)").run(file);
1791
+ }
1792
+ this.db.prepare("DELETE FROM symbols WHERE file_fk = ?").run(file);
1793
+ });
1364
1794
  }
1795
+ /**
1796
+ * Remove every trace of a file (refs, symbols, FTS rows, file meta). Used
1797
+ * when a source file disappears between index runs — previously this only
1798
+ * dropped the `files` row, leaving its symbols orphaned but still searchable.
1799
+ */
1365
1800
  deleteFile(file) {
1366
- this.db.prepare("DELETE FROM files WHERE file = ?").run(file);
1801
+ this.runWithRetry(() => {
1802
+ this.deleteRefsForFile(file);
1803
+ this.deleteSymbolsForFile(file);
1804
+ this.db.prepare("DELETE FROM files WHERE file = ?").run(file);
1805
+ });
1367
1806
  }
1368
1807
  // ─── File metadata ──────────────────────────────────────────────────────────
1369
1808
  upsertFile(meta) {
1370
- this.db.prepare(
1371
- `INSERT INTO files(file, lang, mtime_ms, symbol_count, last_indexed)
1372
- VALUES (?, ?, ?, ?, ?)
1373
- ON CONFLICT(file) DO UPDATE SET
1374
- lang = excluded.lang,
1375
- mtime_ms = excluded.mtime_ms,
1376
- symbol_count = excluded.symbol_count,
1377
- last_indexed = excluded.last_indexed`
1378
- ).run(meta.file, meta.lang, meta.mtimeMs, meta.symbolCount, meta.lastIndexed);
1809
+ this.runWithRetry(() => {
1810
+ this.db.prepare(
1811
+ `INSERT INTO files(file, lang, mtime_ms, symbol_count, last_indexed)
1812
+ VALUES (?, ?, ?, ?, ?)
1813
+ ON CONFLICT(file) DO UPDATE SET
1814
+ lang = excluded.lang,
1815
+ mtime_ms = excluded.mtime_ms,
1816
+ symbol_count = excluded.symbol_count,
1817
+ last_indexed = excluded.last_indexed`
1818
+ ).run(meta.file, meta.lang, meta.mtimeMs, meta.symbolCount, meta.lastIndexed);
1819
+ });
1379
1820
  }
1380
1821
  getFileMeta(file) {
1381
1822
  const rows = this.db.prepare(
@@ -1442,6 +1883,94 @@ var IndexStore = class {
1442
1883
  lspKind: filter?.lspKind
1443
1884
  }));
1444
1885
  }
1886
+ /**
1887
+ * Ranked search — the one-stop query the codebase-search tool and plug-lsp
1888
+ * use. With FTS5 this is a single indexed `MATCH` ranked by SQLite's native
1889
+ * `bm25()` with a built-in `snippet()`; without FTS5 it falls back to the
1890
+ * legacy LIKE scan + in-process BM25 (identical semantics, slower).
1891
+ *
1892
+ * Tokens are matched as prefixes (`"tok"*`), mirroring the old
1893
+ * `LIKE '%tok%'` recall for the common symbol-search shapes ("user" finds
1894
+ * "users", camelCase-split text makes "complex" find "complexOperation").
1895
+ */
1896
+ searchRanked(query2, filter, limit) {
1897
+ const tokens = tokenise(query2);
1898
+ if (tokens.length === 0 || !this.ftsAvailable) {
1899
+ return this.searchRankedFallback(query2, filter, limit);
1900
+ }
1901
+ let effectiveKind = filter?.kind;
1902
+ if (filter?.lspKind !== void 0) {
1903
+ const mapped = lspKindToInternalKind(filter.lspKind);
1904
+ if (mapped === null) return { results: [], total: 0 };
1905
+ effectiveKind = mapped;
1906
+ }
1907
+ const match = tokens.map((t) => `"${t.replaceAll('"', "")}"*`).join(" OR ");
1908
+ const conditions = ["symbols_fts MATCH ?"];
1909
+ const values = [match];
1910
+ if (effectiveKind) {
1911
+ conditions.push("s.kind = ?");
1912
+ values.push(effectiveKind);
1913
+ }
1914
+ if (filter?.lang) {
1915
+ conditions.push("s.lang = ?");
1916
+ values.push(filter.lang);
1917
+ }
1918
+ if (filter?.file) {
1919
+ conditions.push("s.file LIKE ?");
1920
+ values.push(`%${filter.file}%`);
1921
+ }
1922
+ const where = conditions.join(" AND ");
1923
+ 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);
1924
+ const total = countRows[0] ? Number(countRows[0].n) : 0;
1925
+ if (total === 0) return { results: [], total: 0 };
1926
+ const rows = this.db.prepare(
1927
+ `SELECT s.id, s.lang, s.kind, s.name, s.file, s.line, s.col, s.signature, s.doc_comment,
1928
+ -bm25(symbols_fts) AS score,
1929
+ snippet(symbols_fts, 0, '', '', '\u2026', 12) AS snippet
1930
+ FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid
1931
+ WHERE ${where}
1932
+ ORDER BY bm25(symbols_fts)
1933
+ LIMIT ?`
1934
+ ).all(...values, limit);
1935
+ return {
1936
+ results: rows.map((r) => ({
1937
+ id: r.id,
1938
+ lang: r.lang,
1939
+ kind: r.kind,
1940
+ name: r.name,
1941
+ file: r.file,
1942
+ line: r.line,
1943
+ col: r.col,
1944
+ signature: r.signature,
1945
+ docComment: r.doc_comment,
1946
+ // bm25() is negative-is-better; negate so callers keep "higher is
1947
+ // better" and clamp so a match never reports a zero score.
1948
+ score: Math.max(1e-4, r.score),
1949
+ snippet: r.snippet,
1950
+ lspKind: filter?.lspKind
1951
+ })),
1952
+ total
1953
+ };
1954
+ }
1955
+ /** Legacy ranked path: LIKE candidates + in-process BM25 + JS snippets. */
1956
+ searchRankedFallback(query2, filter, limit) {
1957
+ const candidates = this.search(query2, filter);
1958
+ if (candidates.length === 0) return { results: [], total: 0 };
1959
+ if (!query2.trim()) {
1960
+ return { results: candidates.slice(0, limit), total: candidates.length };
1961
+ }
1962
+ const bm25 = buildBm25Index(
1963
+ candidates.map((c) => ({ id: c.id, text: buildIndexableText(c.name, c.signature, c.docComment) }))
1964
+ );
1965
+ const scored = bm25.score(query2, (id) => candidates.some((c) => c.id === id));
1966
+ scored.sort((a, b) => b.score - a.score);
1967
+ const qTokens = tokenise(query2);
1968
+ const results = scored.slice(0, limit).map(({ id, score }) => {
1969
+ const c = expectDefined(candidates.find((cand) => cand.id === id));
1970
+ return { ...c, score, snippet: bm25.extractSnippet(id, qTokens) };
1971
+ });
1972
+ return { results, total: candidates.length };
1973
+ }
1445
1974
  getAllIndexable() {
1446
1975
  return this.db.prepare("SELECT id, text FROM symbols").all().map(
1447
1976
  ({ id, text }) => ({ id, text })
@@ -1491,14 +2020,19 @@ var IndexStore = class {
1491
2020
  };
1492
2021
  }
1493
2022
  setLastIndexed(ts2) {
1494
- this.db.prepare(
1495
- "INSERT OR REPLACE INTO metadata(key, value) VALUES('last_indexed', ?)"
1496
- ).run(String(ts2));
2023
+ this.runWithRetry(() => {
2024
+ this.db.prepare(
2025
+ "INSERT OR REPLACE INTO metadata(key, value) VALUES('last_indexed', ?)"
2026
+ ).run(String(ts2));
2027
+ });
1497
2028
  }
1498
2029
  clearAll() {
1499
- this.db.exec("DELETE FROM symbols");
1500
- this.db.exec("DELETE FROM files");
1501
- this.db.exec("DELETE FROM refs");
2030
+ this.runWithRetry(() => {
2031
+ this.db.exec("DELETE FROM symbols");
2032
+ this.db.exec("DELETE FROM files");
2033
+ this.db.exec("DELETE FROM refs");
2034
+ if (this.ftsAvailable) this.db.exec("DELETE FROM symbols_fts");
2035
+ });
1502
2036
  }
1503
2037
  // ─── Ref CRUD ────────────────────────────────────────────────────────────────
1504
2038
  /**
@@ -1506,46 +2040,52 @@ var IndexStore = class {
1506
2040
  * Replaces any existing refs from the same source (idempotent on re-index).
1507
2041
  */
1508
2042
  insertRefs(fromId, refs) {
1509
- this.db.prepare("DELETE FROM refs WHERE from_id = ?").run(fromId);
1510
- if (refs.length === 0) return;
1511
- const stmt = this.db.prepare(
1512
- `INSERT INTO refs(from_id, to_name, to_id, call_type, line)
1513
- VALUES (?, ?, ?, ?, ?)`
1514
- );
1515
- for (const ref of refs) {
1516
- stmt.run(fromId, ref.toName, ref.toId ?? null, ref.callType, ref.line);
1517
- }
2043
+ this.runWithRetry(() => {
2044
+ this.db.prepare("DELETE FROM refs WHERE from_id = ?").run(fromId);
2045
+ if (refs.length === 0) return;
2046
+ const stmt = this.db.prepare(
2047
+ `INSERT INTO refs(from_id, to_name, to_id, call_type, line)
2048
+ VALUES (?, ?, ?, ?, ?)`
2049
+ );
2050
+ for (const ref of refs) {
2051
+ stmt.run(fromId, ref.toName, ref.toId ?? null, ref.callType, ref.line);
2052
+ }
2053
+ });
1518
2054
  }
1519
2055
  /**
1520
2056
  * Delete all refs whose source symbols are in a given file.
1521
2057
  * Used when re-indexing a file to clear stale refs.
1522
2058
  */
1523
2059
  deleteRefsForFile(file) {
1524
- const ids = this.db.prepare(
1525
- "SELECT id FROM symbols WHERE file = ?"
1526
- ).all(file);
1527
- if (!ids.length) return;
1528
- const placeholders = ids.map(() => "?").join(",");
1529
- this.db.prepare(`DELETE FROM refs WHERE from_id IN (${placeholders})`).run(...ids.map((r) => r.id));
2060
+ this.runWithRetry(() => {
2061
+ const ids = this.db.prepare(
2062
+ "SELECT id FROM symbols WHERE file = ?"
2063
+ ).all(file);
2064
+ if (!ids.length) return;
2065
+ const placeholders = ids.map(() => "?").join(",");
2066
+ this.db.prepare(`DELETE FROM refs WHERE from_id IN (${placeholders})`).run(...ids.map((r) => r.id));
2067
+ });
1530
2068
  }
1531
2069
  /**
1532
2070
  * Resolve `to_name` → `to_id` for all refs that have a name but no id.
1533
2071
  * Call this after all symbols have been inserted to fill in cross-references.
1534
2072
  */
1535
2073
  resolveRefs() {
1536
- const unresolved = this.db.prepare(
1537
- "SELECT id, to_name FROM refs WHERE to_id IS NULL AND to_name IS NOT NULL"
1538
- ).all();
1539
- let resolved = 0;
1540
- for (const row of unresolved) {
1541
- const target = this.db.prepare("SELECT id FROM symbols WHERE name = ? LIMIT 1").all(row.to_name);
1542
- const first = target[0];
1543
- if (first) {
1544
- this.db.prepare("UPDATE refs SET to_id = ? WHERE id = ?").run(first.id, row.id);
1545
- resolved++;
2074
+ return this.runWithRetry(() => {
2075
+ const unresolved = this.db.prepare(
2076
+ "SELECT id, to_name FROM refs WHERE to_id IS NULL AND to_name IS NOT NULL"
2077
+ ).all();
2078
+ let resolved = 0;
2079
+ for (const row of unresolved) {
2080
+ const target = this.db.prepare("SELECT id FROM symbols WHERE name = ? LIMIT 1").all(row.to_name);
2081
+ const first = target[0];
2082
+ if (first) {
2083
+ this.db.prepare("UPDATE refs SET to_id = ? WHERE id = ?").run(first.id, row.id);
2084
+ resolved++;
2085
+ }
1546
2086
  }
1547
- }
1548
- return resolved;
2087
+ return resolved;
2088
+ });
1549
2089
  }
1550
2090
  /**
1551
2091
  * Find all references TO a given symbol (who calls / uses this symbol?).
@@ -1578,7 +2118,7 @@ var IndexStore = class {
1578
2118
  }));
1579
2119
  }
1580
2120
  sizeBytes() {
1581
- const dbPath = path2.join(this.indexDir, DB_FILE);
2121
+ const dbPath = path3.join(this.indexDir, DB_FILE);
1582
2122
  try {
1583
2123
  return fs.statSync(dbPath).size;
1584
2124
  } catch {
@@ -2017,10 +2557,10 @@ func formatType(t ast.Expr) string {
2017
2557
  }
2018
2558
  `;
2019
2559
  function syncGoParse(filePath, content, lang) {
2020
- const tmpDir = path2.join(os.tmpdir(), "ws-go-parse");
2560
+ const tmpDir = path3.join(os.tmpdir(), "ws-go-parse");
2021
2561
  try {
2022
2562
  mkdirSync(tmpDir, { recursive: true });
2023
- const scriptPath = path2.join(tmpDir, "parse.go");
2563
+ const scriptPath = path3.join(tmpDir, "parse.go");
2024
2564
  writeFileSync(scriptPath, GO_PARSE_SCRIPT, "utf8");
2025
2565
  const stdout = execFileSync("go", ["run", scriptPath], {
2026
2566
  input: content,
@@ -2264,9 +2804,9 @@ print(json.dumps([s.to_dict() for s in syms]))
2264
2804
  `;
2265
2805
  function syncPyParse(filePath, lang) {
2266
2806
  try {
2267
- const tmpDir = path2.join(os.tmpdir(), "ws-py-parse");
2807
+ const tmpDir = path3.join(os.tmpdir(), "ws-py-parse");
2268
2808
  mkdirSync(tmpDir, { recursive: true });
2269
- const scriptPath = path2.join(tmpDir, "parse.py");
2809
+ const scriptPath = path3.join(tmpDir, "parse.py");
2270
2810
  writeFileSync(scriptPath, PY_PARSE_SCRIPT, "utf8");
2271
2811
  const stdout = execFileSync("python", [scriptPath, filePath], {
2272
2812
  timeout: 15e3,
@@ -2306,8 +2846,8 @@ function parseSymbols4(opts) {
2306
2846
  }
2307
2847
  function checkNativeParser() {
2308
2848
  try {
2309
- execFileSync("rustc", ["--version"], { stdio: "pipe" });
2310
- const toolsDir = path2.join(process.cwd(), "tools");
2849
+ execFileSync("rustc", ["--version"], { stdio: "pipe", windowsHide: true });
2850
+ const toolsDir = path3.join(process.cwd(), "tools");
2311
2851
  try {
2312
2852
  execFileSync(
2313
2853
  "cargo",
@@ -2317,9 +2857,9 @@ function checkNativeParser() {
2317
2857
  "--format-version",
2318
2858
  "1",
2319
2859
  "--manifest-path",
2320
- path2.join(toolsDir, "Cargo.toml")
2860
+ path3.join(toolsDir, "Cargo.toml")
2321
2861
  ],
2322
- { stdio: "pipe" }
2862
+ { stdio: "pipe", windowsHide: true }
2323
2863
  );
2324
2864
  return true;
2325
2865
  } catch {
@@ -2331,18 +2871,19 @@ function checkNativeParser() {
2331
2871
  }
2332
2872
  function tryNativeParse(file, content) {
2333
2873
  try {
2334
- const toolsDir = path2.join(process.cwd(), "tools");
2335
- const crateDir = path2.join(toolsDir, "syn-parser");
2336
- const tmpFile = path2.join(crateDir, "src", "input.rs");
2874
+ const toolsDir = path3.join(process.cwd(), "tools");
2875
+ const crateDir = path3.join(toolsDir, "syn-parser");
2876
+ const tmpFile = path3.join(crateDir, "src", "input.rs");
2337
2877
  writeFileSync(tmpFile, content, "utf8");
2338
2878
  const result = spawnSync(
2339
2879
  "cargo",
2340
- ["run", "--manifest-path", path2.join(toolsDir, "Cargo.toml")],
2880
+ ["run", "--manifest-path", path3.join(toolsDir, "Cargo.toml")],
2341
2881
  {
2342
2882
  cwd: process.cwd(),
2343
2883
  encoding: "utf8",
2344
2884
  timeout: 15e3,
2345
- stdio: ["pipe", "pipe", "pipe"]
2885
+ stdio: ["pipe", "pipe", "pipe"],
2886
+ windowsHide: true
2346
2887
  }
2347
2888
  );
2348
2889
  if (result.status === 0 && result.stdout) {
@@ -2435,7 +2976,7 @@ function parseSymbols5(opts) {
2435
2976
  function regexParse2(opts) {
2436
2977
  const { file, content, lang } = opts;
2437
2978
  const symbols = [];
2438
- const basename2 = path2.basename(file).toLowerCase();
2979
+ const basename2 = path3.basename(file).toLowerCase();
2439
2980
  const isPackageJson = basename2 === "package.json";
2440
2981
  const isTsconfig = basename2 === "tsconfig.json" || basename2 === "tsconfig.build.json";
2441
2982
  const isJsonSchema = content.includes("$schema") || content.includes("$id") || content.includes("$ref");
@@ -2461,11 +3002,11 @@ function regexParse2(opts) {
2461
3002
  const line = lineFromOffset(offset);
2462
3003
  symbols.push(
2463
3004
  makeSymbol({
2464
- name: path2.basename(file),
3005
+ name: path3.basename(file),
2465
3006
  kind: "object",
2466
3007
  line,
2467
3008
  col: 0,
2468
- signature: `"${path2.basename(file)}" = { ... }`,
3009
+ signature: `"${path3.basename(file)}" = { ... }`,
2469
3010
  file,
2470
3011
  lang
2471
3012
  })
@@ -2756,10 +3297,6 @@ function isScalar(value) {
2756
3297
  if (/^'[^']*'$/.test(value) || /^"[^"]*"$/.test(value)) return true;
2757
3298
  return false;
2758
3299
  }
2759
- function truncate(s, max) {
2760
- if (s.length <= max) return s;
2761
- return s.slice(0, max) + "...";
2762
- }
2763
3300
  function makeSymbol2(opts) {
2764
3301
  return {
2765
3302
  id: 0,
@@ -2814,47 +3351,17 @@ function compileGitignore(lines) {
2814
3351
  if (re.test(p)) ignored = !r.negated;
2815
3352
  }
2816
3353
  return ignored;
2817
- };
2818
- }
2819
- async function loadGitignoreMatcher(projectRoot) {
2820
- let lines = [];
2821
- try {
2822
- const raw = await fs13.readFile(path2.join(projectRoot, ".gitignore"), "utf8");
2823
- lines = raw.split("\n");
2824
- } catch {
2825
- }
2826
- return compileGitignore(lines);
2827
- }
2828
-
2829
- // src/codebase-index/background-indexer.ts
2830
- var _ready = false;
2831
- var _indexing = false;
2832
- var _currentFile = 0;
2833
- var _totalFiles = 0;
2834
- var _lastError = null;
2835
- function setIndexReady() {
2836
- _ready = true;
2837
- }
2838
- function getIndexState() {
2839
- return {
2840
- ready: _ready,
2841
- indexing: _indexing,
2842
- currentFile: _currentFile,
2843
- totalFiles: _totalFiles,
2844
- lastError: _lastError
2845
- };
2846
- }
2847
- var _listeners = [];
2848
- function emitState() {
2849
- const state = getIndexState();
2850
- for (const l of _listeners) l(state);
2851
- }
2852
- function _setIndexProgress(current, total) {
2853
- _currentFile = current;
2854
- _totalFiles = total;
2855
- emitState();
3354
+ };
3355
+ }
3356
+ async function loadGitignoreMatcher(projectRoot) {
3357
+ let lines = [];
3358
+ try {
3359
+ const raw = await fs14.readFile(path3.join(projectRoot, ".gitignore"), "utf8");
3360
+ lines = raw.split("\n");
3361
+ } catch {
3362
+ }
3363
+ return compileGitignore(lines);
2856
3364
  }
2857
- Promise.resolve();
2858
3365
 
2859
3366
  // src/codebase-index/indexer.ts
2860
3367
  var YIELD_EVERY_N = 50;
@@ -2906,21 +3413,21 @@ async function findSourceFiles(projectRoot, ignore, isGitIgnored, signal) {
2906
3413
  }
2907
3414
  let entries;
2908
3415
  try {
2909
- entries = await fs13.readdir(dir, { withFileTypes: true });
3416
+ entries = await fs14.readdir(dir, { withFileTypes: true });
2910
3417
  } catch {
2911
3418
  return;
2912
3419
  }
2913
3420
  dirCount++;
2914
3421
  for (const e of entries) {
2915
3422
  if (ignoreSet.has(e.name)) continue;
2916
- const full = path2.join(dir, e.name);
2917
- const rel = path2.relative(projectRoot, full).replace(/\\/g, "/");
3423
+ const full = path3.join(dir, e.name);
3424
+ const rel = path3.relative(projectRoot, full).replace(/\\/g, "/");
2918
3425
  if (e.isDirectory()) {
2919
3426
  if (isGitIgnored(rel, true)) continue;
2920
3427
  await walk(full);
2921
3428
  } else if (e.isFile()) {
2922
3429
  if (isGitIgnored(rel, false)) continue;
2923
- const ext = path2.extname(e.name);
3430
+ const ext = path3.extname(e.name);
2924
3431
  for (const { ext: extName, pat } of globs) {
2925
3432
  if (ext === extName && (pat.test(rel) || pat.test(e.name))) {
2926
3433
  results.push(full);
@@ -2975,7 +3482,7 @@ async function runIndexerWithStore(store, opts) {
2975
3482
  const isGitIgnored = await loadGitignoreMatcher(projectRoot);
2976
3483
  let files;
2977
3484
  if (opts.files && opts.files.length > 0) {
2978
- files = opts.files.map((f) => path2.resolve(projectRoot, f)).filter((f) => !isGitIgnored(path2.relative(projectRoot, f).replace(/\\/g, "/"), false));
3485
+ files = opts.files.map((f) => path3.resolve(projectRoot, f)).filter((f) => !isGitIgnored(path3.relative(projectRoot, f).replace(/\\/g, "/"), false));
2979
3486
  } else {
2980
3487
  files = await findSourceFiles(projectRoot, ignore, isGitIgnored, signal);
2981
3488
  }
@@ -2993,25 +3500,25 @@ async function runIndexerWithStore(store, opts) {
2993
3500
  }
2994
3501
  for (let fi = 0; fi < files.length; fi++) {
2995
3502
  const file = expectDefined(files[fi]);
2996
- _setIndexProgress(fi + 1, files.length);
3503
+ opts.onProgress?.(fi + 1, files.length);
2997
3504
  if (fi > 0 && fi % YIELD_EVERY_N === 0) {
2998
3505
  await yieldEventLoop();
2999
3506
  throwIfAborted(signal);
3000
3507
  }
3001
- let stat10;
3508
+ let stat11;
3002
3509
  try {
3003
3510
  const statOpts = signal ? { signal } : {};
3004
- stat10 = await fs13.stat(file, statOpts);
3511
+ stat11 = await fs14.stat(file, statOpts);
3005
3512
  } catch (e) {
3006
3513
  if (isAbortError(e)) throw e;
3007
3514
  store.deleteFile(file);
3008
3515
  continue;
3009
3516
  }
3010
- if (!stat10.isFile()) continue;
3517
+ if (!stat11.isFile()) continue;
3011
3518
  const lang = detectLang(file);
3012
3519
  if (!lang) continue;
3013
3520
  const meta = existingMeta.get(file);
3014
- if (!force && meta && meta.mtimeMs === Math.floor(stat10.mtimeMs)) {
3521
+ if (!force && meta && meta.mtimeMs === Math.floor(stat11.mtimeMs)) {
3015
3522
  langStats[lang] = (langStats[lang] ?? 0) + meta.symbolCount;
3016
3523
  symbolsIndexed += meta.symbolCount;
3017
3524
  filesIndexed++;
@@ -3021,7 +3528,7 @@ async function runIndexerWithStore(store, opts) {
3021
3528
  store.deleteSymbolsForFile(file);
3022
3529
  let content;
3023
3530
  try {
3024
- content = await fs13.readFile(file, { encoding: "utf8", signal });
3531
+ content = await fs14.readFile(file, { encoding: "utf8", signal });
3025
3532
  } catch (e) {
3026
3533
  if (isAbortError(e)) throw e;
3027
3534
  errors.push(`read error: ${file}: ${e instanceof Error ? e.message : String(e)}`);
@@ -3038,7 +3545,7 @@ async function runIndexerWithStore(store, opts) {
3038
3545
  store.upsertFile({
3039
3546
  file,
3040
3547
  lang,
3041
- mtimeMs: Math.floor(stat10.mtimeMs),
3548
+ mtimeMs: Math.floor(stat11.mtimeMs),
3042
3549
  symbolCount: 0,
3043
3550
  lastIndexed: Date.now()
3044
3551
  });
@@ -3064,7 +3571,7 @@ async function runIndexerWithStore(store, opts) {
3064
3571
  store.upsertFile({
3065
3572
  file,
3066
3573
  lang,
3067
- mtimeMs: Math.floor(stat10.mtimeMs),
3574
+ mtimeMs: Math.floor(stat11.mtimeMs),
3068
3575
  symbolCount: count,
3069
3576
  lastIndexed: Date.now()
3070
3577
  });
@@ -3072,7 +3579,7 @@ async function runIndexerWithStore(store, opts) {
3072
3579
  }
3073
3580
  for (const [file_] of existingMeta) {
3074
3581
  try {
3075
- await fs13.stat(file_);
3582
+ await fs14.stat(file_);
3076
3583
  } catch {
3077
3584
  store.deleteFile(file_);
3078
3585
  }
@@ -3088,6 +3595,306 @@ async function runIndexerWithStore(store, opts) {
3088
3595
  };
3089
3596
  }
3090
3597
 
3598
+ // src/codebase-index/index-service.ts
3599
+ function stubCtx(projectRoot) {
3600
+ return {
3601
+ projectRoot,
3602
+ cwd: projectRoot,
3603
+ messages: [],
3604
+ todos: [],
3605
+ readFiles: /* @__PURE__ */ new Set(),
3606
+ fileMtimes: /* @__PURE__ */ new Map()
3607
+ };
3608
+ }
3609
+ async function indexService(args, hooks = {}) {
3610
+ return runIndexer(stubCtx(args.projectRoot), {
3611
+ projectRoot: args.projectRoot,
3612
+ indexDir: args.indexDir,
3613
+ files: args.files,
3614
+ force: args.force,
3615
+ langs: args.langs,
3616
+ ignore: args.ignore,
3617
+ signal: hooks.signal,
3618
+ onProgress: hooks.onProgress
3619
+ });
3620
+ }
3621
+ function searchService(args) {
3622
+ const store = new IndexStore(args.projectRoot, { indexDir: args.indexDir });
3623
+ try {
3624
+ return store.searchRanked(
3625
+ args.query,
3626
+ {
3627
+ kind: args.kind,
3628
+ lang: args.lang,
3629
+ file: args.file,
3630
+ lspKind: args.lspKind
3631
+ },
3632
+ args.limit
3633
+ );
3634
+ } finally {
3635
+ store.close();
3636
+ }
3637
+ }
3638
+ function statsService(args) {
3639
+ const store = new IndexStore(args.projectRoot, { indexDir: args.indexDir });
3640
+ try {
3641
+ return store.getStats();
3642
+ } finally {
3643
+ store.close();
3644
+ }
3645
+ }
3646
+
3647
+ // src/codebase-index/background-indexer.ts
3648
+ var DEFAULT_FULL_INDEX_TIMEOUT_MS = 12e4;
3649
+ var DEFAULT_QUERY_TIMEOUT_MS = 8e3;
3650
+ var _ready = false;
3651
+ var _indexing = false;
3652
+ var _currentFile = 0;
3653
+ var _totalFiles = 0;
3654
+ var _lastError = null;
3655
+ function isIndexing() {
3656
+ return _indexing;
3657
+ }
3658
+ function getIndexState() {
3659
+ return {
3660
+ ready: _ready,
3661
+ indexing: _indexing,
3662
+ currentFile: _currentFile,
3663
+ totalFiles: _totalFiles,
3664
+ lastError: _lastError,
3665
+ circuit: indexCircuitBreaker.snapshot()
3666
+ };
3667
+ }
3668
+ var _listeners = [];
3669
+ function emitState() {
3670
+ const state = getIndexState();
3671
+ for (const l of _listeners) l(state);
3672
+ }
3673
+ function setIndexProgress(current, total) {
3674
+ _currentFile = current;
3675
+ _totalFiles = total;
3676
+ emitState();
3677
+ }
3678
+ var worker = null;
3679
+ var workerUnavailable = false;
3680
+ var nextRpcId = 1;
3681
+ var pending = /* @__PURE__ */ new Map();
3682
+ function resolveWorkerUrl() {
3683
+ if (process.env["WRONGSTACK_INDEX_INLINE"]) return null;
3684
+ for (const rel of ["./worker.js", "./codebase-index/worker.js"]) {
3685
+ try {
3686
+ const url = new URL(rel, import.meta.url);
3687
+ if (url.protocol === "file:" && fs.existsSync(fileURLToPath(url))) return url;
3688
+ } catch {
3689
+ }
3690
+ }
3691
+ return null;
3692
+ }
3693
+ function failAllPending(err) {
3694
+ const entries = [...pending.values()];
3695
+ pending.clear();
3696
+ for (const p of entries) p.reject(err);
3697
+ }
3698
+ function ensureWorker() {
3699
+ if (worker) return worker;
3700
+ if (workerUnavailable) return null;
3701
+ const url = resolveWorkerUrl();
3702
+ if (!url) {
3703
+ workerUnavailable = true;
3704
+ return null;
3705
+ }
3706
+ try {
3707
+ const w = new Worker(url, { name: "wstack-codebase-index" });
3708
+ w.unref();
3709
+ w.on("message", (msg) => {
3710
+ if (msg.type === "progress") {
3711
+ pending.get(msg.id)?.onProgress?.(msg.current, msg.total);
3712
+ return;
3713
+ }
3714
+ const entry = pending.get(msg.id);
3715
+ if (!entry) return;
3716
+ pending.delete(msg.id);
3717
+ if (msg.ok) entry.resolve(msg.result);
3718
+ else entry.reject(new Error(msg.error));
3719
+ });
3720
+ w.on("error", (err) => {
3721
+ worker = null;
3722
+ failAllPending(err);
3723
+ });
3724
+ w.on("exit", () => {
3725
+ if (worker === w) worker = null;
3726
+ failAllPending(new Error("codebase-index worker exited"));
3727
+ });
3728
+ worker = w;
3729
+ return w;
3730
+ } catch {
3731
+ workerUnavailable = true;
3732
+ return null;
3733
+ }
3734
+ }
3735
+ function terminateWorker(reason) {
3736
+ const w = worker;
3737
+ worker = null;
3738
+ failAllPending(reason);
3739
+ if (w) void w.terminate().catch(() => {
3740
+ });
3741
+ }
3742
+ function callIndexOp(op, args, opts) {
3743
+ const w = ensureWorker();
3744
+ if (!w) return callInline(op, args, opts);
3745
+ return new Promise((resolve7, reject) => {
3746
+ const id = nextRpcId++;
3747
+ const timer = setTimeout(() => {
3748
+ pending.delete(id);
3749
+ const err = new IndexTimeoutError(
3750
+ `Index ${op} exceeded its ${opts.timeoutMs}ms watchdog timeout`
3751
+ );
3752
+ terminateWorker(err);
3753
+ reject(err);
3754
+ }, opts.timeoutMs);
3755
+ timer.unref?.();
3756
+ const onAbort = () => {
3757
+ w.postMessage({ type: "cancel", id });
3758
+ };
3759
+ if (opts.signal?.aborted) onAbort();
3760
+ else opts.signal?.addEventListener("abort", onAbort, { once: true });
3761
+ const cleanup = () => {
3762
+ clearTimeout(timer);
3763
+ opts.signal?.removeEventListener("abort", onAbort);
3764
+ };
3765
+ pending.set(id, {
3766
+ resolve: (v) => {
3767
+ cleanup();
3768
+ resolve7(v);
3769
+ },
3770
+ reject: (e) => {
3771
+ cleanup();
3772
+ reject(e);
3773
+ },
3774
+ onProgress: opts.onProgress
3775
+ });
3776
+ w.postMessage({ type: "request", id, op, args });
3777
+ });
3778
+ }
3779
+ async function callInline(op, args, opts) {
3780
+ const ac = new AbortController();
3781
+ const onOuterAbort = () => ac.abort(opts.signal?.reason ?? new Error("Indexing cancelled"));
3782
+ if (opts.signal?.aborted) onOuterAbort();
3783
+ else opts.signal?.addEventListener("abort", onOuterAbort, { once: true });
3784
+ let timer;
3785
+ const watchdog = new Promise((_, reject) => {
3786
+ timer = setTimeout(() => {
3787
+ const err = new IndexTimeoutError(
3788
+ `Index ${op} exceeded its ${opts.timeoutMs}ms watchdog timeout`
3789
+ );
3790
+ ac.abort(err);
3791
+ reject(err);
3792
+ }, opts.timeoutMs);
3793
+ timer.unref?.();
3794
+ });
3795
+ const job = async () => {
3796
+ switch (op) {
3797
+ case "index":
3798
+ return await indexService(args, {
3799
+ signal: ac.signal,
3800
+ onProgress: opts.onProgress
3801
+ });
3802
+ case "search":
3803
+ return searchService(args);
3804
+ case "stats":
3805
+ return statsService(args);
3806
+ default:
3807
+ throw new Error(`unknown index op: ${String(op)}`);
3808
+ }
3809
+ };
3810
+ try {
3811
+ return await Promise.race([job(), watchdog]);
3812
+ } finally {
3813
+ if (timer) clearTimeout(timer);
3814
+ opts.signal?.removeEventListener("abort", onOuterAbort);
3815
+ }
3816
+ }
3817
+ var chain = Promise.resolve();
3818
+ function withMutex(job) {
3819
+ const run = chain.then(job, job);
3820
+ chain = run.then(
3821
+ () => void 0,
3822
+ () => void 0
3823
+ );
3824
+ return run;
3825
+ }
3826
+ function circuitOpenError() {
3827
+ const c = indexCircuitBreaker.snapshot();
3828
+ return new CircuitOpenError(
3829
+ "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."
3830
+ );
3831
+ }
3832
+ function isUniqueConstraintError(err) {
3833
+ if (err instanceof Error) {
3834
+ const msg = err.message.toLowerCase();
3835
+ return msg.includes("unique constraint") || msg.includes("UNIQUE constraint");
3836
+ }
3837
+ return false;
3838
+ }
3839
+ async function runStartupIndex(opts) {
3840
+ if (!indexCircuitBreaker.allowRequest()) throw circuitOpenError();
3841
+ _indexing = true;
3842
+ emitState();
3843
+ try {
3844
+ const result = await withMutex(() => {
3845
+ _currentFile = 0;
3846
+ _totalFiles = 0;
3847
+ _lastError = null;
3848
+ return callIndexOp(
3849
+ "index",
3850
+ {
3851
+ projectRoot: opts.projectRoot,
3852
+ indexDir: opts.indexDir,
3853
+ force: opts.force,
3854
+ langs: opts.langs
3855
+ },
3856
+ {
3857
+ timeoutMs: opts.timeoutMs ?? DEFAULT_FULL_INDEX_TIMEOUT_MS,
3858
+ signal: opts.signal,
3859
+ onProgress: setIndexProgress
3860
+ }
3861
+ );
3862
+ });
3863
+ _ready = true;
3864
+ indexCircuitBreaker.recordSuccess();
3865
+ return result;
3866
+ } catch (err) {
3867
+ _lastError = err instanceof Error ? err.message : String(err);
3868
+ if (isUniqueConstraintError(err) && !opts.force) {
3869
+ _lastError = null;
3870
+ const rebuildResult = await runStartupIndex({
3871
+ ...opts,
3872
+ force: true
3873
+ });
3874
+ _ready = true;
3875
+ return rebuildResult;
3876
+ }
3877
+ _ready = true;
3878
+ if (!opts.signal?.aborted) indexCircuitBreaker.recordFailure(err);
3879
+ throw err;
3880
+ } finally {
3881
+ _indexing = false;
3882
+ emitState();
3883
+ }
3884
+ }
3885
+ async function searchCodebaseIndex(args, opts = {}) {
3886
+ return callIndexOp("search", args, {
3887
+ timeoutMs: opts.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
3888
+ signal: opts.signal
3889
+ });
3890
+ }
3891
+ async function codebaseIndexStats(args, opts = {}) {
3892
+ return callIndexOp("stats", args, {
3893
+ timeoutMs: opts.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
3894
+ signal: opts.signal
3895
+ });
3896
+ }
3897
+
3091
3898
  // src/codebase-index/codebase-index-tool.ts
3092
3899
  var codebaseIndexTool = {
3093
3900
  name: "codebase-index",
@@ -3113,103 +3920,34 @@ var codebaseIndexTool = {
3113
3920
  }
3114
3921
  },
3115
3922
  async execute(input, ctx, execOpts) {
3116
- const result = await runIndexer(ctx, {
3923
+ if (isIndexing()) {
3924
+ return {
3925
+ filesIndexed: 0,
3926
+ symbolsIndexed: 0,
3927
+ langStats: {},
3928
+ durationMs: 0,
3929
+ errors: [],
3930
+ note: "A full index is already in progress. Retry codebase-index after it completes (check codebase-stats)."
3931
+ };
3932
+ }
3933
+ const circuit = indexCircuitBreaker.snapshot();
3934
+ if (circuit.state === "open" && circuit.cooldownRemainingMs > 0) {
3935
+ return {
3936
+ filesIndexed: 0,
3937
+ symbolsIndexed: 0,
3938
+ langStats: {},
3939
+ durationMs: 0,
3940
+ errors: [],
3941
+ 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.`
3942
+ };
3943
+ }
3944
+ return await runStartupIndex({
3117
3945
  projectRoot: ctx.projectRoot,
3118
3946
  force: input.force ?? false,
3119
3947
  langs: input.langs,
3120
3948
  indexDir: codebaseIndexDirOverride(ctx),
3121
3949
  signal: execOpts?.signal
3122
3950
  });
3123
- setIndexReady();
3124
- return result;
3125
- }
3126
- };
3127
-
3128
- // src/codebase-index/bm25.ts
3129
- var K1 = 1.5;
3130
- var B = 0.75;
3131
- function tokenise(text) {
3132
- const sanitised = text.replace(/[^\p{L}\p{N}$'_]/gu, " ").replace(/_/g, " ");
3133
- return sanitised.toLowerCase().split(" ").filter(Boolean);
3134
- }
3135
- function splitName(name) {
3136
- return name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
3137
- }
3138
- function buildIndexableText(name, signature, docComment) {
3139
- return [splitName(name), name, signature, docComment].filter(Boolean).join(" ");
3140
- }
3141
- function buildBm25Index(docs) {
3142
- const documents = docs.map((d) => {
3143
- const tokens = tokenise(d.text);
3144
- return { id: d.id, tokens, raw: d.text, len: tokens.length };
3145
- });
3146
- const df = {};
3147
- for (const doc of documents) {
3148
- const seen = /* @__PURE__ */ new Set();
3149
- for (const t of doc.tokens) {
3150
- if (!seen.has(t)) {
3151
- df[t] = (df[t] ?? 0) + 1;
3152
- seen.add(t);
3153
- }
3154
- }
3155
- }
3156
- const N = documents.length;
3157
- const totalLen = documents.reduce((sum, d) => sum + d.len, 0);
3158
- const avgLen = N === 0 ? 0 : totalLen / N;
3159
- return new Bm25Index(documents, df, N, avgLen);
3160
- }
3161
- var Bm25Index = class {
3162
- constructor(documents, df, N, avgLen) {
3163
- this.documents = documents;
3164
- this.df = df;
3165
- this.N = N;
3166
- this.safeAvgLen = avgLen === 0 ? 1 : avgLen;
3167
- }
3168
- documents;
3169
- df;
3170
- N;
3171
- safeAvgLen;
3172
- score(query2, filter) {
3173
- const qTokens = tokenise(query2);
3174
- if (qTokens.length === 0) return [];
3175
- const results = [];
3176
- for (const doc of this.documents) {
3177
- if (filter && !filter(doc.id)) continue;
3178
- let docScore = 0;
3179
- for (const qTerm of qTokens) {
3180
- let tf = 0;
3181
- for (const t of doc.tokens) {
3182
- if (t === qTerm) tf++;
3183
- }
3184
- if (tf === 0) continue;
3185
- const dfVal = this.df[qTerm] ?? 0;
3186
- if (dfVal === 0) continue;
3187
- const idf = Math.log((this.N - dfVal + 0.5) / (dfVal + 0.5) + 1);
3188
- const lenRatio = B * (doc.len / this.safeAvgLen);
3189
- const tfComponent = tf * (K1 + 1) / (tf + K1 * (1 - B + lenRatio));
3190
- docScore += idf * tfComponent;
3191
- }
3192
- if (docScore > 0) results.push({ id: doc.id, score: docScore });
3193
- }
3194
- return results;
3195
- }
3196
- getDoc(id) {
3197
- return this.documents.find((d) => d.id === id);
3198
- }
3199
- extractSnippet(docId, queryTokens, radius = 40) {
3200
- const doc = this.getDoc(docId);
3201
- if (!doc) return "";
3202
- for (const tok of queryTokens) {
3203
- const idx = doc.raw.toLowerCase().indexOf(tok);
3204
- if (idx !== -1) {
3205
- const start = Math.max(0, idx - radius);
3206
- const end = Math.min(doc.raw.length, idx + tok.length + radius);
3207
- const excerpt = doc.raw.slice(start, end);
3208
- const ellipsis = "\u2026";
3209
- return (start > 0 ? ellipsis : "") + excerpt + (end < doc.raw.length ? ellipsis : "");
3210
- }
3211
- }
3212
- return doc.raw.slice(0, radius * 2) + (doc.raw.length > radius * 2 ? "\u2026" : "");
3213
3951
  }
3214
3952
  };
3215
3953
 
@@ -3255,7 +3993,7 @@ var codebaseSearchTool = {
3255
3993
  },
3256
3994
  required: ["query"]
3257
3995
  },
3258
- async execute(input, ctx) {
3996
+ async execute(input, ctx, execOpts) {
3259
3997
  const state = getIndexState();
3260
3998
  if (!state.ready) {
3261
3999
  return {
@@ -3274,51 +4012,30 @@ var codebaseSearchTool = {
3274
4012
  };
3275
4013
  }
3276
4014
  if (state.lastError) {
4015
+ const circuit = state.circuit;
4016
+ 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.";
3277
4017
  return {
3278
4018
  results: [],
3279
4019
  total: 0,
3280
4020
  query: input.query,
3281
- indexStatus: `Index build failed: ${state.lastError}. Try /codebase-reindex.`
4021
+ indexStatus: `Index build failed: ${state.lastError}. ${retryHint}`
3282
4022
  };
3283
4023
  }
3284
- const store = new IndexStore(ctx.projectRoot, { indexDir: codebaseIndexDirOverride(ctx) });
3285
- try {
3286
- const limit = Math.min(input.limit ?? 20, 100);
3287
- const candidates = store.search(input.query, {
4024
+ const limit = Math.min(input.limit ?? 20, 100);
4025
+ const { results, total } = await searchCodebaseIndex(
4026
+ {
4027
+ projectRoot: ctx.projectRoot,
4028
+ indexDir: codebaseIndexDirOverride(ctx),
4029
+ query: input.query,
3288
4030
  kind: input.kind,
3289
4031
  lang: input.lang,
3290
4032
  file: input.file,
3291
- lspKind: input.lspKind
3292
- });
3293
- if (candidates.length === 0) {
3294
- return { results: [], total: 0, query: input.query };
3295
- }
3296
- const indexable = candidates.map((c) => ({
3297
- id: c.id,
3298
- text: buildIndexableText(c.name, c.signature, c.docComment)
3299
- }));
3300
- const bm25 = buildBm25Index(indexable);
3301
- const scored = bm25.score(input.query, (id) => candidates.some((c) => c.id === id));
3302
- scored.sort((a, b) => b.score - a.score);
3303
- const top = scored.slice(0, limit);
3304
- const qTokens = tokenise(input.query);
3305
- const results = top.map(({ id, score }) => {
3306
- const c = expectDefined(candidates.find((c2) => c2.id === id));
3307
- const snippet = bm25.extractSnippet(id, qTokens);
3308
- return {
3309
- ...c,
3310
- score,
3311
- snippet
3312
- };
3313
- });
3314
- return {
3315
- results,
3316
- total: candidates.length,
3317
- query: input.query
3318
- };
3319
- } finally {
3320
- store.close();
3321
- }
4033
+ lspKind: input.lspKind,
4034
+ limit
4035
+ },
4036
+ { signal: execOpts?.signal }
4037
+ );
4038
+ return { results, total, query: input.query };
3322
4039
  }
3323
4040
  };
3324
4041
 
@@ -3337,7 +4054,7 @@ var codebaseStatsTool = {
3337
4054
  properties: {},
3338
4055
  additionalProperties: false
3339
4056
  },
3340
- async execute(_input, ctx) {
4057
+ async execute(_input, ctx, execOpts) {
3341
4058
  const idxState = getIndexState();
3342
4059
  if (!idxState.ready) {
3343
4060
  return {
@@ -3352,34 +4069,30 @@ var codebaseStatsTool = {
3352
4069
  indexStatus: idxState.indexing ? `Indexing in progress (${idxState.currentFile}/${idxState.totalFiles} files).` : "Index not yet built."
3353
4070
  };
3354
4071
  }
4072
+ const stats = await codebaseIndexStats(
4073
+ { projectRoot: ctx.projectRoot, indexDir: codebaseIndexDirOverride(ctx) },
4074
+ { signal: execOpts?.signal }
4075
+ );
3355
4076
  if (idxState.indexing) {
3356
- const store2 = new IndexStore(ctx.projectRoot, { indexDir: codebaseIndexDirOverride(ctx) });
3357
- try {
3358
- const stats = store2.getStats();
3359
- return {
3360
- ...stats,
3361
- indexStatus: `Index refresh in progress (${idxState.currentFile}/${idxState.totalFiles} files). Stats may be incomplete.`
3362
- };
3363
- } finally {
3364
- store2.close();
3365
- }
3366
- }
3367
- const store = new IndexStore(ctx.projectRoot, { indexDir: codebaseIndexDirOverride(ctx) });
3368
- try {
3369
- const stats = store.getStats();
3370
4077
  return {
3371
- totalSymbols: stats.totalSymbols,
3372
- totalFiles: stats.totalFiles,
3373
- byLang: stats.byLang,
3374
- byKind: stats.byKind,
3375
- lastIndexed: stats.lastIndexed,
3376
- sizeBytes: stats.sizeBytes,
3377
- indexPath: stats.indexPath,
3378
- version: stats.version
4078
+ ...stats,
4079
+ indexStatus: `Index refresh in progress (${idxState.currentFile}/${idxState.totalFiles} files). Stats may be incomplete.`
3379
4080
  };
3380
- } finally {
3381
- store.close();
3382
4081
  }
4082
+ const circuit = idxState.circuit;
4083
+ return {
4084
+ totalSymbols: stats.totalSymbols,
4085
+ totalFiles: stats.totalFiles,
4086
+ byLang: stats.byLang,
4087
+ byKind: stats.byKind,
4088
+ lastIndexed: stats.lastIndexed,
4089
+ sizeBytes: stats.sizeBytes,
4090
+ indexPath: stats.indexPath,
4091
+ version: stats.version,
4092
+ ...circuit.state === "open" ? {
4093
+ 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.`
4094
+ } : {}
4095
+ };
3383
4096
  }
3384
4097
  };
3385
4098
  var diffTool = {
@@ -3463,11 +4176,11 @@ function findGitDir(cwd) {
3463
4176
  let dir = cwd;
3464
4177
  for (let i = 0; i < 20; i++) {
3465
4178
  try {
3466
- const stat10 = statSync(path2.join(dir, ".git"));
3467
- if (stat10.isDirectory()) return dir;
4179
+ const stat11 = statSync(path3.join(dir, ".git"));
4180
+ if (stat11.isDirectory()) return dir;
3468
4181
  } catch {
3469
4182
  }
3470
- const parent = path2.dirname(dir);
4183
+ const parent = path3.dirname(dir);
3471
4184
  if (parent === dir) break;
3472
4185
  dir = parent;
3473
4186
  }
@@ -3481,7 +4194,8 @@ function runGit(args, cwd, signal) {
3481
4194
  cwd,
3482
4195
  signal,
3483
4196
  env: buildChildEnv(),
3484
- stdio: ["ignore", "pipe", "pipe"]
4197
+ stdio: ["ignore", "pipe", "pipe"],
4198
+ windowsHide: true
3485
4199
  });
3486
4200
  child.stdout?.on("data", (c) => {
3487
4201
  stdout += c.toString();
@@ -3507,9 +4221,9 @@ async function fileDiff(input, ctx, _signal) {
3507
4221
  const results = [];
3508
4222
  for (const file of files) {
3509
4223
  const absPath = safeResolve(file, ctx);
3510
- const stat10 = await fs13.stat(absPath).catch(() => null);
3511
- if (!stat10?.isFile()) continue;
3512
- const content = await fs13.readFile(absPath, "utf8");
4224
+ const stat11 = await fs14.stat(absPath).catch(() => null);
4225
+ if (!stat11?.isFile()) continue;
4226
+ const content = await fs14.readFile(absPath, "utf8");
3513
4227
  const lines = content.split(/\r?\n/);
3514
4228
  results.push(formatWithLineNumbers(file, lines));
3515
4229
  }
@@ -3571,7 +4285,7 @@ var documentTool = {
3571
4285
  const fileList = input.files ? await resolveFiles(Array.isArray(input.files) ? input.files.join(",") : input.files, cwd) : input.path ? [safeResolve(input.path, ctx)] : [];
3572
4286
  for (const absPath of fileList) {
3573
4287
  try {
3574
- const content = await fs13.readFile(absPath, "utf8");
4288
+ const content = await fs14.readFile(absPath, "utf8");
3575
4289
  filesProcessed++;
3576
4290
  const processed = processFile(
3577
4291
  content,
@@ -3607,8 +4321,8 @@ async function resolveFiles(filesInput, cwd) {
3607
4321
  for (const f of files) {
3608
4322
  const absPath = f.trim().startsWith("/") ? f.trim() : `${cwd}/${f.trim()}`;
3609
4323
  try {
3610
- const stat10 = await fs13.stat(absPath);
3611
- if (stat10.isFile()) resolved.push(absPath);
4324
+ const stat11 = await fs14.stat(absPath);
4325
+ if (stat11.isFile()) resolved.push(absPath);
3612
4326
  } catch {
3613
4327
  }
3614
4328
  }
@@ -3699,18 +4413,18 @@ var editTool = {
3699
4413
  if (input.new_string === void 0) throw new Error("edit: new_string is required");
3700
4414
  if (input.old_string === "") throw new Error("edit: old_string cannot be empty");
3701
4415
  const absPath = await safeResolveReal(input.path, ctx);
3702
- const stat10 = await fs13.stat(absPath).catch((err) => {
4416
+ const stat11 = await fs14.stat(absPath).catch((err) => {
3703
4417
  if (err.code === "ENOENT") {
3704
4418
  throw new Error(`edit: file "${input.path}" does not exist. Use \`write\` instead.`);
3705
4419
  }
3706
4420
  throw err;
3707
4421
  });
3708
- if (!stat10.isFile()) throw new Error(`edit: "${input.path}" is not a regular file`);
4422
+ if (!stat11.isFile()) throw new Error(`edit: "${input.path}" is not a regular file`);
3709
4423
  if (!ctx.hasRead(absPath)) {
3710
4424
  throw new Error(`edit: file "${input.path}" was not read in this session. Read it first.`);
3711
4425
  }
3712
- const original = await fs13.readFile(absPath, "utf8");
3713
- const updated = await fs13.stat(absPath);
4426
+ const original = await fs14.readFile(absPath, "utf8");
4427
+ const updated = await fs14.stat(absPath);
3714
4428
  const mtimeTolerance = process.platform === "win32" ? 2e3 : 1;
3715
4429
  const lastReadMtime = ctx.lastReadMtime(absPath);
3716
4430
  if (lastReadMtime !== void 0 && updated.mtimeMs > lastReadMtime + mtimeTolerance) {
@@ -3750,7 +4464,7 @@ var editTool = {
3750
4464
  const newFileLf = input.replace_all ? fileLf.split(oldLf).join(newLf) : fileLf.replace(oldLf, newLf);
3751
4465
  const newFile = toStyle(newFileLf, style);
3752
4466
  await atomicWrite(absPath, newFile, { mode: updated.mode & 511 });
3753
- const written = await fs13.stat(absPath);
4467
+ const written = await fs14.stat(absPath);
3754
4468
  ctx.recordRead(absPath, written.mtimeMs);
3755
4469
  ctx.session.recordFileChange({
3756
4470
  path: absPath,
@@ -3975,9 +4689,9 @@ var execTool = {
3975
4689
  allowed: false
3976
4690
  };
3977
4691
  }
3978
- const requestedCwd = input.cwd ? path2.resolve(ctx.projectRoot, input.cwd) : ctx.cwd;
3979
- const rel = path2.relative(ctx.projectRoot, requestedCwd);
3980
- if (rel.startsWith("..") || path2.isAbsolute(rel)) {
4692
+ const requestedCwd = input.cwd ? path3.resolve(ctx.projectRoot, input.cwd) : ctx.cwd;
4693
+ const rel = path3.relative(ctx.projectRoot, requestedCwd);
4694
+ if (rel.startsWith("..") || path3.isAbsolute(rel)) {
3981
4695
  return {
3982
4696
  command: cmd,
3983
4697
  args,
@@ -3999,6 +4713,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
3999
4713
  let stderr = "";
4000
4714
  let killed = false;
4001
4715
  const startedAt = Date.now();
4716
+ const spool = createOutputSpool({ tool: `exec-${cmd}`, thresholdBytes: MAX_OUTPUT2 });
4002
4717
  const resolved = resolveWin32Command(cmd);
4003
4718
  const isWin = process.platform === "win32";
4004
4719
  const needsShell = isWin && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
@@ -4006,6 +4721,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
4006
4721
  cwd,
4007
4722
  env: buildChildEnv(sessionId),
4008
4723
  stdio: ["ignore", "pipe", "pipe"],
4724
+ windowsHide: true,
4009
4725
  ...isWin ? {} : { signal },
4010
4726
  ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {}
4011
4727
  });
@@ -4030,10 +4746,14 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
4030
4746
  else signal.addEventListener("abort", onAbort, { once: true });
4031
4747
  }
4032
4748
  child.stdout?.on("data", (chunk) => {
4033
- if (stdout.length < MAX_OUTPUT2) stdout += chunk.toString();
4749
+ const text = chunk.toString();
4750
+ if (stdout.length < MAX_OUTPUT2) stdout += text;
4751
+ spool.write(text);
4034
4752
  });
4035
4753
  child.stderr?.on("data", (chunk) => {
4036
- if (stderr.length < MAX_OUTPUT2) stderr += chunk.toString();
4754
+ const text = chunk.toString();
4755
+ if (stderr.length < MAX_OUTPUT2) stderr += text;
4756
+ spool.write(text);
4037
4757
  });
4038
4758
  child.on("close", (code) => {
4039
4759
  clearTimeout(timer);
@@ -4042,10 +4762,11 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
4042
4762
  const durationMs = Date.now() - startedAt;
4043
4763
  const exitCode = killed ? 124 : code ?? 1;
4044
4764
  registry.afterCall(durationMs, exitCode !== 0);
4765
+ const spooled = spool.finalize();
4045
4766
  resolve7({
4046
4767
  command: cmd,
4047
4768
  args,
4048
- stdout: normalizeCommandOutput(stdout),
4769
+ stdout: normalizeCommandOutput(stdout) + (spooled ? spoolNote(spooled) : ""),
4049
4770
  stderr: normalizeCommandOutput(stderr),
4050
4771
  exitCode,
4051
4772
  truncated: Buffer.byteLength(stdout, "utf8") > COMMAND_OUTPUT_MAX_BYTES || Buffer.byteLength(stderr, "utf8") > COMMAND_OUTPUT_MAX_BYTES,
@@ -4057,6 +4778,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
4057
4778
  if (isWin) signal.removeEventListener("abort", onAbort);
4058
4779
  if (typeof pid === "number") registry.unregister(pid);
4059
4780
  registry.afterCall(Date.now() - startedAt, true);
4781
+ spool.finalize();
4060
4782
  resolve7({
4061
4783
  command: cmd,
4062
4784
  args,
@@ -4487,13 +5209,13 @@ var formatTool = {
4487
5209
  }
4488
5210
  };
4489
5211
  async function detectFixer(cwd) {
4490
- const { stat: stat10 } = await import('node:fs/promises');
5212
+ const { stat: stat11 } = await import('node:fs/promises');
4491
5213
  try {
4492
- await stat10(`${cwd}/biome.json`);
5214
+ await stat11(`${cwd}/biome.json`);
4493
5215
  return "biome";
4494
5216
  } catch {
4495
5217
  try {
4496
- await stat10(`${cwd}/.prettierrc`);
5218
+ await stat11(`${cwd}/.prettierrc`);
4497
5219
  return "prettier";
4498
5220
  } catch {
4499
5221
  return "biome";
@@ -4636,8 +5358,8 @@ function findGitDir2(cwd, projectRoot) {
4636
5358
  let dir = cwd;
4637
5359
  for (let i = 0; i < 20; i++) {
4638
5360
  try {
4639
- const stat10 = statSync(`${dir}/.git`);
4640
- if (stat10.isDirectory() || stat10.isFile()) return dir;
5361
+ const stat11 = statSync(`${dir}/.git`);
5362
+ if (stat11.isDirectory() || stat11.isFile()) return dir;
4641
5363
  } catch {
4642
5364
  }
4643
5365
  if (dir === root) break;
@@ -4725,7 +5447,8 @@ function runGit2(args, cwd, signal) {
4725
5447
  cwd,
4726
5448
  signal,
4727
5449
  env: buildChildEnv(),
4728
- stdio: ["ignore", "pipe", "pipe"]
5450
+ stdio: ["ignore", "pipe", "pipe"],
5451
+ windowsHide: true
4729
5452
  });
4730
5453
  child.stdout?.on("data", (chunk) => {
4731
5454
  if (stdout.length < MAX_OUTPUT3) {
@@ -4801,7 +5524,7 @@ var globTool = {
4801
5524
  }
4802
5525
  let entries;
4803
5526
  try {
4804
- entries = await fs13.readdir(dir, { withFileTypes: true });
5527
+ entries = await fs14.readdir(dir, { withFileTypes: true });
4805
5528
  } catch {
4806
5529
  return;
4807
5530
  }
@@ -4810,14 +5533,14 @@ var globTool = {
4810
5533
  if (DEFAULT_IGNORE2.includes(name)) continue;
4811
5534
  if (ignored.includes(name)) continue;
4812
5535
  const rel = relPrefix ? `${relPrefix}/${name}` : name;
4813
- const full = path2.join(dir, name);
5536
+ const full = path3.join(dir, name);
4814
5537
  if (e.isDirectory()) {
4815
5538
  await walk(full, rel);
4816
5539
  if (truncated) return;
4817
5540
  } else if (e.isFile()) {
4818
5541
  if (re.test(rel) || re.test(name)) {
4819
5542
  try {
4820
- const st = await fs13.stat(full);
5543
+ const st = await fs14.stat(full);
4821
5544
  results.push({ rel: full, mtime: st.mtimeMs });
4822
5545
  if (results.length >= limit) {
4823
5546
  truncated = true;
@@ -4836,7 +5559,7 @@ var globTool = {
4836
5559
  };
4837
5560
  async function readGitignore(dir) {
4838
5561
  try {
4839
- const raw = await fs13.readFile(path2.join(dir, ".gitignore"), "utf8");
5562
+ const raw = await fs14.readFile(path3.join(dir, ".gitignore"), "utf8");
4840
5563
  return raw.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
4841
5564
  } catch {
4842
5565
  return [];
@@ -4970,7 +5693,7 @@ var grepTool = {
4970
5693
  async function detectRg(signal) {
4971
5694
  return new Promise((resolve7) => {
4972
5695
  try {
4973
- const p = spawn("rg", ["--version"], { env: buildChildEnv(), stdio: "ignore", signal });
5696
+ const p = spawn("rg", ["--version"], { env: buildChildEnv(), stdio: "ignore", signal, windowsHide: true });
4974
5697
  p.on("error", () => resolve7(false));
4975
5698
  p.on("close", (code) => resolve7(code === 0));
4976
5699
  } catch {
@@ -5000,7 +5723,7 @@ async function* runRgStream(input, base, mode, limit, signal) {
5000
5723
  const FLUSH_AT = 16;
5001
5724
  const MAX_BUF_BYTES = 1e6;
5002
5725
  let bufOverflow = false;
5003
- const child = spawn("rg", args, { signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"] });
5726
+ const child = spawn("rg", args, { signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
5004
5727
  const queue = [];
5005
5728
  let waiter;
5006
5729
  const wake = () => {
@@ -5120,7 +5843,7 @@ async function runNative(input, base, mode, limit, signal) {
5120
5843
  if (stopped || signal.aborted) return;
5121
5844
  let entries;
5122
5845
  try {
5123
- entries = await fs13.readdir(dir, { withFileTypes: true });
5846
+ entries = await fs14.readdir(dir, { withFileTypes: true });
5124
5847
  } catch {
5125
5848
  return;
5126
5849
  }
@@ -5128,16 +5851,16 @@ async function runNative(input, base, mode, limit, signal) {
5128
5851
  if (stopped) return;
5129
5852
  if (DEFAULT_IGNORE3.includes(e.name)) continue;
5130
5853
  if (e.isSymbolicLink()) continue;
5131
- const full = path2.join(dir, e.name);
5854
+ const full = path3.join(dir, e.name);
5132
5855
  if (e.isDirectory()) {
5133
5856
  await walk(full);
5134
5857
  } else if (e.isFile()) {
5135
5858
  if (globRe && !globRe.test(e.name) && !globRe.test(full)) continue;
5136
5859
  if (globRe) globRe.lastIndex = 0;
5137
5860
  try {
5138
- const stat10 = await fs13.stat(full);
5139
- if (stat10.size > 1e6) continue;
5140
- const head = await fs13.readFile(full);
5861
+ const stat11 = await fs14.stat(full);
5862
+ if (stat11.size > 1e6) continue;
5863
+ const head = await fs14.readFile(full);
5141
5864
  if (isBinaryBuffer(head)) continue;
5142
5865
  const text = head.toString("utf8");
5143
5866
  const lines = text.split(/\r?\n/);
@@ -5346,7 +6069,7 @@ var jsonTool = {
5346
6069
  let raw;
5347
6070
  if (input.file) {
5348
6071
  try {
5349
- raw = await fs13.readFile(input.file, "utf8");
6072
+ raw = await fs14.readFile(input.file, "utf8");
5350
6073
  } catch {
5351
6074
  return { data: null, formatted: "", type: "unknown", error: `Could not read file` };
5352
6075
  }
@@ -5384,8 +6107,8 @@ var jsonTool = {
5384
6107
  };
5385
6108
  }
5386
6109
  };
5387
- function query(data, path20) {
5388
- const parts = path20.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
6110
+ function query(data, path21) {
6111
+ const parts = path21.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
5389
6112
  let current = data;
5390
6113
  for (const part of parts) {
5391
6114
  if (current === null || current === void 0) return void 0;
@@ -5514,11 +6237,11 @@ var lintTool = {
5514
6237
  }
5515
6238
  };
5516
6239
  async function detectLinter(cwd) {
5517
- const { stat: stat10 } = await import('node:fs/promises');
6240
+ const { stat: stat11 } = await import('node:fs/promises');
5518
6241
  const checks = ["biome.json", ".eslintrc.json", "tslint.json", ".eslintrc.js", "tsconfig.json"];
5519
6242
  for (const f of checks) {
5520
6243
  try {
5521
- await stat10(`${cwd}/${f}`);
6244
+ await stat11(`${cwd}/${f}`);
5522
6245
  if (f.includes("biome")) return "biome";
5523
6246
  if (f.includes("eslint")) return "eslint";
5524
6247
  if (f.includes("tslint")) return "tslint";
@@ -5625,7 +6348,7 @@ async function dockerLogs(service, lines, filterRe, cwd, signal, since) {
5625
6348
  clearTimeout(timer);
5626
6349
  resolve7(result);
5627
6350
  };
5628
- const child = spawn("docker", args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"] });
6351
+ const child = spawn("docker", args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
5629
6352
  const timer = setTimeout(() => {
5630
6353
  child.kill("SIGTERM");
5631
6354
  finish(empty());
@@ -5656,7 +6379,7 @@ async function dockerLogs(service, lines, filterRe, cwd, signal, since) {
5656
6379
  }
5657
6380
  var DOCKER_LOGS_TIMEOUT_MS = 3e3;
5658
6381
  var MAX_TAIL_LINES = 1e5;
5659
- async function fileLogs(path20, lines, filterRe, stream) {
6382
+ async function fileLogs(path21, lines, filterRe, stream) {
5660
6383
  const { createInterface } = await import('node:readline');
5661
6384
  const { createReadStream } = await import('node:fs');
5662
6385
  const entries = [];
@@ -5665,7 +6388,7 @@ async function fileLogs(path20, lines, filterRe, stream) {
5665
6388
  let writeIdx = 0;
5666
6389
  let totalLines = 0;
5667
6390
  const rl = createInterface({
5668
- input: createReadStream(path20),
6391
+ input: createReadStream(path21),
5669
6392
  crlfDelay: Number.POSITIVE_INFINITY
5670
6393
  });
5671
6394
  for await (const line of rl) {
@@ -5686,7 +6409,7 @@ async function fileLogs(path20, lines, filterRe, stream) {
5686
6409
  if (parsed) entries.push(parsed);
5687
6410
  }
5688
6411
  return {
5689
- source: path20,
6412
+ source: path21,
5690
6413
  entries,
5691
6414
  total: entries.length,
5692
6415
  truncated: totalLines > effLines,
@@ -5771,7 +6494,7 @@ function runOutdated(manager, args, cwd, signal) {
5771
6494
  const MAX = 1e5;
5772
6495
  const resolved = resolveWin32Command(manager);
5773
6496
  const needsShell = process.platform === "win32" && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
5774
- const child = spawn(resolved, args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {} });
6497
+ const child = spawn(resolved, args, { cwd, signal, env: buildChildEnv(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true, ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {} });
5775
6498
  child.stdout?.on("data", (c) => {
5776
6499
  if (stdout.length < MAX) stdout += c.toString();
5777
6500
  });
@@ -5855,9 +6578,9 @@ var patchTool = {
5855
6578
  for (const t of targets) {
5856
6579
  const stripped = stripPathComponents(t, strip);
5857
6580
  if (!stripped) continue;
5858
- const candidate = path2.resolve(dir, stripped);
5859
- const rel = path2.relative(ctx.projectRoot, candidate);
5860
- if (rel.startsWith("..") || path2.isAbsolute(rel)) {
6581
+ const candidate = path3.resolve(dir, stripped);
6582
+ const rel = path3.relative(ctx.projectRoot, candidate);
6583
+ if (rel.startsWith("..") || path3.isAbsolute(rel)) {
5861
6584
  return {
5862
6585
  applied: 0,
5863
6586
  rejected: 1,
@@ -5867,12 +6590,12 @@ var patchTool = {
5867
6590
  };
5868
6591
  }
5869
6592
  }
5870
- const tmpDir = await fs13.mkdtemp(path2.join(os.tmpdir(), ".wstack_patch_"));
6593
+ const tmpDir = await fs14.mkdtemp(path3.join(os.tmpdir(), ".wstack_patch_"));
5871
6594
  try {
5872
- await fs13.chmod(tmpDir, 448).catch(() => {
6595
+ await fs14.chmod(tmpDir, 448).catch(() => {
5873
6596
  });
5874
- const patchFile = path2.join(tmpDir, "in.diff");
5875
- await fs13.writeFile(patchFile, input.patch, { mode: 384 });
6597
+ const patchFile = path3.join(tmpDir, "in.diff");
6598
+ await fs14.writeFile(patchFile, input.patch, { mode: 384 });
5876
6599
  const args = [`-p${strip}`, "--merge", ...dryRun ? ["--dry-run"] : [], "-i", patchFile];
5877
6600
  const result = await runPatch(args, dir, opts.signal);
5878
6601
  if (result.exitCode !== 0 && !dryRun) {
@@ -5893,7 +6616,7 @@ var patchTool = {
5893
6616
  message: result.stdout || "patch applied"
5894
6617
  };
5895
6618
  } finally {
5896
- await fs13.rm(tmpDir, { recursive: true, force: true }).catch(() => {
6619
+ await fs14.rm(tmpDir, { recursive: true, force: true }).catch(() => {
5897
6620
  });
5898
6621
  }
5899
6622
  }
@@ -5918,7 +6641,7 @@ function runPatch(args, cwd, signal) {
5918
6641
  let stdout = "";
5919
6642
  let stderr = "";
5920
6643
  const env = { ...buildChildEnv(), LANG: "C", LC_ALL: "C" };
5921
- const child = spawn("patch", args, { cwd, signal, env, stdio: ["pipe", "pipe", "pipe"] });
6644
+ const child = spawn("patch", args, { cwd, signal, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
5922
6645
  child.stdout?.on("data", (c) => {
5923
6646
  stdout += c.toString();
5924
6647
  });
@@ -6193,9 +6916,9 @@ var readTool = {
6193
6916
  async execute(input, ctx) {
6194
6917
  if (!input?.path) throw new Error("read: path is required");
6195
6918
  const absPath = await safeResolveReal(input.path, ctx);
6196
- let stat10;
6919
+ let stat11;
6197
6920
  try {
6198
- stat10 = await fs13.stat(absPath);
6921
+ stat11 = await fs14.stat(absPath);
6199
6922
  } catch (err) {
6200
6923
  const code = err.code;
6201
6924
  if (code === "ENOENT") throw new Error(`read: file not found "${input.path}"`);
@@ -6203,11 +6926,11 @@ var readTool = {
6203
6926
  `read: failed to stat "${input.path}": ${err instanceof Error ? err.message : String(err)}`
6204
6927
  );
6205
6928
  }
6206
- if (!stat10.isFile()) throw new Error(`read: "${input.path}" is not a regular file`);
6207
- if (stat10.size > MAX_BYTES2) {
6208
- throw new Error(`read: file too large (${stat10.size} bytes, limit ${MAX_BYTES2})`);
6929
+ if (!stat11.isFile()) throw new Error(`read: "${input.path}" is not a regular file`);
6930
+ if (stat11.size > MAX_BYTES2) {
6931
+ throw new Error(`read: file too large (${stat11.size} bytes, limit ${MAX_BYTES2})`);
6209
6932
  }
6210
- const buf = await fs13.readFile(absPath);
6933
+ const buf = await fs14.readFile(absPath);
6211
6934
  if (isBinaryBuffer(buf)) {
6212
6935
  throw new Error(`read: "${input.path}" appears to be binary`);
6213
6936
  }
@@ -6217,14 +6940,14 @@ var readTool = {
6217
6940
  const offset = Math.max(1, input.offset ?? 1);
6218
6941
  const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
6219
6942
  if (limit === 0) {
6220
- ctx.recordRead(absPath, stat10.mtimeMs);
6943
+ ctx.recordRead(absPath, stat11.mtimeMs);
6221
6944
  return { text: "", total_lines: total, encoding: "utf8", truncated: total > 0 };
6222
6945
  }
6223
6946
  const slice = allLines.slice(offset - 1, offset - 1 + limit);
6224
6947
  const truncated = offset - 1 + slice.length < total;
6225
6948
  const width = String(offset + slice.length - 1).length;
6226
6949
  const numbered = slice.map((line, i) => `${String(offset + i).padStart(width, " ")}\u2192${line}`).join("\n");
6227
- ctx.recordRead(absPath, stat10.mtimeMs);
6950
+ ctx.recordRead(absPath, stat11.mtimeMs);
6228
6951
  return {
6229
6952
  text: numbered,
6230
6953
  total_lines: total,
@@ -6275,11 +6998,11 @@ var replaceTool = {
6275
6998
  const dryRun = input.dry_run ?? false;
6276
6999
  const filesInput = Array.isArray(input.files) ? input.files.join(",") : input.files;
6277
7000
  const fileList = await resolveFiles2(filesInput, ctx, globRe);
6278
- const realRoot = await fs13.realpath(ctx.projectRoot).catch(() => ctx.projectRoot);
7001
+ const realRoot = await fs14.realpath(ctx.projectRoot).catch(() => ctx.projectRoot);
6279
7002
  const results = [];
6280
7003
  let totalReplacements = 0;
6281
7004
  for (const absPath of fileList) {
6282
- const lstat2 = await fs13.lstat(absPath).catch((err) => {
7005
+ const lstat2 = await fs14.lstat(absPath).catch((err) => {
6283
7006
  if (err.code === "ENOENT") return null;
6284
7007
  throw err;
6285
7008
  });
@@ -6287,17 +7010,17 @@ var replaceTool = {
6287
7010
  if (lstat2.isSymbolicLink()) continue;
6288
7011
  let realPath;
6289
7012
  try {
6290
- realPath = await fs13.realpath(absPath);
7013
+ realPath = await fs14.realpath(absPath);
6291
7014
  } catch {
6292
7015
  continue;
6293
7016
  }
6294
- const rel = path2.relative(realRoot, realPath);
6295
- if (rel.startsWith("..") || path2.isAbsolute(rel)) continue;
6296
- const stat10 = await fs13.stat(realPath).catch(() => null);
6297
- if (!stat10 || !stat10.isFile()) continue;
7017
+ const rel = path3.relative(realRoot, realPath);
7018
+ if (rel.startsWith("..") || path3.isAbsolute(rel)) continue;
7019
+ const stat11 = await fs14.stat(realPath).catch(() => null);
7020
+ if (!stat11 || !stat11.isFile()) continue;
6298
7021
  let content;
6299
7022
  try {
6300
- const buf = await fs13.readFile(realPath);
7023
+ const buf = await fs14.readFile(realPath);
6301
7024
  if (isBinaryBuffer(buf)) continue;
6302
7025
  content = buf.toString("utf8");
6303
7026
  } catch {
@@ -6319,7 +7042,7 @@ var replaceTool = {
6319
7042
  totalReplacements += count;
6320
7043
  if (!dryRun) {
6321
7044
  const newContent = toStyle(newContentLf, style);
6322
- await atomicWrite(realPath, newContent, { mode: stat10.mode & 511 });
7045
+ await atomicWrite(realPath, newContent, { mode: stat11.mode & 511 });
6323
7046
  }
6324
7047
  const diff = dryRun || matches.length > 0 ? unifiedDiff(content, toStyle(newContentLf, style), {
6325
7048
  fromFile: absPath,
@@ -6349,8 +7072,8 @@ async function resolveFiles2(filesInput, ctx, extraGlob) {
6349
7072
  const resolved = [];
6350
7073
  for (const p of parts) {
6351
7074
  const absPath = safeResolve(p, ctx);
6352
- const stat10 = await fs13.stat(absPath).catch(() => null);
6353
- if (stat10?.isFile()) {
7075
+ const stat11 = await fs14.stat(absPath).catch(() => null);
7076
+ if (stat11?.isFile()) {
6354
7077
  resolved.push(absPath);
6355
7078
  }
6356
7079
  }
@@ -6370,7 +7093,7 @@ async function globFiles(pattern, base, extraGlob) {
6370
7093
  function checkRg() {
6371
7094
  return new Promise((resolve7) => {
6372
7095
  try {
6373
- const p = spawn("rg", ["--version"], { env: buildChildEnv(), stdio: "ignore" });
7096
+ const p = spawn("rg", ["--version"], { env: buildChildEnv(), stdio: "ignore", windowsHide: true });
6374
7097
  p.on("error", () => resolve7(false));
6375
7098
  p.on("close", (code) => resolve7(code === 0));
6376
7099
  } catch {
@@ -6383,7 +7106,8 @@ function spawnRgFind(pattern, base) {
6383
7106
  const child = spawn("rg", args, {
6384
7107
  signal: AbortSignal.timeout(3e4),
6385
7108
  env: buildChildEnv(),
6386
- stdio: ["ignore", "pipe", "pipe"]
7109
+ stdio: ["ignore", "pipe", "pipe"],
7110
+ windowsHide: true
6387
7111
  });
6388
7112
  let buf = "";
6389
7113
  child.stdout?.on("data", (chunk) => {
@@ -6404,16 +7128,16 @@ async function globNative(pattern, base, extraGlob) {
6404
7128
  const walk = async (dir) => {
6405
7129
  let entries;
6406
7130
  try {
6407
- entries = await fs13.readdir(dir, { withFileTypes: true });
7131
+ entries = await fs14.readdir(dir, { withFileTypes: true });
6408
7132
  } catch {
6409
7133
  return;
6410
7134
  }
6411
7135
  for (const e of entries) {
6412
7136
  if (DEFAULT_IGNORE4.includes(e.name)) continue;
6413
- const full = path2.join(dir, e.name);
7137
+ const full = path3.join(dir, e.name);
6414
7138
  try {
6415
- const stat10 = await fs13.lstat(full);
6416
- if (stat10.isSymbolicLink()) continue;
7139
+ const stat11 = await fs14.lstat(full);
7140
+ if (stat11.isSymbolicLink()) continue;
6417
7141
  } catch {
6418
7142
  continue;
6419
7143
  }
@@ -6580,16 +7304,16 @@ async function handleBuiltIn(name, templateFiles, cwd, ctx, dryRun, vars) {
6580
7304
  let filesCreated = 0;
6581
7305
  for (const [filePath, content] of Object.entries(templateFiles)) {
6582
7306
  const resolvedPath = substituteVars(filePath, name, vars);
6583
- const joinedPath = path2.join(cwd, resolvedPath);
6584
- const root = path2.resolve(ctx.projectRoot);
6585
- const target = path2.resolve(joinedPath);
6586
- const rel = path2.relative(root, target);
6587
- if (rel.startsWith("..") || path2.isAbsolute(rel)) {
7307
+ const joinedPath = path3.join(cwd, resolvedPath);
7308
+ const root = path3.resolve(ctx.projectRoot);
7309
+ const target = path3.resolve(joinedPath);
7310
+ const rel = path3.relative(root, target);
7311
+ if (rel.startsWith("..") || path3.isAbsolute(rel)) {
6588
7312
  throw new Error(`scaffold: generated path "${resolvedPath}" would escape project root`);
6589
7313
  }
6590
7314
  const fullPath = target;
6591
7315
  if (!dryRun) {
6592
- await fs13.mkdir(path2.dirname(fullPath), { recursive: true });
7316
+ await fs14.mkdir(path3.dirname(fullPath), { recursive: true });
6593
7317
  await atomicWrite(fullPath, substituteVars(content, name, vars));
6594
7318
  }
6595
7319
  files.push(resolvedPath);
@@ -6867,7 +7591,7 @@ var setWorkingDirTool = {
6867
7591
  };
6868
7592
  }
6869
7593
  try {
6870
- await fs13.access(resolved);
7594
+ await fs14.access(resolved);
6871
7595
  } catch {
6872
7596
  try {
6873
7597
  ctx.setWorkingDir(previous);
@@ -7202,7 +7926,11 @@ var testTool = {
7202
7926
  coverage: { type: "boolean", description: "Generate coverage report (default: false)" },
7203
7927
  cwd: { type: "string", description: "Working directory (default: cwd)" },
7204
7928
  grep: { type: "string", description: "Filter tests by name pattern (default: none)" },
7205
- timeout: { type: "integer", description: "Test timeout in ms (default: 30000)" }
7929
+ timeout: { type: "integer", description: "Test timeout in ms (default: 30000)" },
7930
+ verbose: {
7931
+ type: "boolean",
7932
+ description: "Per-test verbose reporter output (default: false \u2014 the summary reporter is used; full output is always saved to a log file referenced in the result)"
7933
+ }
7206
7934
  }
7207
7935
  },
7208
7936
  async execute(input, ctx, opts) {
@@ -7250,11 +7978,11 @@ var testTool = {
7250
7978
  }
7251
7979
  };
7252
7980
  async function detectRunner(cwd) {
7253
- const { stat: stat10 } = await import('node:fs/promises');
7981
+ const { stat: stat11 } = await import('node:fs/promises');
7254
7982
  const candidates = ["vitest.config.ts", "jest.config.js", ".mocharc.json"];
7255
7983
  for (const f of candidates) {
7256
7984
  try {
7257
- await stat10(path2.join(cwd, f));
7985
+ await stat11(path3.join(cwd, f));
7258
7986
  if (f.includes("vitest")) return "vitest";
7259
7987
  if (f.includes("jest")) return "jest";
7260
7988
  if (f.includes("mocha")) return "mocha";
@@ -7268,17 +7996,14 @@ function buildArgs2(runner, input) {
7268
7996
  const timeout = input.timeout ?? 3e4;
7269
7997
  switch (runner) {
7270
7998
  case "vitest":
7271
- args.push("run", "--reporter=verbose");
7272
- if (input.watch) {
7273
- args[1] = "";
7274
- args.push("watch");
7275
- }
7999
+ args.push(input.watch ? "watch" : "run");
8000
+ if (input.verbose) args.push("--reporter=verbose");
7276
8001
  if (input.coverage) args.push("--coverage");
7277
8002
  if (input.grep) args.push("--testNamePattern", input.grep);
7278
8003
  args.push("--testTimeout", String(timeout));
7279
8004
  break;
7280
8005
  case "jest":
7281
- args.push("--verbose");
8006
+ if (input.verbose) args.push("--verbose");
7282
8007
  if (input.watch) args.push("--watch");
7283
8008
  if (input.coverage) args.push("--coverage");
7284
8009
  if (input.grep) args.push("--testPathPattern", input.grep);
@@ -7322,7 +8047,13 @@ function parseResult(runner, result, duration) {
7322
8047
  passed,
7323
8048
  failed,
7324
8049
  duration_ms: duration,
7325
- output: normalizeCommandOutput(result.stdout || result.error || ""),
8050
+ // A passing run only needs the tail summary in chat history — counts are
8051
+ // already parsed above and the FULL log is on disk (spool marker rides
8052
+ // the stdout tail). Failures keep the standard command-output cap so
8053
+ // the agent sees the failure details inline.
8054
+ output: normalizeCommandOutput(result.stdout || result.error || "", {
8055
+ maxBytes: result.exitCode === 0 ? 4096 : void 0
8056
+ }),
7326
8057
  truncated: result.truncated
7327
8058
  };
7328
8059
  }
@@ -7840,7 +8571,7 @@ var treeTool = {
7840
8571
  }
7841
8572
  };
7842
8573
  async function walkDir(dir, depth, opts) {
7843
- const entries = await fs13.readdir(dir, { withFileTypes: true }).catch(() => []);
8574
+ const entries = await fs14.readdir(dir, { withFileTypes: true }).catch(() => []);
7844
8575
  const filtered = entries.filter((e) => {
7845
8576
  if (!opts.showHidden && e.name.startsWith(".")) return false;
7846
8577
  if (opts.exclude.has(e.name)) return false;
@@ -7870,7 +8601,7 @@ async function walkDir(dir, depth, opts) {
7870
8601
  opts.lines.push(opts.prefix + branch + displayName);
7871
8602
  if (entry.isDirectory() && (opts.maxDepth === 0 || depth < opts.maxDepth)) {
7872
8603
  const childPrefix = opts.prefix + connector;
7873
- await walkDir(path2.join(dir, entry.name), depth + 1, {
8604
+ await walkDir(path3.join(dir, entry.name), depth + 1, {
7874
8605
  ...opts,
7875
8606
  prefix: childPrefix,
7876
8607
  isLast
@@ -7949,12 +8680,12 @@ var typecheckTool = {
7949
8680
  }
7950
8681
  };
7951
8682
  async function findTsConfig(cwd) {
7952
- const { stat: stat10 } = await import('node:fs/promises');
8683
+ const { stat: stat11 } = await import('node:fs/promises');
7953
8684
  const candidates = ["tsconfig.json", "tsconfig.base.json"];
7954
8685
  for (const f of candidates) {
7955
8686
  try {
7956
- const s = await stat10(path2.join(cwd, f));
7957
- if (s.isFile()) return path2.join(cwd, f);
8687
+ const s = await stat11(path3.join(cwd, f));
8688
+ if (s.isFile()) return path3.join(cwd, f);
7958
8689
  } catch {
7959
8690
  }
7960
8691
  }
@@ -7990,14 +8721,14 @@ var writeTool = {
7990
8721
  let existed = false;
7991
8722
  let prev = "";
7992
8723
  try {
7993
- const stat11 = await fs13.stat(absPath);
7994
- existed = stat11.isFile();
8724
+ const stat12 = await fs14.stat(absPath);
8725
+ existed = stat12.isFile();
7995
8726
  if (existed) {
7996
8727
  if (!ctx.hasRead(absPath)) {
7997
- prev = await fs13.readFile(absPath, "utf8");
7998
- ctx.recordRead(absPath, stat11.mtimeMs);
8728
+ prev = await fs14.readFile(absPath, "utf8");
8729
+ ctx.recordRead(absPath, stat12.mtimeMs);
7999
8730
  } else {
8000
- prev = await fs13.readFile(absPath, "utf8");
8731
+ prev = await fs14.readFile(absPath, "utf8");
8001
8732
  }
8002
8733
  }
8003
8734
  } catch (err) {
@@ -8008,8 +8739,8 @@ var writeTool = {
8008
8739
  await atomicWrite(absPath, input.content);
8009
8740
  const diff = existed ? unifiedDiff(prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
8010
8741
  + (new file, ${input.content.split("\n").length} lines)`;
8011
- const stat10 = await fs13.stat(absPath);
8012
- ctx.recordRead(absPath, stat10.mtimeMs);
8742
+ const stat11 = await fs14.stat(absPath);
8743
+ ctx.recordRead(absPath, stat11.mtimeMs);
8013
8744
  ctx.session.recordFileChange({
8014
8745
  path: absPath,
8015
8746
  action: existed ? "modified" : "created",