@wrongstack/tools 0.250.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.
package/dist/builtin.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { spawn, execFileSync, spawnSync } from 'node:child_process';
2
2
  import * as Core from '@wrongstack/core';
3
- import { buildChildEnv, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff, compileGlob, expectDefined, recordPackageAction, detectPackageEcosystem, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, setPlanItemStatus, loadTasks, emptyTaskFile, saveTasks, formatTaskList, formatPlan, mutateTasks, loadPlan, emptyPlan, savePlan, computeTaskItemProgress, resolveWstackPaths, truncate } from '@wrongstack/core';
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';
7
- import { resolve, sep, dirname, join } from 'node:path';
5
+ import { statSync, mkdirSync, createWriteStream, writeFileSync } from 'node:fs';
8
6
  import * as fs14 from 'node:fs/promises';
7
+ import * as path3 from 'node:path';
8
+ import { resolve, sep, dirname, join } from 'node:path';
9
9
  import * as os from 'node:os';
10
10
  import { createRequire } from 'node:module';
11
11
  import { fileURLToPath } from 'node:url';
@@ -17,370 +17,106 @@ import { Agent } from 'undici';
17
17
  import { randomUUID } from 'node:crypto';
18
18
 
19
19
  // src/_spawn-stream.ts
20
- function resolveWin32Command(cmd) {
21
- if (process.platform !== "win32") return cmd;
22
- if (cmd.includes("/") || cmd.includes("\\") || path2.extname(cmd)) {
23
- return cmd;
24
- }
25
- const pathext = (process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.WS;.MSC").toLowerCase().split(";");
26
- const pathDirs = (process.env["PATH"] ?? "").split(path2.delimiter);
27
- for (const dir of pathDirs) {
28
- const base = path2.join(dir, cmd);
29
- for (const ext of pathext) {
30
- const full = `${base}${ext}`;
31
- try {
32
- fs.accessSync(full, fs.constants.X_OK);
33
- return full;
34
- } 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
+ }
35
40
  }
41
+ } catch {
36
42
  }
37
- }
38
- return cmd;
39
- }
40
-
41
- // src/_spawn-stream.ts
42
- async function* spawnStream(opts) {
43
- const max = opts.maxBytes ?? 2e5;
44
- const flushAt = opts.flushBytes ?? 4 * 1024;
45
- const maxQueue = opts.maxQueueSize ?? 500;
46
- let stdout = "";
47
- let stderr = "";
48
- let pending2 = "";
49
- let error;
50
- const cmd = resolveWin32Command(opts.cmd);
51
- const needsShell = process.platform === "win32" && (cmd.endsWith(".cmd") || cmd.endsWith(".bat"));
52
- const child = spawn(cmd, opts.args, {
53
- cwd: opts.cwd,
54
- signal: opts.signal,
55
- env: buildChildEnv(),
56
- stdio: ["ignore", "pipe", "pipe"],
57
- windowsHide: true,
58
- ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {}
59
- });
60
- const queue = [];
61
- let waiter;
62
- let paused = false;
63
- const wake = () => {
64
- if (waiter) {
65
- const w = waiter;
66
- waiter = void 0;
67
- w();
68
- }
69
- };
70
- const resume = () => {
71
- if (paused && queue.length < maxQueue) {
72
- paused = false;
73
- child.stdout?.resume();
74
- child.stderr?.resume();
75
- }
76
- };
77
- child.stdout?.on("data", (c) => {
78
- const s = c.toString();
79
- if (stdout.length < max) stdout += s;
80
- queue.push({ kind: "out", data: s });
81
- wake();
82
- if (!paused && queue.length >= maxQueue) {
83
- paused = true;
84
- child.stdout?.pause();
85
- child.stderr?.pause();
86
- }
87
- });
88
- child.stderr?.on("data", (c) => {
89
- const s = c.toString();
90
- if (stderr.length < max) stderr += s;
91
- queue.push({ kind: "err", data: s });
92
- wake();
93
- if (!paused && queue.length >= maxQueue) {
94
- paused = true;
95
- child.stdout?.pause();
96
- child.stderr?.pause();
97
- }
98
- });
99
- child.on("error", (e) => {
100
- error = e.message;
101
- queue.push({ kind: "error", data: e.message });
102
- wake();
103
- });
104
- child.on("close", (code) => {
105
- queue.push({ kind: "close", data: "", code: code ?? 0 });
106
- wake();
107
- });
108
- let exitCode = 0;
109
- let spawnFailed = false;
110
- for (; ; ) {
111
- while (queue.length === 0) {
112
- await new Promise((resolve7) => {
113
- waiter = resolve7;
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;
114
75
  });
76
+ stream.write(head);
77
+ } catch {
78
+ failed = true;
79
+ stream = null;
80
+ filePath = null;
115
81
  }
116
- const chunk = queue.shift();
117
- resume();
118
- if (chunk.kind === "close") {
119
- if (!spawnFailed) exitCode = chunk.code ?? 0;
120
- break;
121
- }
122
- if (chunk.kind === "error") {
123
- spawnFailed = true;
124
- exitCode = 1;
125
- continue;
126
- }
127
- pending2 += chunk.data;
128
- if (pending2.length >= flushAt) {
129
- yield { type: "partial_output", text: pending2 };
130
- pending2 = "";
131
- }
132
- }
133
- if (pending2.length > 0) {
134
- yield { type: "partial_output", text: pending2 };
135
- }
136
- return {
137
- stdout,
138
- stderr,
139
- exitCode,
140
- truncated: stdout.length >= max || stderr.length >= max,
141
- error
142
82
  };
143
- }
144
- async function detectPackageManager(cwd) {
145
- const { stat: stat10 } = await import('node:fs/promises');
146
- try {
147
- await stat10(`${cwd}/pnpm-lock.yaml`);
148
- return "pnpm";
149
- } catch {
150
- }
151
- try {
152
- await stat10(`${cwd}/yarn.lock`);
153
- return "yarn";
154
- } catch {
155
- }
156
- return "npm";
157
- }
158
- function resolvePath(input, ctx) {
159
- return path2.isAbsolute(input) ? path2.normalize(input) : path2.resolve(ctx.workingDir ?? ctx.cwd, input);
160
- }
161
- function ensureInsideRoot(absPath, ctx) {
162
- const root = path2.resolve(ctx.projectRoot);
163
- const target = path2.resolve(absPath);
164
- const rel = path2.relative(root, target);
165
- if (rel.startsWith("..") || path2.isAbsolute(rel)) {
166
- throw new Error(`Path "${absPath}" is outside project root "${root}"`);
167
- }
168
- return target;
169
- }
170
- function safeResolve(input, ctx) {
171
- return ensureInsideRoot(resolvePath(input, ctx), ctx);
172
- }
173
- async function assertRealInsideRoot(absPath, ctx) {
174
- const realRoot = await fs14.realpath(ctx.projectRoot).catch(() => path2.resolve(ctx.projectRoot));
175
- let probe = absPath;
176
- for (; ; ) {
177
- let real;
178
- try {
179
- real = await fs14.realpath(probe);
180
- } catch (err) {
181
- if (err.code === "ENOENT") {
182
- const parent = path2.dirname(probe);
183
- if (parent === probe) return;
184
- probe = parent;
185
- continue;
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;
186
97
  }
187
- throw err;
188
- }
189
- const rel = path2.relative(realRoot, real);
190
- if (rel.startsWith("..") || path2.isAbsolute(rel)) {
191
- throw new Error(
192
- `Path "${absPath}" resolves through a symlink outside project root "${realRoot}"`
193
- );
194
- }
195
- return;
196
- }
197
- }
198
- async function safeResolveReal(input, ctx) {
199
- const abs = safeResolve(input, ctx);
200
- await assertRealInsideRoot(abs, ctx);
201
- return abs;
202
- }
203
- function truncateMiddle(s, max) {
204
- if (Buffer.byteLength(s, "utf8") <= max) return s;
205
- const half = Math.floor(max / 2);
206
- return s.slice(0, half) + `
207
- \u2026[truncated ${Buffer.byteLength(s, "utf8") - max} bytes from middle]\u2026
208
- ` + s.slice(-half);
209
- }
210
- function isBinaryBuffer(buf) {
211
- const len = Math.min(buf.length, 8192);
212
- for (let i = 0; i < len; i++) {
213
- if (buf[i] === 0) return true;
214
- }
215
- return false;
216
- }
217
- var COMMAND_OUTPUT_MAX_BYTES = 32768;
218
- var REPEAT_RUN_THRESHOLD = 3;
219
- function collapseCarriageReturns(text) {
220
- const lf = text.replace(/\r\n/g, "\n");
221
- if (!lf.includes("\r")) return lf;
222
- return lf.split("\n").map((line) => line.includes("\r") ? line.slice(line.lastIndexOf("\r") + 1) : line).join("\n");
223
- }
224
- function collapseConsecutiveDuplicates(text, minRun = REPEAT_RUN_THRESHOLD) {
225
- const lines = text.split("\n");
226
- const out = [];
227
- let i = 0;
228
- while (i < lines.length) {
229
- let j = i + 1;
230
- while (j < lines.length && lines[j] === lines[i]) j++;
231
- const run = j - i;
232
- if (run >= minRun) {
233
- out.push(lines[i], `\u2026 \u27E8repeated ${run}\xD7\u27E9`);
234
- } else {
235
- for (let k = i; k < j; k++) out.push(lines[k]);
236
- }
237
- i = j;
238
- }
239
- return out.join("\n");
240
- }
241
- function takeHeadBytes(s, maxBytes) {
242
- if (maxBytes <= 0) return "";
243
- if (Buffer.byteLength(s, "utf8") <= maxBytes) return s;
244
- let lo = 0;
245
- let hi = s.length;
246
- while (lo < hi) {
247
- const mid = Math.ceil((lo + hi) / 2);
248
- if (Buffer.byteLength(s.slice(0, mid), "utf8") <= maxBytes) lo = mid;
249
- else hi = mid - 1;
250
- }
251
- return s.slice(0, lo);
252
- }
253
- function takeTailBytes(s, maxBytes) {
254
- if (maxBytes <= 0) return "";
255
- if (Buffer.byteLength(s, "utf8") <= maxBytes) return s;
256
- let lo = 0;
257
- let hi = s.length;
258
- while (lo < hi) {
259
- const mid = Math.ceil((lo + hi) / 2);
260
- if (Buffer.byteLength(s.slice(s.length - mid), "utf8") <= maxBytes) lo = mid;
261
- else hi = mid - 1;
262
- }
263
- return s.slice(s.length - lo);
264
- }
265
- function truncateHeadTail(s, maxBytes) {
266
- const total = Buffer.byteLength(s, "utf8");
267
- if (total <= maxBytes) return s;
268
- const MARKER_RESERVE = 64;
269
- const avail = Math.max(0, maxBytes - MARKER_RESERVE);
270
- const headBudget = Math.floor(avail * 0.45);
271
- const head = takeHeadBytes(s, headBudget);
272
- const tail = takeTailBytes(s, avail - Buffer.byteLength(head, "utf8"));
273
- const kept = Buffer.byteLength(head, "utf8") + Buffer.byteLength(tail, "utf8");
274
- return `${head}
275
- \u2026[truncated ${total - kept} bytes]\u2026
276
- ${tail}`;
277
- }
278
- function normalizeCommandOutput(raw, opts = {}) {
279
- if (!raw) return raw;
280
- let text = Core.stripAnsi(raw);
281
- text = collapseCarriageReturns(text);
282
- text = text.replace(/[ \t]+$/gm, "");
283
- text = collapseConsecutiveDuplicates(text);
284
- text = text.replace(/\n{3,}/g, "\n\n");
285
- return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);
286
- }
287
-
288
- // src/audit.ts
289
- var auditTool = {
290
- name: "audit",
291
- category: "Package Management",
292
- description: "Run a security audit against project dependencies (using pnpm/npm audit). Reports known vulnerabilities with severity.",
293
- 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.",
294
- permission: "confirm",
295
- mutating: false,
296
- timeoutMs: 6e4,
297
- inputSchema: {
298
- type: "object",
299
- properties: {
300
- cwd: { type: "string", description: "Working directory (default: cwd)" },
301
- level: {
302
- type: "string",
303
- enum: ["low", "moderate", "high", "critical"],
304
- description: "Minimum severity level to report"
305
- },
306
- fix: { type: "boolean", description: "Attempt to fix vulnerabilities (default: false)" },
307
- packages: { type: "string", description: "Specific package(s) to audit (comma-separated)" }
308
- }
309
- },
310
- async execute(input, ctx, opts) {
311
- let final;
312
- const executeStream = auditTool.executeStream;
313
- if (!executeStream) throw new Error("auditTool: stream execution unavailable");
314
- for await (const ev of executeStream(input, ctx, opts)) {
315
- if (ev.type === "final") final = ev.output;
316
- }
317
- if (!final) throw new Error("audit: stream ended without final event");
318
- return final;
319
- },
320
- async *executeStream(input, ctx, opts) {
321
- const cwd = input.cwd ? safeResolve(input.cwd, ctx) : ctx.cwd;
322
- const manager = await detectPackageManager(cwd);
323
- yield { type: "log", text: `Auditing with ${manager}\u2026`, data: { manager } };
324
- const args = ["audit", "--json"];
325
- if (input.fix) args.push("--fix");
326
- if (input.packages) {
327
- const pkgs = Array.isArray(input.packages) ? input.packages : input.packages.split(",");
328
- args.push(...pkgs.map((p) => p.trim()));
329
- }
330
- const result = yield* spawnStream({
331
- cmd: manager,
332
- args,
333
- cwd,
334
- signal: opts.signal,
335
- maxBytes: 1e5
336
- });
337
- yield { type: "final", output: parseAuditOutput(result.stdout, result.exitCode) };
338
- }
339
- };
340
- function parseAuditOutput(json, exitCode) {
341
- if (!json) {
342
- return {
343
- exit_code: exitCode,
344
- vulnerabilities: [],
345
- total: 0,
346
- summary: exitCode === 0 ? "No vulnerabilities found" : "Audit failed",
347
- output: "",
348
- truncated: false
349
- };
350
- }
351
- try {
352
- const data = JSON.parse(json);
353
- const advisories = [];
354
- const ads = data.advisories ?? {};
355
- for (const id of Object.keys(ads)) {
356
- const adv = ads[id];
357
- advisories.push({
358
- severity: adv.severity ?? "unknown",
359
- package: adv.module_name ?? id,
360
- title: adv.title ?? "Unknown vulnerability",
361
- url: adv.url ?? ""
362
- });
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 };
363
118
  }
364
- const total = advisories.length;
365
- 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`;
366
- return {
367
- exit_code: exitCode,
368
- vulnerabilities: advisories,
369
- total,
370
- summary,
371
- output: json,
372
- truncated: json.length >= 1e5
373
- };
374
- } catch {
375
- return {
376
- exit_code: exitCode,
377
- vulnerabilities: [],
378
- total: 0,
379
- summary: "Could not parse audit output",
380
- output: json,
381
- truncated: false
382
- };
383
- }
119
+ };
384
120
  }
385
121
 
386
122
  // src/circuit-breaker.ts
@@ -726,43 +462,464 @@ var ProcessRegistryImpl = class {
726
462
  }, graceMs);
727
463
  timer.unref?.();
728
464
  }
729
- } catch {
465
+ } catch {
466
+ }
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);
480
+ }
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);
492
+ }
493
+ return killed;
494
+ }
495
+ };
496
+ var _registry;
497
+ function getProcessRegistry() {
498
+ if (!_registry) {
499
+ _registry = new ProcessRegistryImpl();
500
+ }
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;
507
+ }
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
+ }
519
+ }
520
+ }
521
+ return cmd;
522
+ }
523
+
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
554
+ });
555
+ }
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;
625
+ try {
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
+ }
681
+ }
682
+ }
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 {
689
+ }
690
+ try {
691
+ await stat11(`${cwd}/yarn.lock`);
692
+ return "yarn";
693
+ } catch {
694
+ }
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}"`);
706
+ }
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;
730
727
  }
731
- p.killed = true;
732
- return true;
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;
733
735
  }
734
- /**
735
- * Kill all tracked processes.
736
- * Returns the PIDs that were kill()ed.
737
- */
738
- killAll(opts = {}) {
739
- const pids = Array.from(this.processes.keys());
740
- const killed = [];
741
- for (const pid of pids) {
742
- const p = this.processes.get(pid);
743
- if (p && !p.protected && this.kill(pid, opts)) killed.push(pid);
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;
753
+ }
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]);
744
775
  }
745
- return killed;
776
+ i = j;
746
777
  }
747
- /**
748
- * Kill all processes for a specific session.
749
- * Returns the PIDs that were kill()ed.
750
- */
751
- killSession(sessionId, opts = {}) {
752
- const pids = this.bySession(sessionId).map((p) => p.pid);
753
- const killed = [];
754
- for (const pid of pids) {
755
- if (this.kill(pid, opts)) killed.push(pid);
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;
789
+ }
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;
801
+ }
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)" }
756
847
  }
757
- return killed;
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;
855
+ }
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()));
868
+ }
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) };
758
877
  }
759
878
  };
760
- var _registry;
761
- function getProcessRegistry() {
762
- if (!_registry) {
763
- _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
+ };
764
922
  }
765
- return _registry;
766
923
  }
767
924
 
768
925
  // src/bash.ts
@@ -858,7 +1015,7 @@ var bashTool = {
858
1015
  })();
859
1016
  const args = isWin ? ["/c", input.command] : ["-c", input.command];
860
1017
  const env = buildChildEnv(ctx.session?.id);
861
- const detached = isWin ? !!input.background : true;
1018
+ const detached = !isWin;
862
1019
  const startedAt = Date.now();
863
1020
  if (input.background) {
864
1021
  let buf2 = "";
@@ -867,10 +1024,14 @@ var bashTool = {
867
1024
  cwd: ctx.projectRoot,
868
1025
  env,
869
1026
  stdio: ["ignore", "pipe", "pipe"],
870
- detached: true,
871
- // Detached console children on Windows allocate their own VISIBLE
872
- // console window (one per background command test suites flash
873
- // dozens). CREATE_NO_WINDOW suppresses it; no-op elsewhere.
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,
874
1035
  windowsHide: true,
875
1036
  signal: opts.signal
876
1037
  });
@@ -886,24 +1047,22 @@ var bashTool = {
886
1047
  });
887
1048
  child2.on("close", () => registry.unregister(pid2));
888
1049
  }
889
- child2.stdout?.on("data", (chunk) => {
890
- if (!truncated) {
891
- const remain = MAX_OUTPUT - buf2.length;
892
- if (remain > 0) {
893
- buf2 += chunk.toString().slice(0, remain);
894
- }
895
- 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);
896
1055
  }
897
- });
898
- child2.stderr?.on("data", (chunk) => {
899
- if (!truncated) {
900
- const remain = MAX_OUTPUT - buf2.length;
901
- if (remain > 0) {
902
- buf2 += chunk.toString().slice(0, remain);
903
- }
904
- 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);
905
1060
  }
906
- });
1061
+ };
1062
+ child2.stdout?.on("data", onBgData);
1063
+ child2.stderr?.on("data", onBgData);
1064
+ child2.stdout?.unref?.();
1065
+ child2.stderr?.unref?.();
907
1066
  child2.on("close", () => {
908
1067
  registry.afterCall(Date.now() - startedAt, false, bypassBreaker);
909
1068
  });
@@ -942,6 +1101,7 @@ var bashTool = {
942
1101
  let pending2 = "";
943
1102
  let timedOut = false;
944
1103
  const timers = [];
1104
+ const spool = createOutputSpool({ tool: "bash", thresholdBytes: MAX_OUTPUT });
945
1105
  function killWithTimeout(child2, timeoutMs2) {
946
1106
  if (isWin) {
947
1107
  if (typeof child2.pid === "number" && child2.exitCode === null && killWin32Tree(child2.pid)) {
@@ -1047,6 +1207,7 @@ var bashTool = {
1047
1207
  if (buf.length < MAX_OUTPUT) {
1048
1208
  buf += text.slice(0, MAX_OUTPUT - buf.length);
1049
1209
  }
1210
+ spool.write(text);
1050
1211
  pending2 += text;
1051
1212
  push({ kind: "data", text });
1052
1213
  pauseIfFlooded();
@@ -1074,10 +1235,11 @@ var bashTool = {
1074
1235
  if (remainder !== null) {
1075
1236
  yield { type: "partial_output", text: remainder };
1076
1237
  }
1238
+ const spooled = spool.finalize();
1077
1239
  yield {
1078
1240
  type: "final",
1079
1241
  output: {
1080
- output: normalizeCommandOutput(buf),
1242
+ output: normalizeCommandOutput(buf) + (spooled ? spoolNote(spooled) : ""),
1081
1243
  exit_code: c.code,
1082
1244
  timed_out: timedOut
1083
1245
  }
@@ -1092,6 +1254,7 @@ var bashTool = {
1092
1254
  }
1093
1255
  } finally {
1094
1256
  for (const t of timers) clearTimeout(t);
1257
+ spool.finalize();
1095
1258
  if (isWin) opts.signal.removeEventListener("abort", onAbort);
1096
1259
  child.stdout?.off("data", onData);
1097
1260
  child.stderr?.off("data", onData);
@@ -1515,7 +1678,7 @@ var IndexStore = class {
1515
1678
  this.indexDir = resolveIndexDir(projectRoot, opts.indexDir);
1516
1679
  fs.mkdirSync(this.indexDir, { recursive: true });
1517
1680
  const Database = loadDatabaseSync();
1518
- this.db = new Database(path2.join(this.indexDir, DB_FILE));
1681
+ this.db = new Database(path3.join(this.indexDir, DB_FILE));
1519
1682
  try {
1520
1683
  this.db.exec("PRAGMA journal_mode = WAL");
1521
1684
  this.db.exec("PRAGMA busy_timeout = 5000");
@@ -1955,7 +2118,7 @@ var IndexStore = class {
1955
2118
  }));
1956
2119
  }
1957
2120
  sizeBytes() {
1958
- const dbPath = path2.join(this.indexDir, DB_FILE);
2121
+ const dbPath = path3.join(this.indexDir, DB_FILE);
1959
2122
  try {
1960
2123
  return fs.statSync(dbPath).size;
1961
2124
  } catch {
@@ -2394,10 +2557,10 @@ func formatType(t ast.Expr) string {
2394
2557
  }
2395
2558
  `;
2396
2559
  function syncGoParse(filePath, content, lang) {
2397
- const tmpDir = path2.join(os.tmpdir(), "ws-go-parse");
2560
+ const tmpDir = path3.join(os.tmpdir(), "ws-go-parse");
2398
2561
  try {
2399
2562
  mkdirSync(tmpDir, { recursive: true });
2400
- const scriptPath = path2.join(tmpDir, "parse.go");
2563
+ const scriptPath = path3.join(tmpDir, "parse.go");
2401
2564
  writeFileSync(scriptPath, GO_PARSE_SCRIPT, "utf8");
2402
2565
  const stdout = execFileSync("go", ["run", scriptPath], {
2403
2566
  input: content,
@@ -2641,9 +2804,9 @@ print(json.dumps([s.to_dict() for s in syms]))
2641
2804
  `;
2642
2805
  function syncPyParse(filePath, lang) {
2643
2806
  try {
2644
- const tmpDir = path2.join(os.tmpdir(), "ws-py-parse");
2807
+ const tmpDir = path3.join(os.tmpdir(), "ws-py-parse");
2645
2808
  mkdirSync(tmpDir, { recursive: true });
2646
- const scriptPath = path2.join(tmpDir, "parse.py");
2809
+ const scriptPath = path3.join(tmpDir, "parse.py");
2647
2810
  writeFileSync(scriptPath, PY_PARSE_SCRIPT, "utf8");
2648
2811
  const stdout = execFileSync("python", [scriptPath, filePath], {
2649
2812
  timeout: 15e3,
@@ -2684,7 +2847,7 @@ function parseSymbols4(opts) {
2684
2847
  function checkNativeParser() {
2685
2848
  try {
2686
2849
  execFileSync("rustc", ["--version"], { stdio: "pipe", windowsHide: true });
2687
- const toolsDir = path2.join(process.cwd(), "tools");
2850
+ const toolsDir = path3.join(process.cwd(), "tools");
2688
2851
  try {
2689
2852
  execFileSync(
2690
2853
  "cargo",
@@ -2694,7 +2857,7 @@ function checkNativeParser() {
2694
2857
  "--format-version",
2695
2858
  "1",
2696
2859
  "--manifest-path",
2697
- path2.join(toolsDir, "Cargo.toml")
2860
+ path3.join(toolsDir, "Cargo.toml")
2698
2861
  ],
2699
2862
  { stdio: "pipe", windowsHide: true }
2700
2863
  );
@@ -2708,13 +2871,13 @@ function checkNativeParser() {
2708
2871
  }
2709
2872
  function tryNativeParse(file, content) {
2710
2873
  try {
2711
- const toolsDir = path2.join(process.cwd(), "tools");
2712
- const crateDir = path2.join(toolsDir, "syn-parser");
2713
- 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");
2714
2877
  writeFileSync(tmpFile, content, "utf8");
2715
2878
  const result = spawnSync(
2716
2879
  "cargo",
2717
- ["run", "--manifest-path", path2.join(toolsDir, "Cargo.toml")],
2880
+ ["run", "--manifest-path", path3.join(toolsDir, "Cargo.toml")],
2718
2881
  {
2719
2882
  cwd: process.cwd(),
2720
2883
  encoding: "utf8",
@@ -2813,7 +2976,7 @@ function parseSymbols5(opts) {
2813
2976
  function regexParse2(opts) {
2814
2977
  const { file, content, lang } = opts;
2815
2978
  const symbols = [];
2816
- const basename2 = path2.basename(file).toLowerCase();
2979
+ const basename2 = path3.basename(file).toLowerCase();
2817
2980
  const isPackageJson = basename2 === "package.json";
2818
2981
  const isTsconfig = basename2 === "tsconfig.json" || basename2 === "tsconfig.build.json";
2819
2982
  const isJsonSchema = content.includes("$schema") || content.includes("$id") || content.includes("$ref");
@@ -2839,11 +3002,11 @@ function regexParse2(opts) {
2839
3002
  const line = lineFromOffset(offset);
2840
3003
  symbols.push(
2841
3004
  makeSymbol({
2842
- name: path2.basename(file),
3005
+ name: path3.basename(file),
2843
3006
  kind: "object",
2844
3007
  line,
2845
3008
  col: 0,
2846
- signature: `"${path2.basename(file)}" = { ... }`,
3009
+ signature: `"${path3.basename(file)}" = { ... }`,
2847
3010
  file,
2848
3011
  lang
2849
3012
  })
@@ -3193,7 +3356,7 @@ function compileGitignore(lines) {
3193
3356
  async function loadGitignoreMatcher(projectRoot) {
3194
3357
  let lines = [];
3195
3358
  try {
3196
- const raw = await fs14.readFile(path2.join(projectRoot, ".gitignore"), "utf8");
3359
+ const raw = await fs14.readFile(path3.join(projectRoot, ".gitignore"), "utf8");
3197
3360
  lines = raw.split("\n");
3198
3361
  } catch {
3199
3362
  }
@@ -3257,14 +3420,14 @@ async function findSourceFiles(projectRoot, ignore, isGitIgnored, signal) {
3257
3420
  dirCount++;
3258
3421
  for (const e of entries) {
3259
3422
  if (ignoreSet.has(e.name)) continue;
3260
- const full = path2.join(dir, e.name);
3261
- 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, "/");
3262
3425
  if (e.isDirectory()) {
3263
3426
  if (isGitIgnored(rel, true)) continue;
3264
3427
  await walk(full);
3265
3428
  } else if (e.isFile()) {
3266
3429
  if (isGitIgnored(rel, false)) continue;
3267
- const ext = path2.extname(e.name);
3430
+ const ext = path3.extname(e.name);
3268
3431
  for (const { ext: extName, pat } of globs) {
3269
3432
  if (ext === extName && (pat.test(rel) || pat.test(e.name))) {
3270
3433
  results.push(full);
@@ -3319,7 +3482,7 @@ async function runIndexerWithStore(store, opts) {
3319
3482
  const isGitIgnored = await loadGitignoreMatcher(projectRoot);
3320
3483
  let files;
3321
3484
  if (opts.files && opts.files.length > 0) {
3322
- 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));
3323
3486
  } else {
3324
3487
  files = await findSourceFiles(projectRoot, ignore, isGitIgnored, signal);
3325
3488
  }
@@ -3342,20 +3505,20 @@ async function runIndexerWithStore(store, opts) {
3342
3505
  await yieldEventLoop();
3343
3506
  throwIfAborted(signal);
3344
3507
  }
3345
- let stat10;
3508
+ let stat11;
3346
3509
  try {
3347
3510
  const statOpts = signal ? { signal } : {};
3348
- stat10 = await fs14.stat(file, statOpts);
3511
+ stat11 = await fs14.stat(file, statOpts);
3349
3512
  } catch (e) {
3350
3513
  if (isAbortError(e)) throw e;
3351
3514
  store.deleteFile(file);
3352
3515
  continue;
3353
3516
  }
3354
- if (!stat10.isFile()) continue;
3517
+ if (!stat11.isFile()) continue;
3355
3518
  const lang = detectLang(file);
3356
3519
  if (!lang) continue;
3357
3520
  const meta = existingMeta.get(file);
3358
- if (!force && meta && meta.mtimeMs === Math.floor(stat10.mtimeMs)) {
3521
+ if (!force && meta && meta.mtimeMs === Math.floor(stat11.mtimeMs)) {
3359
3522
  langStats[lang] = (langStats[lang] ?? 0) + meta.symbolCount;
3360
3523
  symbolsIndexed += meta.symbolCount;
3361
3524
  filesIndexed++;
@@ -3382,7 +3545,7 @@ async function runIndexerWithStore(store, opts) {
3382
3545
  store.upsertFile({
3383
3546
  file,
3384
3547
  lang,
3385
- mtimeMs: Math.floor(stat10.mtimeMs),
3548
+ mtimeMs: Math.floor(stat11.mtimeMs),
3386
3549
  symbolCount: 0,
3387
3550
  lastIndexed: Date.now()
3388
3551
  });
@@ -3408,7 +3571,7 @@ async function runIndexerWithStore(store, opts) {
3408
3571
  store.upsertFile({
3409
3572
  file,
3410
3573
  lang,
3411
- mtimeMs: Math.floor(stat10.mtimeMs),
3574
+ mtimeMs: Math.floor(stat11.mtimeMs),
3412
3575
  symbolCount: count,
3413
3576
  lastIndexed: Date.now()
3414
3577
  });
@@ -3666,6 +3829,13 @@ function circuitOpenError() {
3666
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."
3667
3830
  );
3668
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
+ }
3669
3839
  async function runStartupIndex(opts) {
3670
3840
  if (!indexCircuitBreaker.allowRequest()) throw circuitOpenError();
3671
3841
  _indexing = true;
@@ -3695,6 +3865,15 @@ async function runStartupIndex(opts) {
3695
3865
  return result;
3696
3866
  } catch (err) {
3697
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
+ }
3698
3877
  _ready = true;
3699
3878
  if (!opts.signal?.aborted) indexCircuitBreaker.recordFailure(err);
3700
3879
  throw err;
@@ -3997,11 +4176,11 @@ function findGitDir(cwd) {
3997
4176
  let dir = cwd;
3998
4177
  for (let i = 0; i < 20; i++) {
3999
4178
  try {
4000
- const stat10 = statSync(path2.join(dir, ".git"));
4001
- if (stat10.isDirectory()) return dir;
4179
+ const stat11 = statSync(path3.join(dir, ".git"));
4180
+ if (stat11.isDirectory()) return dir;
4002
4181
  } catch {
4003
4182
  }
4004
- const parent = path2.dirname(dir);
4183
+ const parent = path3.dirname(dir);
4005
4184
  if (parent === dir) break;
4006
4185
  dir = parent;
4007
4186
  }
@@ -4042,8 +4221,8 @@ async function fileDiff(input, ctx, _signal) {
4042
4221
  const results = [];
4043
4222
  for (const file of files) {
4044
4223
  const absPath = safeResolve(file, ctx);
4045
- const stat10 = await fs14.stat(absPath).catch(() => null);
4046
- if (!stat10?.isFile()) continue;
4224
+ const stat11 = await fs14.stat(absPath).catch(() => null);
4225
+ if (!stat11?.isFile()) continue;
4047
4226
  const content = await fs14.readFile(absPath, "utf8");
4048
4227
  const lines = content.split(/\r?\n/);
4049
4228
  results.push(formatWithLineNumbers(file, lines));
@@ -4142,8 +4321,8 @@ async function resolveFiles(filesInput, cwd) {
4142
4321
  for (const f of files) {
4143
4322
  const absPath = f.trim().startsWith("/") ? f.trim() : `${cwd}/${f.trim()}`;
4144
4323
  try {
4145
- const stat10 = await fs14.stat(absPath);
4146
- if (stat10.isFile()) resolved.push(absPath);
4324
+ const stat11 = await fs14.stat(absPath);
4325
+ if (stat11.isFile()) resolved.push(absPath);
4147
4326
  } catch {
4148
4327
  }
4149
4328
  }
@@ -4234,13 +4413,13 @@ var editTool = {
4234
4413
  if (input.new_string === void 0) throw new Error("edit: new_string is required");
4235
4414
  if (input.old_string === "") throw new Error("edit: old_string cannot be empty");
4236
4415
  const absPath = await safeResolveReal(input.path, ctx);
4237
- const stat10 = await fs14.stat(absPath).catch((err) => {
4416
+ const stat11 = await fs14.stat(absPath).catch((err) => {
4238
4417
  if (err.code === "ENOENT") {
4239
4418
  throw new Error(`edit: file "${input.path}" does not exist. Use \`write\` instead.`);
4240
4419
  }
4241
4420
  throw err;
4242
4421
  });
4243
- 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`);
4244
4423
  if (!ctx.hasRead(absPath)) {
4245
4424
  throw new Error(`edit: file "${input.path}" was not read in this session. Read it first.`);
4246
4425
  }
@@ -4510,9 +4689,9 @@ var execTool = {
4510
4689
  allowed: false
4511
4690
  };
4512
4691
  }
4513
- const requestedCwd = input.cwd ? path2.resolve(ctx.projectRoot, input.cwd) : ctx.cwd;
4514
- const rel = path2.relative(ctx.projectRoot, requestedCwd);
4515
- 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)) {
4516
4695
  return {
4517
4696
  command: cmd,
4518
4697
  args,
@@ -4534,6 +4713,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
4534
4713
  let stderr = "";
4535
4714
  let killed = false;
4536
4715
  const startedAt = Date.now();
4716
+ const spool = createOutputSpool({ tool: `exec-${cmd}`, thresholdBytes: MAX_OUTPUT2 });
4537
4717
  const resolved = resolveWin32Command(cmd);
4538
4718
  const isWin = process.platform === "win32";
4539
4719
  const needsShell = isWin && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
@@ -4566,10 +4746,14 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
4566
4746
  else signal.addEventListener("abort", onAbort, { once: true });
4567
4747
  }
4568
4748
  child.stdout?.on("data", (chunk) => {
4569
- 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);
4570
4752
  });
4571
4753
  child.stderr?.on("data", (chunk) => {
4572
- 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);
4573
4757
  });
4574
4758
  child.on("close", (code) => {
4575
4759
  clearTimeout(timer);
@@ -4578,10 +4762,11 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
4578
4762
  const durationMs = Date.now() - startedAt;
4579
4763
  const exitCode = killed ? 124 : code ?? 1;
4580
4764
  registry.afterCall(durationMs, exitCode !== 0);
4765
+ const spooled = spool.finalize();
4581
4766
  resolve7({
4582
4767
  command: cmd,
4583
4768
  args,
4584
- stdout: normalizeCommandOutput(stdout),
4769
+ stdout: normalizeCommandOutput(stdout) + (spooled ? spoolNote(spooled) : ""),
4585
4770
  stderr: normalizeCommandOutput(stderr),
4586
4771
  exitCode,
4587
4772
  truncated: Buffer.byteLength(stdout, "utf8") > COMMAND_OUTPUT_MAX_BYTES || Buffer.byteLength(stderr, "utf8") > COMMAND_OUTPUT_MAX_BYTES,
@@ -4593,6 +4778,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
4593
4778
  if (isWin) signal.removeEventListener("abort", onAbort);
4594
4779
  if (typeof pid === "number") registry.unregister(pid);
4595
4780
  registry.afterCall(Date.now() - startedAt, true);
4781
+ spool.finalize();
4596
4782
  resolve7({
4597
4783
  command: cmd,
4598
4784
  args,
@@ -5023,13 +5209,13 @@ var formatTool = {
5023
5209
  }
5024
5210
  };
5025
5211
  async function detectFixer(cwd) {
5026
- const { stat: stat10 } = await import('node:fs/promises');
5212
+ const { stat: stat11 } = await import('node:fs/promises');
5027
5213
  try {
5028
- await stat10(`${cwd}/biome.json`);
5214
+ await stat11(`${cwd}/biome.json`);
5029
5215
  return "biome";
5030
5216
  } catch {
5031
5217
  try {
5032
- await stat10(`${cwd}/.prettierrc`);
5218
+ await stat11(`${cwd}/.prettierrc`);
5033
5219
  return "prettier";
5034
5220
  } catch {
5035
5221
  return "biome";
@@ -5172,8 +5358,8 @@ function findGitDir2(cwd, projectRoot) {
5172
5358
  let dir = cwd;
5173
5359
  for (let i = 0; i < 20; i++) {
5174
5360
  try {
5175
- const stat10 = statSync(`${dir}/.git`);
5176
- if (stat10.isDirectory() || stat10.isFile()) return dir;
5361
+ const stat11 = statSync(`${dir}/.git`);
5362
+ if (stat11.isDirectory() || stat11.isFile()) return dir;
5177
5363
  } catch {
5178
5364
  }
5179
5365
  if (dir === root) break;
@@ -5347,7 +5533,7 @@ var globTool = {
5347
5533
  if (DEFAULT_IGNORE2.includes(name)) continue;
5348
5534
  if (ignored.includes(name)) continue;
5349
5535
  const rel = relPrefix ? `${relPrefix}/${name}` : name;
5350
- const full = path2.join(dir, name);
5536
+ const full = path3.join(dir, name);
5351
5537
  if (e.isDirectory()) {
5352
5538
  await walk(full, rel);
5353
5539
  if (truncated) return;
@@ -5373,7 +5559,7 @@ var globTool = {
5373
5559
  };
5374
5560
  async function readGitignore(dir) {
5375
5561
  try {
5376
- const raw = await fs14.readFile(path2.join(dir, ".gitignore"), "utf8");
5562
+ const raw = await fs14.readFile(path3.join(dir, ".gitignore"), "utf8");
5377
5563
  return raw.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
5378
5564
  } catch {
5379
5565
  return [];
@@ -5665,15 +5851,15 @@ async function runNative(input, base, mode, limit, signal) {
5665
5851
  if (stopped) return;
5666
5852
  if (DEFAULT_IGNORE3.includes(e.name)) continue;
5667
5853
  if (e.isSymbolicLink()) continue;
5668
- const full = path2.join(dir, e.name);
5854
+ const full = path3.join(dir, e.name);
5669
5855
  if (e.isDirectory()) {
5670
5856
  await walk(full);
5671
5857
  } else if (e.isFile()) {
5672
5858
  if (globRe && !globRe.test(e.name) && !globRe.test(full)) continue;
5673
5859
  if (globRe) globRe.lastIndex = 0;
5674
5860
  try {
5675
- const stat10 = await fs14.stat(full);
5676
- if (stat10.size > 1e6) continue;
5861
+ const stat11 = await fs14.stat(full);
5862
+ if (stat11.size > 1e6) continue;
5677
5863
  const head = await fs14.readFile(full);
5678
5864
  if (isBinaryBuffer(head)) continue;
5679
5865
  const text = head.toString("utf8");
@@ -5921,8 +6107,8 @@ var jsonTool = {
5921
6107
  };
5922
6108
  }
5923
6109
  };
5924
- function query(data, path20) {
5925
- 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);
5926
6112
  let current = data;
5927
6113
  for (const part of parts) {
5928
6114
  if (current === null || current === void 0) return void 0;
@@ -6051,11 +6237,11 @@ var lintTool = {
6051
6237
  }
6052
6238
  };
6053
6239
  async function detectLinter(cwd) {
6054
- const { stat: stat10 } = await import('node:fs/promises');
6240
+ const { stat: stat11 } = await import('node:fs/promises');
6055
6241
  const checks = ["biome.json", ".eslintrc.json", "tslint.json", ".eslintrc.js", "tsconfig.json"];
6056
6242
  for (const f of checks) {
6057
6243
  try {
6058
- await stat10(`${cwd}/${f}`);
6244
+ await stat11(`${cwd}/${f}`);
6059
6245
  if (f.includes("biome")) return "biome";
6060
6246
  if (f.includes("eslint")) return "eslint";
6061
6247
  if (f.includes("tslint")) return "tslint";
@@ -6193,7 +6379,7 @@ async function dockerLogs(service, lines, filterRe, cwd, signal, since) {
6193
6379
  }
6194
6380
  var DOCKER_LOGS_TIMEOUT_MS = 3e3;
6195
6381
  var MAX_TAIL_LINES = 1e5;
6196
- async function fileLogs(path20, lines, filterRe, stream) {
6382
+ async function fileLogs(path21, lines, filterRe, stream) {
6197
6383
  const { createInterface } = await import('node:readline');
6198
6384
  const { createReadStream } = await import('node:fs');
6199
6385
  const entries = [];
@@ -6202,7 +6388,7 @@ async function fileLogs(path20, lines, filterRe, stream) {
6202
6388
  let writeIdx = 0;
6203
6389
  let totalLines = 0;
6204
6390
  const rl = createInterface({
6205
- input: createReadStream(path20),
6391
+ input: createReadStream(path21),
6206
6392
  crlfDelay: Number.POSITIVE_INFINITY
6207
6393
  });
6208
6394
  for await (const line of rl) {
@@ -6223,7 +6409,7 @@ async function fileLogs(path20, lines, filterRe, stream) {
6223
6409
  if (parsed) entries.push(parsed);
6224
6410
  }
6225
6411
  return {
6226
- source: path20,
6412
+ source: path21,
6227
6413
  entries,
6228
6414
  total: entries.length,
6229
6415
  truncated: totalLines > effLines,
@@ -6392,9 +6578,9 @@ var patchTool = {
6392
6578
  for (const t of targets) {
6393
6579
  const stripped = stripPathComponents(t, strip);
6394
6580
  if (!stripped) continue;
6395
- const candidate = path2.resolve(dir, stripped);
6396
- const rel = path2.relative(ctx.projectRoot, candidate);
6397
- 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)) {
6398
6584
  return {
6399
6585
  applied: 0,
6400
6586
  rejected: 1,
@@ -6404,11 +6590,11 @@ var patchTool = {
6404
6590
  };
6405
6591
  }
6406
6592
  }
6407
- const tmpDir = await fs14.mkdtemp(path2.join(os.tmpdir(), ".wstack_patch_"));
6593
+ const tmpDir = await fs14.mkdtemp(path3.join(os.tmpdir(), ".wstack_patch_"));
6408
6594
  try {
6409
6595
  await fs14.chmod(tmpDir, 448).catch(() => {
6410
6596
  });
6411
- const patchFile = path2.join(tmpDir, "in.diff");
6597
+ const patchFile = path3.join(tmpDir, "in.diff");
6412
6598
  await fs14.writeFile(patchFile, input.patch, { mode: 384 });
6413
6599
  const args = [`-p${strip}`, "--merge", ...dryRun ? ["--dry-run"] : [], "-i", patchFile];
6414
6600
  const result = await runPatch(args, dir, opts.signal);
@@ -6730,9 +6916,9 @@ var readTool = {
6730
6916
  async execute(input, ctx) {
6731
6917
  if (!input?.path) throw new Error("read: path is required");
6732
6918
  const absPath = await safeResolveReal(input.path, ctx);
6733
- let stat10;
6919
+ let stat11;
6734
6920
  try {
6735
- stat10 = await fs14.stat(absPath);
6921
+ stat11 = await fs14.stat(absPath);
6736
6922
  } catch (err) {
6737
6923
  const code = err.code;
6738
6924
  if (code === "ENOENT") throw new Error(`read: file not found "${input.path}"`);
@@ -6740,9 +6926,9 @@ var readTool = {
6740
6926
  `read: failed to stat "${input.path}": ${err instanceof Error ? err.message : String(err)}`
6741
6927
  );
6742
6928
  }
6743
- if (!stat10.isFile()) throw new Error(`read: "${input.path}" is not a regular file`);
6744
- if (stat10.size > MAX_BYTES2) {
6745
- 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})`);
6746
6932
  }
6747
6933
  const buf = await fs14.readFile(absPath);
6748
6934
  if (isBinaryBuffer(buf)) {
@@ -6754,14 +6940,14 @@ var readTool = {
6754
6940
  const offset = Math.max(1, input.offset ?? 1);
6755
6941
  const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
6756
6942
  if (limit === 0) {
6757
- ctx.recordRead(absPath, stat10.mtimeMs);
6943
+ ctx.recordRead(absPath, stat11.mtimeMs);
6758
6944
  return { text: "", total_lines: total, encoding: "utf8", truncated: total > 0 };
6759
6945
  }
6760
6946
  const slice = allLines.slice(offset - 1, offset - 1 + limit);
6761
6947
  const truncated = offset - 1 + slice.length < total;
6762
6948
  const width = String(offset + slice.length - 1).length;
6763
6949
  const numbered = slice.map((line, i) => `${String(offset + i).padStart(width, " ")}\u2192${line}`).join("\n");
6764
- ctx.recordRead(absPath, stat10.mtimeMs);
6950
+ ctx.recordRead(absPath, stat11.mtimeMs);
6765
6951
  return {
6766
6952
  text: numbered,
6767
6953
  total_lines: total,
@@ -6828,10 +7014,10 @@ var replaceTool = {
6828
7014
  } catch {
6829
7015
  continue;
6830
7016
  }
6831
- const rel = path2.relative(realRoot, realPath);
6832
- if (rel.startsWith("..") || path2.isAbsolute(rel)) continue;
6833
- const stat10 = await fs14.stat(realPath).catch(() => null);
6834
- 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;
6835
7021
  let content;
6836
7022
  try {
6837
7023
  const buf = await fs14.readFile(realPath);
@@ -6856,7 +7042,7 @@ var replaceTool = {
6856
7042
  totalReplacements += count;
6857
7043
  if (!dryRun) {
6858
7044
  const newContent = toStyle(newContentLf, style);
6859
- await atomicWrite(realPath, newContent, { mode: stat10.mode & 511 });
7045
+ await atomicWrite(realPath, newContent, { mode: stat11.mode & 511 });
6860
7046
  }
6861
7047
  const diff = dryRun || matches.length > 0 ? unifiedDiff(content, toStyle(newContentLf, style), {
6862
7048
  fromFile: absPath,
@@ -6886,8 +7072,8 @@ async function resolveFiles2(filesInput, ctx, extraGlob) {
6886
7072
  const resolved = [];
6887
7073
  for (const p of parts) {
6888
7074
  const absPath = safeResolve(p, ctx);
6889
- const stat10 = await fs14.stat(absPath).catch(() => null);
6890
- if (stat10?.isFile()) {
7075
+ const stat11 = await fs14.stat(absPath).catch(() => null);
7076
+ if (stat11?.isFile()) {
6891
7077
  resolved.push(absPath);
6892
7078
  }
6893
7079
  }
@@ -6948,10 +7134,10 @@ async function globNative(pattern, base, extraGlob) {
6948
7134
  }
6949
7135
  for (const e of entries) {
6950
7136
  if (DEFAULT_IGNORE4.includes(e.name)) continue;
6951
- const full = path2.join(dir, e.name);
7137
+ const full = path3.join(dir, e.name);
6952
7138
  try {
6953
- const stat10 = await fs14.lstat(full);
6954
- if (stat10.isSymbolicLink()) continue;
7139
+ const stat11 = await fs14.lstat(full);
7140
+ if (stat11.isSymbolicLink()) continue;
6955
7141
  } catch {
6956
7142
  continue;
6957
7143
  }
@@ -7118,16 +7304,16 @@ async function handleBuiltIn(name, templateFiles, cwd, ctx, dryRun, vars) {
7118
7304
  let filesCreated = 0;
7119
7305
  for (const [filePath, content] of Object.entries(templateFiles)) {
7120
7306
  const resolvedPath = substituteVars(filePath, name, vars);
7121
- const joinedPath = path2.join(cwd, resolvedPath);
7122
- const root = path2.resolve(ctx.projectRoot);
7123
- const target = path2.resolve(joinedPath);
7124
- const rel = path2.relative(root, target);
7125
- 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)) {
7126
7312
  throw new Error(`scaffold: generated path "${resolvedPath}" would escape project root`);
7127
7313
  }
7128
7314
  const fullPath = target;
7129
7315
  if (!dryRun) {
7130
- await fs14.mkdir(path2.dirname(fullPath), { recursive: true });
7316
+ await fs14.mkdir(path3.dirname(fullPath), { recursive: true });
7131
7317
  await atomicWrite(fullPath, substituteVars(content, name, vars));
7132
7318
  }
7133
7319
  files.push(resolvedPath);
@@ -7740,7 +7926,11 @@ var testTool = {
7740
7926
  coverage: { type: "boolean", description: "Generate coverage report (default: false)" },
7741
7927
  cwd: { type: "string", description: "Working directory (default: cwd)" },
7742
7928
  grep: { type: "string", description: "Filter tests by name pattern (default: none)" },
7743
- 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
+ }
7744
7934
  }
7745
7935
  },
7746
7936
  async execute(input, ctx, opts) {
@@ -7788,11 +7978,11 @@ var testTool = {
7788
7978
  }
7789
7979
  };
7790
7980
  async function detectRunner(cwd) {
7791
- const { stat: stat10 } = await import('node:fs/promises');
7981
+ const { stat: stat11 } = await import('node:fs/promises');
7792
7982
  const candidates = ["vitest.config.ts", "jest.config.js", ".mocharc.json"];
7793
7983
  for (const f of candidates) {
7794
7984
  try {
7795
- await stat10(path2.join(cwd, f));
7985
+ await stat11(path3.join(cwd, f));
7796
7986
  if (f.includes("vitest")) return "vitest";
7797
7987
  if (f.includes("jest")) return "jest";
7798
7988
  if (f.includes("mocha")) return "mocha";
@@ -7806,17 +7996,14 @@ function buildArgs2(runner, input) {
7806
7996
  const timeout = input.timeout ?? 3e4;
7807
7997
  switch (runner) {
7808
7998
  case "vitest":
7809
- args.push("run", "--reporter=verbose");
7810
- if (input.watch) {
7811
- args[1] = "";
7812
- args.push("watch");
7813
- }
7999
+ args.push(input.watch ? "watch" : "run");
8000
+ if (input.verbose) args.push("--reporter=verbose");
7814
8001
  if (input.coverage) args.push("--coverage");
7815
8002
  if (input.grep) args.push("--testNamePattern", input.grep);
7816
8003
  args.push("--testTimeout", String(timeout));
7817
8004
  break;
7818
8005
  case "jest":
7819
- args.push("--verbose");
8006
+ if (input.verbose) args.push("--verbose");
7820
8007
  if (input.watch) args.push("--watch");
7821
8008
  if (input.coverage) args.push("--coverage");
7822
8009
  if (input.grep) args.push("--testPathPattern", input.grep);
@@ -7860,7 +8047,13 @@ function parseResult(runner, result, duration) {
7860
8047
  passed,
7861
8048
  failed,
7862
8049
  duration_ms: duration,
7863
- 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
+ }),
7864
8057
  truncated: result.truncated
7865
8058
  };
7866
8059
  }
@@ -8408,7 +8601,7 @@ async function walkDir(dir, depth, opts) {
8408
8601
  opts.lines.push(opts.prefix + branch + displayName);
8409
8602
  if (entry.isDirectory() && (opts.maxDepth === 0 || depth < opts.maxDepth)) {
8410
8603
  const childPrefix = opts.prefix + connector;
8411
- await walkDir(path2.join(dir, entry.name), depth + 1, {
8604
+ await walkDir(path3.join(dir, entry.name), depth + 1, {
8412
8605
  ...opts,
8413
8606
  prefix: childPrefix,
8414
8607
  isLast
@@ -8487,12 +8680,12 @@ var typecheckTool = {
8487
8680
  }
8488
8681
  };
8489
8682
  async function findTsConfig(cwd) {
8490
- const { stat: stat10 } = await import('node:fs/promises');
8683
+ const { stat: stat11 } = await import('node:fs/promises');
8491
8684
  const candidates = ["tsconfig.json", "tsconfig.base.json"];
8492
8685
  for (const f of candidates) {
8493
8686
  try {
8494
- const s = await stat10(path2.join(cwd, f));
8495
- 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);
8496
8689
  } catch {
8497
8690
  }
8498
8691
  }
@@ -8528,12 +8721,12 @@ var writeTool = {
8528
8721
  let existed = false;
8529
8722
  let prev = "";
8530
8723
  try {
8531
- const stat11 = await fs14.stat(absPath);
8532
- existed = stat11.isFile();
8724
+ const stat12 = await fs14.stat(absPath);
8725
+ existed = stat12.isFile();
8533
8726
  if (existed) {
8534
8727
  if (!ctx.hasRead(absPath)) {
8535
8728
  prev = await fs14.readFile(absPath, "utf8");
8536
- ctx.recordRead(absPath, stat11.mtimeMs);
8729
+ ctx.recordRead(absPath, stat12.mtimeMs);
8537
8730
  } else {
8538
8731
  prev = await fs14.readFile(absPath, "utf8");
8539
8732
  }
@@ -8546,8 +8739,8 @@ var writeTool = {
8546
8739
  await atomicWrite(absPath, input.content);
8547
8740
  const diff = existed ? unifiedDiff(prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
8548
8741
  + (new file, ${input.content.split("\n").length} lines)`;
8549
- const stat10 = await fs14.stat(absPath);
8550
- ctx.recordRead(absPath, stat10.mtimeMs);
8742
+ const stat11 = await fs14.stat(absPath);
8743
+ ctx.recordRead(absPath, stat11.mtimeMs);
8551
8744
  ctx.session.recordFileChange({
8552
8745
  path: absPath,
8553
8746
  action: existed ? "modified" : "created",