@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/index.js CHANGED
@@ -2,11 +2,11 @@ import * as fs4 from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import { resolve, sep, dirname, join } from 'node:path';
4
4
  import * as Core from '@wrongstack/core';
5
- import { atomicWrite, unifiedDiff, detectNewlineStyle, normalizeToLf, toStyle, compileGlob, expectDefined, buildChildEnv, loadPlan, setPlanItemStatus, savePlan, loadTasks, saveTasks, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, emptyTaskFile, formatTaskList, formatPlan, recordPackageAction, detectPackageEcosystem, mutateTasks, emptyPlan, computeTaskItemProgress, resolveWstackPaths, truncate } from '@wrongstack/core';
5
+ import { atomicWrite, unifiedDiff, detectNewlineStyle, normalizeToLf, toStyle, compileGlob, expectDefined, buildChildEnv, loadPlan, setPlanItemStatus, savePlan, loadTasks, saveTasks, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, emptyTaskFile, formatTaskList, formatPlan, recordPackageAction, detectPackageEcosystem, mutateTasks, emptyPlan, computeTaskItemProgress, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
6
6
  import { spawn, execFileSync, spawnSync } from 'node:child_process';
7
7
  import * as os from 'node:os';
8
8
  import * as fs7 from 'node:fs';
9
- import { statSync, writeFileSync, mkdirSync } from 'node:fs';
9
+ import { statSync, mkdirSync, createWriteStream, writeFileSync } from 'node:fs';
10
10
  import * as dns from 'node:dns/promises';
11
11
  import * as net from 'node:net';
12
12
  import { Agent } from 'undici';
@@ -18,14 +18,14 @@ import { randomUUID } from 'node:crypto';
18
18
 
19
19
  // src/read.ts
20
20
  async function detectPackageManager(cwd) {
21
- const { stat: stat10 } = await import('node:fs/promises');
21
+ const { stat: stat11 } = await import('node:fs/promises');
22
22
  try {
23
- await stat10(`${cwd}/pnpm-lock.yaml`);
23
+ await stat11(`${cwd}/pnpm-lock.yaml`);
24
24
  return "pnpm";
25
25
  } catch {
26
26
  }
27
27
  try {
28
- await stat10(`${cwd}/yarn.lock`);
28
+ await stat11(`${cwd}/yarn.lock`);
29
29
  return "yarn";
30
30
  } catch {
31
31
  }
@@ -194,9 +194,9 @@ var readTool = {
194
194
  async execute(input, ctx) {
195
195
  if (!input?.path) throw new Error("read: path is required");
196
196
  const absPath = await safeResolveReal(input.path, ctx);
197
- let stat10;
197
+ let stat11;
198
198
  try {
199
- stat10 = await fs4.stat(absPath);
199
+ stat11 = await fs4.stat(absPath);
200
200
  } catch (err) {
201
201
  const code = err.code;
202
202
  if (code === "ENOENT") throw new Error(`read: file not found "${input.path}"`);
@@ -204,9 +204,9 @@ var readTool = {
204
204
  `read: failed to stat "${input.path}": ${err instanceof Error ? err.message : String(err)}`
205
205
  );
206
206
  }
207
- if (!stat10.isFile()) throw new Error(`read: "${input.path}" is not a regular file`);
208
- if (stat10.size > MAX_BYTES) {
209
- throw new Error(`read: file too large (${stat10.size} bytes, limit ${MAX_BYTES})`);
207
+ if (!stat11.isFile()) throw new Error(`read: "${input.path}" is not a regular file`);
208
+ if (stat11.size > MAX_BYTES) {
209
+ throw new Error(`read: file too large (${stat11.size} bytes, limit ${MAX_BYTES})`);
210
210
  }
211
211
  const buf = await fs4.readFile(absPath);
212
212
  if (isBinaryBuffer(buf)) {
@@ -218,14 +218,14 @@ var readTool = {
218
218
  const offset = Math.max(1, input.offset ?? 1);
219
219
  const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
220
220
  if (limit === 0) {
221
- ctx.recordRead(absPath, stat10.mtimeMs);
221
+ ctx.recordRead(absPath, stat11.mtimeMs);
222
222
  return { text: "", total_lines: total, encoding: "utf8", truncated: total > 0 };
223
223
  }
224
224
  const slice = allLines.slice(offset - 1, offset - 1 + limit);
225
225
  const truncated = offset - 1 + slice.length < total;
226
226
  const width = String(offset + slice.length - 1).length;
227
227
  const numbered = slice.map((line, i) => `${String(offset + i).padStart(width, " ")}\u2192${line}`).join("\n");
228
- ctx.recordRead(absPath, stat10.mtimeMs);
228
+ ctx.recordRead(absPath, stat11.mtimeMs);
229
229
  return {
230
230
  text: numbered,
231
231
  total_lines: total,
@@ -264,12 +264,12 @@ var writeTool = {
264
264
  let existed = false;
265
265
  let prev = "";
266
266
  try {
267
- const stat11 = await fs4.stat(absPath);
268
- existed = stat11.isFile();
267
+ const stat12 = await fs4.stat(absPath);
268
+ existed = stat12.isFile();
269
269
  if (existed) {
270
270
  if (!ctx.hasRead(absPath)) {
271
271
  prev = await fs4.readFile(absPath, "utf8");
272
- ctx.recordRead(absPath, stat11.mtimeMs);
272
+ ctx.recordRead(absPath, stat12.mtimeMs);
273
273
  } else {
274
274
  prev = await fs4.readFile(absPath, "utf8");
275
275
  }
@@ -282,8 +282,8 @@ var writeTool = {
282
282
  await atomicWrite(absPath, input.content);
283
283
  const diff = existed ? unifiedDiff(prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
284
284
  + (new file, ${input.content.split("\n").length} lines)`;
285
- const stat10 = await fs4.stat(absPath);
286
- ctx.recordRead(absPath, stat10.mtimeMs);
285
+ const stat11 = await fs4.stat(absPath);
286
+ ctx.recordRead(absPath, stat11.mtimeMs);
287
287
  ctx.session.recordFileChange({
288
288
  path: absPath,
289
289
  action: existed ? "modified" : "created",
@@ -323,13 +323,13 @@ var editTool = {
323
323
  if (input.new_string === void 0) throw new Error("edit: new_string is required");
324
324
  if (input.old_string === "") throw new Error("edit: old_string cannot be empty");
325
325
  const absPath = await safeResolveReal(input.path, ctx);
326
- const stat10 = await fs4.stat(absPath).catch((err) => {
326
+ const stat11 = await fs4.stat(absPath).catch((err) => {
327
327
  if (err.code === "ENOENT") {
328
328
  throw new Error(`edit: file "${input.path}" does not exist. Use \`write\` instead.`);
329
329
  }
330
330
  throw err;
331
331
  });
332
- if (!stat10.isFile()) throw new Error(`edit: "${input.path}" is not a regular file`);
332
+ if (!stat11.isFile()) throw new Error(`edit: "${input.path}" is not a regular file`);
333
333
  if (!ctx.hasRead(absPath)) {
334
334
  throw new Error(`edit: file "${input.path}" was not read in this session. Read it first.`);
335
335
  }
@@ -524,8 +524,8 @@ var replaceTool = {
524
524
  }
525
525
  const rel = path.relative(realRoot, realPath);
526
526
  if (rel.startsWith("..") || path.isAbsolute(rel)) continue;
527
- const stat10 = await fs4.stat(realPath).catch(() => null);
528
- if (!stat10 || !stat10.isFile()) continue;
527
+ const stat11 = await fs4.stat(realPath).catch(() => null);
528
+ if (!stat11 || !stat11.isFile()) continue;
529
529
  let content;
530
530
  try {
531
531
  const buf = await fs4.readFile(realPath);
@@ -550,7 +550,7 @@ var replaceTool = {
550
550
  totalReplacements += count;
551
551
  if (!dryRun) {
552
552
  const newContent = toStyle(newContentLf, style);
553
- await atomicWrite(realPath, newContent, { mode: stat10.mode & 511 });
553
+ await atomicWrite(realPath, newContent, { mode: stat11.mode & 511 });
554
554
  }
555
555
  const diff = dryRun || matches.length > 0 ? unifiedDiff(content, toStyle(newContentLf, style), {
556
556
  fromFile: absPath,
@@ -580,8 +580,8 @@ async function resolveFiles(filesInput, ctx, extraGlob) {
580
580
  const resolved = [];
581
581
  for (const p of parts) {
582
582
  const absPath = safeResolve(p, ctx);
583
- const stat10 = await fs4.stat(absPath).catch(() => null);
584
- if (stat10?.isFile()) {
583
+ const stat11 = await fs4.stat(absPath).catch(() => null);
584
+ if (stat11?.isFile()) {
585
585
  resolved.push(absPath);
586
586
  }
587
587
  }
@@ -644,8 +644,8 @@ async function globNative(pattern, base, extraGlob) {
644
644
  if (DEFAULT_IGNORE.includes(e.name)) continue;
645
645
  const full = path.join(dir, e.name);
646
646
  try {
647
- const stat10 = await fs4.lstat(full);
648
- if (stat10.isSymbolicLink()) continue;
647
+ const stat11 = await fs4.lstat(full);
648
+ if (stat11.isSymbolicLink()) continue;
649
649
  } catch {
650
650
  continue;
651
651
  }
@@ -996,8 +996,8 @@ async function runNative(input, base, mode, limit, signal) {
996
996
  if (globRe && !globRe.test(e.name) && !globRe.test(full)) continue;
997
997
  if (globRe) globRe.lastIndex = 0;
998
998
  try {
999
- const stat10 = await fs4.stat(full);
1000
- if (stat10.size > 1e6) continue;
999
+ const stat11 = await fs4.stat(full);
1000
+ if (stat11.size > 1e6) continue;
1001
1001
  const head = await fs4.readFile(full);
1002
1002
  if (isBinaryBuffer(head)) continue;
1003
1003
  const text = head.toString("utf8");
@@ -1037,6 +1037,107 @@ async function runNative(input, base, mode, limit, signal) {
1037
1037
  used: "native"
1038
1038
  };
1039
1039
  }
1040
+ var SPOOL_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
1041
+ var SPOOL_WRITE_HWM_BYTES = 4 * 1024 * 1024;
1042
+ var sweepStarted = false;
1043
+ function toolOutputDir() {
1044
+ return path.join(wstackGlobalRoot(), "tool-output");
1045
+ }
1046
+ function sweepOldSpoolFiles(dir) {
1047
+ if (sweepStarted) return;
1048
+ sweepStarted = true;
1049
+ void (async () => {
1050
+ try {
1051
+ const now = Date.now();
1052
+ for (const name of await fs4.readdir(dir)) {
1053
+ if (!name.endsWith(".log")) continue;
1054
+ const p = path.join(dir, name);
1055
+ try {
1056
+ const st = await fs4.stat(p);
1057
+ if (now - st.mtimeMs > SPOOL_RETENTION_MS) await fs4.unlink(p);
1058
+ } catch {
1059
+ }
1060
+ }
1061
+ } catch {
1062
+ }
1063
+ })();
1064
+ }
1065
+ function spoolNote(info) {
1066
+ const dropped = info.droppedBytes > 0 ? `, ~${info.droppedBytes} bytes dropped under backpressure` : "";
1067
+ return `
1068
+ [output truncated \u2014 full ${info.bytes} bytes at ${info.path}${dropped}; read/grep that file selectively instead of re-running with more output]`;
1069
+ }
1070
+ function createOutputSpool(opts) {
1071
+ const threshold = opts.thresholdBytes ?? 32768;
1072
+ const safeTool = opts.tool.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 40) || "tool";
1073
+ let head = "";
1074
+ let headBytes = 0;
1075
+ let totalBytes = 0;
1076
+ let droppedBytes = 0;
1077
+ let stream = null;
1078
+ let filePath = null;
1079
+ let failed = false;
1080
+ let finalized = false;
1081
+ const open = () => {
1082
+ if (stream || failed) return;
1083
+ try {
1084
+ const dir = toolOutputDir();
1085
+ mkdirSync(dir, { recursive: true });
1086
+ sweepOldSpoolFiles(dir);
1087
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1088
+ const rand = Math.random().toString(36).slice(2, 6);
1089
+ filePath = path.join(dir, `${stamp}-${safeTool}-${rand}.log`);
1090
+ stream = createWriteStream(filePath, { flags: "w", encoding: "utf8" });
1091
+ stream.on("error", () => {
1092
+ failed = true;
1093
+ stream = null;
1094
+ filePath = null;
1095
+ });
1096
+ stream.write(head);
1097
+ } catch {
1098
+ failed = true;
1099
+ stream = null;
1100
+ filePath = null;
1101
+ }
1102
+ };
1103
+ return {
1104
+ write(text) {
1105
+ if (finalized || !text) return;
1106
+ totalBytes += Buffer.byteLength(text, "utf8");
1107
+ if (!stream && !failed) {
1108
+ if (headBytes + text.length <= threshold) {
1109
+ head += text;
1110
+ headBytes += text.length;
1111
+ return;
1112
+ }
1113
+ head += text;
1114
+ open();
1115
+ head = "";
1116
+ return;
1117
+ }
1118
+ if (stream) {
1119
+ if (stream.writableLength > SPOOL_WRITE_HWM_BYTES) {
1120
+ droppedBytes += Buffer.byteLength(text, "utf8");
1121
+ return;
1122
+ }
1123
+ stream.write(text);
1124
+ }
1125
+ },
1126
+ finalize() {
1127
+ if (finalized) {
1128
+ return filePath ? { path: filePath, bytes: totalBytes, droppedBytes } : null;
1129
+ }
1130
+ finalized = true;
1131
+ head = "";
1132
+ if (!stream || !filePath) return null;
1133
+ try {
1134
+ stream.end();
1135
+ } catch {
1136
+ }
1137
+ return { path: filePath, bytes: totalBytes, droppedBytes };
1138
+ }
1139
+ };
1140
+ }
1040
1141
 
1041
1142
  // src/circuit-breaker.ts
1042
1143
  var DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
@@ -1516,7 +1617,7 @@ var bashTool = {
1516
1617
  })();
1517
1618
  const args = isWin ? ["/c", input.command] : ["-c", input.command];
1518
1619
  const env = buildChildEnv(ctx.session?.id);
1519
- const detached = isWin ? !!input.background : true;
1620
+ const detached = !isWin;
1520
1621
  const startedAt = Date.now();
1521
1622
  if (input.background) {
1522
1623
  let buf2 = "";
@@ -1525,10 +1626,14 @@ var bashTool = {
1525
1626
  cwd: ctx.projectRoot,
1526
1627
  env,
1527
1628
  stdio: ["ignore", "pipe", "pipe"],
1528
- detached: true,
1529
- // Detached console children on Windows allocate their own VISIBLE
1530
- // console window (one per background command test suites flash
1531
- // dozens). CREATE_NO_WINDOW suppresses it; no-op elsewhere.
1629
+ // win32: CreateProcess IGNORES CREATE_NO_WINDOW (windowsHide) when
1630
+ // DETACHED_PROCESS (detached: true) is set, so the console-less
1631
+ // cmd.exe's grandchildren (node, dev servers) each allocate a fresh
1632
+ // VISIBLE console window. detached: false lets CREATE_NO_WINDOW
1633
+ // apply: the child gets a hidden console that grandchildren inherit.
1634
+ // Windows children survive parent exit either way. POSIX keeps
1635
+ // detached for the process-group kill semantics.
1636
+ detached: !isWin,
1532
1637
  windowsHide: true,
1533
1638
  signal: opts.signal
1534
1639
  });
@@ -1544,24 +1649,22 @@ var bashTool = {
1544
1649
  });
1545
1650
  child2.on("close", () => registry.unregister(pid2));
1546
1651
  }
1547
- child2.stdout?.on("data", (chunk) => {
1548
- if (!truncated) {
1549
- const remain = MAX_OUTPUT - buf2.length;
1550
- if (remain > 0) {
1551
- buf2 += chunk.toString().slice(0, remain);
1552
- }
1553
- if (buf2.length >= MAX_OUTPUT) truncated = true;
1652
+ const onBgData = (chunk) => {
1653
+ if (truncated) return;
1654
+ const remain = MAX_OUTPUT - buf2.length;
1655
+ if (remain > 0) {
1656
+ buf2 += chunk.toString().slice(0, remain);
1554
1657
  }
1555
- });
1556
- child2.stderr?.on("data", (chunk) => {
1557
- if (!truncated) {
1558
- const remain = MAX_OUTPUT - buf2.length;
1559
- if (remain > 0) {
1560
- buf2 += chunk.toString().slice(0, remain);
1561
- }
1562
- if (buf2.length >= MAX_OUTPUT) truncated = true;
1658
+ if (buf2.length >= MAX_OUTPUT) {
1659
+ truncated = true;
1660
+ child2.stdout?.off("data", onBgData);
1661
+ child2.stderr?.off("data", onBgData);
1563
1662
  }
1564
- });
1663
+ };
1664
+ child2.stdout?.on("data", onBgData);
1665
+ child2.stderr?.on("data", onBgData);
1666
+ child2.stdout?.unref?.();
1667
+ child2.stderr?.unref?.();
1565
1668
  child2.on("close", () => {
1566
1669
  registry.afterCall(Date.now() - startedAt, false, bypassBreaker);
1567
1670
  });
@@ -1600,6 +1703,7 @@ var bashTool = {
1600
1703
  let pending2 = "";
1601
1704
  let timedOut = false;
1602
1705
  const timers = [];
1706
+ const spool = createOutputSpool({ tool: "bash", thresholdBytes: MAX_OUTPUT });
1603
1707
  function killWithTimeout(child2, timeoutMs2) {
1604
1708
  if (isWin) {
1605
1709
  if (typeof child2.pid === "number" && child2.exitCode === null && killWin32Tree(child2.pid)) {
@@ -1705,6 +1809,7 @@ var bashTool = {
1705
1809
  if (buf.length < MAX_OUTPUT) {
1706
1810
  buf += text.slice(0, MAX_OUTPUT - buf.length);
1707
1811
  }
1812
+ spool.write(text);
1708
1813
  pending2 += text;
1709
1814
  push({ kind: "data", text });
1710
1815
  pauseIfFlooded();
@@ -1732,10 +1837,11 @@ var bashTool = {
1732
1837
  if (remainder !== null) {
1733
1838
  yield { type: "partial_output", text: remainder };
1734
1839
  }
1840
+ const spooled = spool.finalize();
1735
1841
  yield {
1736
1842
  type: "final",
1737
1843
  output: {
1738
- output: normalizeCommandOutput(buf),
1844
+ output: normalizeCommandOutput(buf) + (spooled ? spoolNote(spooled) : ""),
1739
1845
  exit_code: c.code,
1740
1846
  timed_out: timedOut
1741
1847
  }
@@ -1750,6 +1856,7 @@ var bashTool = {
1750
1856
  }
1751
1857
  } finally {
1752
1858
  for (const t of timers) clearTimeout(t);
1859
+ spool.finalize();
1753
1860
  if (isWin) opts.signal.removeEventListener("abort", onAbort);
1754
1861
  child.stdout?.off("data", onData);
1755
1862
  child.stderr?.off("data", onData);
@@ -1990,6 +2097,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
1990
2097
  let stderr = "";
1991
2098
  let killed = false;
1992
2099
  const startedAt = Date.now();
2100
+ const spool = createOutputSpool({ tool: `exec-${cmd}`, thresholdBytes: MAX_OUTPUT2 });
1993
2101
  const resolved = resolveWin32Command(cmd);
1994
2102
  const isWin = process.platform === "win32";
1995
2103
  const needsShell = isWin && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
@@ -2022,10 +2130,14 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
2022
2130
  else signal.addEventListener("abort", onAbort, { once: true });
2023
2131
  }
2024
2132
  child.stdout?.on("data", (chunk) => {
2025
- if (stdout.length < MAX_OUTPUT2) stdout += chunk.toString();
2133
+ const text = chunk.toString();
2134
+ if (stdout.length < MAX_OUTPUT2) stdout += text;
2135
+ spool.write(text);
2026
2136
  });
2027
2137
  child.stderr?.on("data", (chunk) => {
2028
- if (stderr.length < MAX_OUTPUT2) stderr += chunk.toString();
2138
+ const text = chunk.toString();
2139
+ if (stderr.length < MAX_OUTPUT2) stderr += text;
2140
+ spool.write(text);
2029
2141
  });
2030
2142
  child.on("close", (code) => {
2031
2143
  clearTimeout(timer);
@@ -2034,10 +2146,11 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
2034
2146
  const durationMs = Date.now() - startedAt;
2035
2147
  const exitCode = killed ? 124 : code ?? 1;
2036
2148
  registry.afterCall(durationMs, exitCode !== 0);
2149
+ const spooled = spool.finalize();
2037
2150
  resolve7({
2038
2151
  command: cmd,
2039
2152
  args,
2040
- stdout: normalizeCommandOutput(stdout),
2153
+ stdout: normalizeCommandOutput(stdout) + (spooled ? spoolNote(spooled) : ""),
2041
2154
  stderr: normalizeCommandOutput(stderr),
2042
2155
  exitCode,
2043
2156
  truncated: Buffer.byteLength(stdout, "utf8") > COMMAND_OUTPUT_MAX_BYTES || Buffer.byteLength(stderr, "utf8") > COMMAND_OUTPUT_MAX_BYTES,
@@ -2049,6 +2162,7 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
2049
2162
  if (isWin) signal.removeEventListener("abort", onAbort);
2050
2163
  if (typeof pid === "number") registry.unregister(pid);
2051
2164
  registry.afterCall(Date.now() - startedAt, true);
2165
+ spool.finalize();
2052
2166
  resolve7({
2053
2167
  command: cmd,
2054
2168
  args,
@@ -3071,8 +3185,8 @@ function findGitDir(cwd, projectRoot) {
3071
3185
  let dir = cwd;
3072
3186
  for (let i = 0; i < 20; i++) {
3073
3187
  try {
3074
- const stat10 = statSync(`${dir}/.git`);
3075
- if (stat10.isDirectory() || stat10.isFile()) return dir;
3188
+ const stat11 = statSync(`${dir}/.git`);
3189
+ if (stat11.isDirectory() || stat11.isFile()) return dir;
3076
3190
  } catch {
3077
3191
  }
3078
3192
  if (dir === root) break;
@@ -3375,8 +3489,8 @@ var jsonTool = {
3375
3489
  };
3376
3490
  }
3377
3491
  };
3378
- function query(data, path20) {
3379
- const parts = path20.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
3492
+ function query(data, path21) {
3493
+ const parts = path21.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
3380
3494
  let current = data;
3381
3495
  for (const part of parts) {
3382
3496
  if (current === null || current === void 0) return void 0;
@@ -3505,8 +3619,8 @@ function findGitDir2(cwd) {
3505
3619
  let dir = cwd;
3506
3620
  for (let i = 0; i < 20; i++) {
3507
3621
  try {
3508
- const stat10 = statSync(path.join(dir, ".git"));
3509
- if (stat10.isDirectory()) return dir;
3622
+ const stat11 = statSync(path.join(dir, ".git"));
3623
+ if (stat11.isDirectory()) return dir;
3510
3624
  } catch {
3511
3625
  }
3512
3626
  const parent = path.dirname(dir);
@@ -3550,8 +3664,8 @@ async function fileDiff(input, ctx, _signal) {
3550
3664
  const results = [];
3551
3665
  for (const file of files) {
3552
3666
  const absPath = safeResolve(file, ctx);
3553
- const stat10 = await fs4.stat(absPath).catch(() => null);
3554
- if (!stat10?.isFile()) continue;
3667
+ const stat11 = await fs4.stat(absPath).catch(() => null);
3668
+ if (!stat11?.isFile()) continue;
3555
3669
  const content = await fs4.readFile(absPath, "utf8");
3556
3670
  const lines = content.split(/\r?\n/);
3557
3671
  results.push(formatWithLineNumbers(file, lines));
@@ -3754,16 +3868,29 @@ async function* spawnStream(opts) {
3754
3868
  let stderr = "";
3755
3869
  let pending2 = "";
3756
3870
  let error;
3871
+ const spool = createOutputSpool({ tool: opts.cmd, thresholdBytes: max });
3757
3872
  const cmd = resolveWin32Command(opts.cmd);
3758
- const needsShell = process.platform === "win32" && (cmd.endsWith(".cmd") || cmd.endsWith(".bat"));
3873
+ const isWin = process.platform === "win32";
3874
+ const needsShell = isWin && (cmd.endsWith(".cmd") || cmd.endsWith(".bat"));
3759
3875
  const child = spawn(cmd, opts.args, {
3760
3876
  cwd: opts.cwd,
3761
- signal: opts.signal,
3762
3877
  env: buildChildEnv(),
3763
3878
  stdio: ["ignore", "pipe", "pipe"],
3764
3879
  windowsHide: true,
3880
+ ...isWin ? {} : { signal: opts.signal },
3765
3881
  ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {}
3766
3882
  });
3883
+ const registry = getProcessRegistry();
3884
+ const pid = child.pid;
3885
+ if (typeof pid === "number") {
3886
+ registry.register({
3887
+ pid,
3888
+ name: opts.cmd,
3889
+ command: redactCommand(`${opts.cmd} ${opts.args.join(" ")}`),
3890
+ startedAt: Date.now(),
3891
+ child
3892
+ });
3893
+ }
3767
3894
  const queue = [];
3768
3895
  let waiter;
3769
3896
  let paused = false;
@@ -3781,9 +3908,10 @@ async function* spawnStream(opts) {
3781
3908
  child.stderr?.resume();
3782
3909
  }
3783
3910
  };
3784
- child.stdout?.on("data", (c) => {
3911
+ const onOut = (c) => {
3785
3912
  const s = c.toString();
3786
3913
  if (stdout.length < max) stdout += s;
3914
+ spool.write(s);
3787
3915
  queue.push({ kind: "out", data: s });
3788
3916
  wake();
3789
3917
  if (!paused && queue.length >= maxQueue) {
@@ -3791,10 +3919,11 @@ async function* spawnStream(opts) {
3791
3919
  child.stdout?.pause();
3792
3920
  child.stderr?.pause();
3793
3921
  }
3794
- });
3795
- child.stderr?.on("data", (c) => {
3922
+ };
3923
+ const onErr = (c) => {
3796
3924
  const s = c.toString();
3797
3925
  if (stderr.length < max) stderr += s;
3926
+ spool.write(s);
3798
3927
  queue.push({ kind: "err", data: s });
3799
3928
  wake();
3800
3929
  if (!paused && queue.length >= maxQueue) {
@@ -3802,51 +3931,92 @@ async function* spawnStream(opts) {
3802
3931
  child.stdout?.pause();
3803
3932
  child.stderr?.pause();
3804
3933
  }
3805
- });
3934
+ };
3935
+ child.stdout?.on("data", onOut);
3936
+ child.stderr?.on("data", onErr);
3806
3937
  child.on("error", (e) => {
3807
3938
  error = e.message;
3808
3939
  queue.push({ kind: "error", data: e.message });
3809
3940
  wake();
3810
3941
  });
3811
3942
  child.on("close", (code) => {
3943
+ if (typeof pid === "number") registry.unregister(pid);
3812
3944
  queue.push({ kind: "close", data: "", code: code ?? 0 });
3813
3945
  wake();
3814
3946
  });
3947
+ const onAbort = () => {
3948
+ if (typeof pid === "number") {
3949
+ registry.kill(pid, { force: true });
3950
+ } else {
3951
+ try {
3952
+ child.kill("SIGKILL");
3953
+ } catch {
3954
+ }
3955
+ }
3956
+ queue.push({ kind: "close", data: "", code: 124 });
3957
+ wake();
3958
+ };
3959
+ if (opts.signal.aborted) onAbort();
3960
+ else opts.signal.addEventListener("abort", onAbort, { once: true });
3815
3961
  let exitCode = 0;
3816
3962
  let spawnFailed = false;
3817
- for (; ; ) {
3818
- while (queue.length === 0) {
3819
- await new Promise((resolve7) => {
3820
- waiter = resolve7;
3821
- });
3822
- }
3823
- const chunk = queue.shift();
3824
- resume();
3825
- if (chunk.kind === "close") {
3826
- if (!spawnFailed) exitCode = chunk.code ?? 0;
3827
- break;
3828
- }
3829
- if (chunk.kind === "error") {
3830
- spawnFailed = true;
3831
- exitCode = 1;
3832
- continue;
3963
+ try {
3964
+ for (; ; ) {
3965
+ while (queue.length === 0) {
3966
+ await new Promise((resolve7) => {
3967
+ waiter = resolve7;
3968
+ });
3969
+ }
3970
+ const chunk = queue.shift();
3971
+ resume();
3972
+ if (chunk.kind === "close") {
3973
+ if (!spawnFailed) exitCode = chunk.code ?? 0;
3974
+ break;
3975
+ }
3976
+ if (chunk.kind === "error") {
3977
+ spawnFailed = true;
3978
+ exitCode = 1;
3979
+ continue;
3980
+ }
3981
+ pending2 += chunk.data;
3982
+ if (pending2.length >= flushAt) {
3983
+ yield { type: "partial_output", text: pending2 };
3984
+ pending2 = "";
3985
+ }
3833
3986
  }
3834
- pending2 += chunk.data;
3835
- if (pending2.length >= flushAt) {
3987
+ if (pending2.length > 0) {
3836
3988
  yield { type: "partial_output", text: pending2 };
3837
- pending2 = "";
3989
+ }
3990
+ const spooled = spool.finalize();
3991
+ return {
3992
+ // The marker rides on stdout's tail so every consumer's head+tail
3993
+ // normalization keeps it without per-tool changes.
3994
+ stdout: spooled ? stdout + spoolNote(spooled) : stdout,
3995
+ stderr,
3996
+ exitCode,
3997
+ truncated: stdout.length >= max || stderr.length >= max,
3998
+ error,
3999
+ spoolPath: spooled?.path,
4000
+ spoolBytes: spooled?.bytes
4001
+ };
4002
+ } finally {
4003
+ spool.finalize();
4004
+ opts.signal.removeEventListener("abort", onAbort);
4005
+ child.stdout?.off("data", onOut);
4006
+ child.stderr?.off("data", onErr);
4007
+ child.stdout?.destroy();
4008
+ child.stderr?.destroy();
4009
+ if (child.exitCode === null && !child.killed) {
4010
+ if (typeof pid === "number") {
4011
+ registry.kill(pid, { force: true });
4012
+ } else {
4013
+ try {
4014
+ child.kill("SIGKILL");
4015
+ } catch {
4016
+ }
4017
+ }
3838
4018
  }
3839
4019
  }
3840
- if (pending2.length > 0) {
3841
- yield { type: "partial_output", text: pending2 };
3842
- }
3843
- return {
3844
- stdout,
3845
- stderr,
3846
- exitCode,
3847
- truncated: stdout.length >= max || stderr.length >= max,
3848
- error
3849
- };
3850
4020
  }
3851
4021
 
3852
4022
  // src/lint.ts
@@ -3929,11 +4099,11 @@ var lintTool = {
3929
4099
  }
3930
4100
  };
3931
4101
  async function detectLinter(cwd) {
3932
- const { stat: stat10 } = await import('node:fs/promises');
4102
+ const { stat: stat11 } = await import('node:fs/promises');
3933
4103
  const checks = ["biome.json", ".eslintrc.json", "tslint.json", ".eslintrc.js", "tsconfig.json"];
3934
4104
  for (const f of checks) {
3935
4105
  try {
3936
- await stat10(`${cwd}/${f}`);
4106
+ await stat11(`${cwd}/${f}`);
3937
4107
  if (f.includes("biome")) return "biome";
3938
4108
  if (f.includes("eslint")) return "eslint";
3939
4109
  if (f.includes("tslint")) return "tslint";
@@ -4031,13 +4201,13 @@ var formatTool = {
4031
4201
  }
4032
4202
  };
4033
4203
  async function detectFixer(cwd) {
4034
- const { stat: stat10 } = await import('node:fs/promises');
4204
+ const { stat: stat11 } = await import('node:fs/promises');
4035
4205
  try {
4036
- await stat10(`${cwd}/biome.json`);
4206
+ await stat11(`${cwd}/biome.json`);
4037
4207
  return "biome";
4038
4208
  } catch {
4039
4209
  try {
4040
- await stat10(`${cwd}/.prettierrc`);
4210
+ await stat11(`${cwd}/.prettierrc`);
4041
4211
  return "prettier";
4042
4212
  } catch {
4043
4213
  return "biome";
@@ -4115,11 +4285,11 @@ var typecheckTool = {
4115
4285
  }
4116
4286
  };
4117
4287
  async function findTsConfig(cwd) {
4118
- const { stat: stat10 } = await import('node:fs/promises');
4288
+ const { stat: stat11 } = await import('node:fs/promises');
4119
4289
  const candidates = ["tsconfig.json", "tsconfig.base.json"];
4120
4290
  for (const f of candidates) {
4121
4291
  try {
4122
- const s = await stat10(path.join(cwd, f));
4292
+ const s = await stat11(path.join(cwd, f));
4123
4293
  if (s.isFile()) return path.join(cwd, f);
4124
4294
  } catch {
4125
4295
  }
@@ -4150,7 +4320,11 @@ var testTool = {
4150
4320
  coverage: { type: "boolean", description: "Generate coverage report (default: false)" },
4151
4321
  cwd: { type: "string", description: "Working directory (default: cwd)" },
4152
4322
  grep: { type: "string", description: "Filter tests by name pattern (default: none)" },
4153
- timeout: { type: "integer", description: "Test timeout in ms (default: 30000)" }
4323
+ timeout: { type: "integer", description: "Test timeout in ms (default: 30000)" },
4324
+ verbose: {
4325
+ type: "boolean",
4326
+ description: "Per-test verbose reporter output (default: false \u2014 the summary reporter is used; full output is always saved to a log file referenced in the result)"
4327
+ }
4154
4328
  }
4155
4329
  },
4156
4330
  async execute(input, ctx, opts) {
@@ -4198,11 +4372,11 @@ var testTool = {
4198
4372
  }
4199
4373
  };
4200
4374
  async function detectRunner(cwd) {
4201
- const { stat: stat10 } = await import('node:fs/promises');
4375
+ const { stat: stat11 } = await import('node:fs/promises');
4202
4376
  const candidates = ["vitest.config.ts", "jest.config.js", ".mocharc.json"];
4203
4377
  for (const f of candidates) {
4204
4378
  try {
4205
- await stat10(path.join(cwd, f));
4379
+ await stat11(path.join(cwd, f));
4206
4380
  if (f.includes("vitest")) return "vitest";
4207
4381
  if (f.includes("jest")) return "jest";
4208
4382
  if (f.includes("mocha")) return "mocha";
@@ -4216,17 +4390,14 @@ function buildArgs2(runner, input) {
4216
4390
  const timeout = input.timeout ?? 3e4;
4217
4391
  switch (runner) {
4218
4392
  case "vitest":
4219
- args.push("run", "--reporter=verbose");
4220
- if (input.watch) {
4221
- args[1] = "";
4222
- args.push("watch");
4223
- }
4393
+ args.push(input.watch ? "watch" : "run");
4394
+ if (input.verbose) args.push("--reporter=verbose");
4224
4395
  if (input.coverage) args.push("--coverage");
4225
4396
  if (input.grep) args.push("--testNamePattern", input.grep);
4226
4397
  args.push("--testTimeout", String(timeout));
4227
4398
  break;
4228
4399
  case "jest":
4229
- args.push("--verbose");
4400
+ if (input.verbose) args.push("--verbose");
4230
4401
  if (input.watch) args.push("--watch");
4231
4402
  if (input.coverage) args.push("--coverage");
4232
4403
  if (input.grep) args.push("--testPathPattern", input.grep);
@@ -4270,7 +4441,13 @@ function parseResult(runner, result, duration) {
4270
4441
  passed,
4271
4442
  failed,
4272
4443
  duration_ms: duration,
4273
- output: normalizeCommandOutput(result.stdout || result.error || ""),
4444
+ // A passing run only needs the tail summary in chat history — counts are
4445
+ // already parsed above and the FULL log is on disk (spool marker rides
4446
+ // the stdout tail). Failures keep the standard command-output cap so
4447
+ // the agent sees the failure details inline.
4448
+ output: normalizeCommandOutput(result.stdout || result.error || "", {
4449
+ maxBytes: result.exitCode === 0 ? 4096 : void 0
4450
+ }),
4274
4451
  truncated: result.truncated
4275
4452
  };
4276
4453
  }
@@ -4736,7 +4913,7 @@ async function dockerLogs(service, lines, filterRe, cwd, signal, since) {
4736
4913
  }
4737
4914
  var DOCKER_LOGS_TIMEOUT_MS = 3e3;
4738
4915
  var MAX_TAIL_LINES = 1e5;
4739
- async function fileLogs(path20, lines, filterRe, stream) {
4916
+ async function fileLogs(path21, lines, filterRe, stream) {
4740
4917
  const { createInterface } = await import('node:readline');
4741
4918
  const { createReadStream } = await import('node:fs');
4742
4919
  const entries = [];
@@ -4745,7 +4922,7 @@ async function fileLogs(path20, lines, filterRe, stream) {
4745
4922
  let writeIdx = 0;
4746
4923
  let totalLines = 0;
4747
4924
  const rl = createInterface({
4748
- input: createReadStream(path20),
4925
+ input: createReadStream(path21),
4749
4926
  crlfDelay: Number.POSITIVE_INFINITY
4750
4927
  });
4751
4928
  for await (const line of rl) {
@@ -4766,7 +4943,7 @@ async function fileLogs(path20, lines, filterRe, stream) {
4766
4943
  if (parsed) entries.push(parsed);
4767
4944
  }
4768
4945
  return {
4769
- source: path20,
4946
+ source: path21,
4770
4947
  entries,
4771
4948
  total: entries.length,
4772
4949
  truncated: totalLines > effLines,
@@ -4889,8 +5066,8 @@ async function resolveFiles2(filesInput, cwd) {
4889
5066
  for (const f of files) {
4890
5067
  const absPath = f.trim().startsWith("/") ? f.trim() : `${cwd}/${f.trim()}`;
4891
5068
  try {
4892
- const stat10 = await fs4.stat(absPath);
4893
- if (stat10.isFile()) resolved.push(absPath);
5069
+ const stat11 = await fs4.stat(absPath);
5070
+ if (stat11.isFile()) resolved.push(absPath);
4894
5071
  } catch {
4895
5072
  }
4896
5073
  }
@@ -7902,20 +8079,20 @@ async function runIndexerWithStore(store, opts) {
7902
8079
  await yieldEventLoop();
7903
8080
  throwIfAborted(signal);
7904
8081
  }
7905
- let stat10;
8082
+ let stat11;
7906
8083
  try {
7907
8084
  const statOpts = signal ? { signal } : {};
7908
- stat10 = await fs4.stat(file, statOpts);
8085
+ stat11 = await fs4.stat(file, statOpts);
7909
8086
  } catch (e) {
7910
8087
  if (isAbortError(e)) throw e;
7911
8088
  store.deleteFile(file);
7912
8089
  continue;
7913
8090
  }
7914
- if (!stat10.isFile()) continue;
8091
+ if (!stat11.isFile()) continue;
7915
8092
  const lang = detectLang(file);
7916
8093
  if (!lang) continue;
7917
8094
  const meta = existingMeta.get(file);
7918
- if (!force && meta && meta.mtimeMs === Math.floor(stat10.mtimeMs)) {
8095
+ if (!force && meta && meta.mtimeMs === Math.floor(stat11.mtimeMs)) {
7919
8096
  langStats[lang] = (langStats[lang] ?? 0) + meta.symbolCount;
7920
8097
  symbolsIndexed += meta.symbolCount;
7921
8098
  filesIndexed++;
@@ -7942,7 +8119,7 @@ async function runIndexerWithStore(store, opts) {
7942
8119
  store.upsertFile({
7943
8120
  file,
7944
8121
  lang,
7945
- mtimeMs: Math.floor(stat10.mtimeMs),
8122
+ mtimeMs: Math.floor(stat11.mtimeMs),
7946
8123
  symbolCount: 0,
7947
8124
  lastIndexed: Date.now()
7948
8125
  });
@@ -7968,7 +8145,7 @@ async function runIndexerWithStore(store, opts) {
7968
8145
  store.upsertFile({
7969
8146
  file,
7970
8147
  lang,
7971
- mtimeMs: Math.floor(stat10.mtimeMs),
8148
+ mtimeMs: Math.floor(stat11.mtimeMs),
7972
8149
  symbolCount: count,
7973
8150
  lastIndexed: Date.now()
7974
8151
  });
@@ -8249,6 +8426,13 @@ function debounceKey(indexDir, file) {
8249
8426
  function isIndexableFile(filePath) {
8250
8427
  return detectLang(filePath) !== null;
8251
8428
  }
8429
+ function isUniqueConstraintError(err) {
8430
+ if (err instanceof Error) {
8431
+ const msg = err.message.toLowerCase();
8432
+ return msg.includes("unique constraint") || msg.includes("UNIQUE constraint");
8433
+ }
8434
+ return false;
8435
+ }
8252
8436
  async function runStartupIndex(opts) {
8253
8437
  if (!indexCircuitBreaker.allowRequest()) throw circuitOpenError();
8254
8438
  _indexing = true;
@@ -8278,6 +8462,15 @@ async function runStartupIndex(opts) {
8278
8462
  return result;
8279
8463
  } catch (err) {
8280
8464
  _lastError = err instanceof Error ? err.message : String(err);
8465
+ if (isUniqueConstraintError(err) && !opts.force) {
8466
+ _lastError = null;
8467
+ const rebuildResult = await runStartupIndex({
8468
+ ...opts,
8469
+ force: true
8470
+ });
8471
+ _ready = true;
8472
+ return rebuildResult;
8473
+ }
8281
8474
  _ready = true;
8282
8475
  if (!opts.signal?.aborted) indexCircuitBreaker.recordFailure(err);
8283
8476
  throw err;