@wrongstack/tools 0.236.0 → 0.255.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/audit.js +591 -48
  2. package/dist/audit.js.map +1 -1
  3. package/dist/background-indexer-CJ5JiV5i.d.ts +365 -0
  4. package/dist/bash.js +135 -20
  5. package/dist/bash.js.map +1 -1
  6. package/dist/builtin.js +1840 -1109
  7. package/dist/builtin.js.map +1 -1
  8. package/dist/codebase-index/index.d.ts +53 -2
  9. package/dist/codebase-index/index.js +870 -364
  10. package/dist/codebase-index/index.js.map +1 -1
  11. package/dist/codebase-index/worker.d.ts +2 -0
  12. package/dist/codebase-index/worker.js +2326 -0
  13. package/dist/codebase-index/worker.js.map +1 -0
  14. package/dist/diff.js +2 -1
  15. package/dist/diff.js.map +1 -1
  16. package/dist/exec.js +116 -5
  17. package/dist/exec.js.map +1 -1
  18. package/dist/format.js +591 -48
  19. package/dist/format.js.map +1 -1
  20. package/dist/git.js +2 -1
  21. package/dist/git.js.map +1 -1
  22. package/dist/grep.js +2 -2
  23. package/dist/grep.js.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +1189 -496
  26. package/dist/index.js.map +1 -1
  27. package/dist/install.js +591 -48
  28. package/dist/install.js.map +1 -1
  29. package/dist/lint.js +590 -47
  30. package/dist/lint.js.map +1 -1
  31. package/dist/logs.js +1 -1
  32. package/dist/logs.js.map +1 -1
  33. package/dist/outdated.js +1 -1
  34. package/dist/outdated.js.map +1 -1
  35. package/dist/pack.js +1840 -1109
  36. package/dist/pack.js.map +1 -1
  37. package/dist/patch.js +1 -1
  38. package/dist/patch.js.map +1 -1
  39. package/dist/replace.js +3 -2
  40. package/dist/replace.js.map +1 -1
  41. package/dist/test.d.ts +1 -0
  42. package/dist/test.js +605 -55
  43. package/dist/test.js.map +1 -1
  44. package/dist/typecheck.js +591 -48
  45. package/dist/typecheck.js.map +1 -1
  46. package/package.json +3 -3
  47. package/dist/background-indexer-CtbgPExj.d.ts +0 -228
package/dist/format.js CHANGED
@@ -1,19 +1,505 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import * as Core from '@wrongstack/core';
3
- import { buildChildEnv } from '@wrongstack/core';
3
+ import { buildChildEnv, expectDefined, wstackGlobalRoot } from '@wrongstack/core';
4
4
  import * as fs from 'node:fs';
5
- import * as path2 from 'node:path';
5
+ import { mkdirSync, createWriteStream } from 'node:fs';
6
+ import * as fsp from 'node:fs/promises';
7
+ import * as path3 from 'node:path';
8
+ import * as os from 'node:os';
6
9
 
7
10
  // src/_spawn-stream.ts
11
+ var SPOOL_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
12
+ var SPOOL_WRITE_HWM_BYTES = 4 * 1024 * 1024;
13
+ var sweepStarted = false;
14
+ function toolOutputDir() {
15
+ return path3.join(wstackGlobalRoot(), "tool-output");
16
+ }
17
+ function sweepOldSpoolFiles(dir) {
18
+ if (sweepStarted) return;
19
+ sweepStarted = true;
20
+ void (async () => {
21
+ try {
22
+ const now = Date.now();
23
+ for (const name of await fsp.readdir(dir)) {
24
+ if (!name.endsWith(".log")) continue;
25
+ const p = path3.join(dir, name);
26
+ try {
27
+ const st = await fsp.stat(p);
28
+ if (now - st.mtimeMs > SPOOL_RETENTION_MS) await fsp.unlink(p);
29
+ } catch {
30
+ }
31
+ }
32
+ } catch {
33
+ }
34
+ })();
35
+ }
36
+ function spoolNote(info) {
37
+ const dropped = info.droppedBytes > 0 ? `, ~${info.droppedBytes} bytes dropped under backpressure` : "";
38
+ return `
39
+ [output truncated \u2014 full ${info.bytes} bytes at ${info.path}${dropped}; read/grep that file selectively instead of re-running with more output]`;
40
+ }
41
+ function createOutputSpool(opts) {
42
+ const threshold = opts.thresholdBytes;
43
+ const safeTool = opts.tool.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 40) || "tool";
44
+ let head = "";
45
+ let headBytes = 0;
46
+ let totalBytes = 0;
47
+ let droppedBytes = 0;
48
+ let stream = null;
49
+ let filePath = null;
50
+ let failed = false;
51
+ let finalized = false;
52
+ const open = () => {
53
+ if (stream || failed) return;
54
+ try {
55
+ const dir = toolOutputDir();
56
+ mkdirSync(dir, { recursive: true });
57
+ sweepOldSpoolFiles(dir);
58
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
59
+ const rand = Math.random().toString(36).slice(2, 6);
60
+ filePath = path3.join(dir, `${stamp}-${safeTool}-${rand}.log`);
61
+ stream = createWriteStream(filePath, { flags: "w", encoding: "utf8" });
62
+ stream.on("error", () => {
63
+ failed = true;
64
+ stream = null;
65
+ filePath = null;
66
+ });
67
+ stream.write(head);
68
+ } catch {
69
+ failed = true;
70
+ stream = null;
71
+ filePath = null;
72
+ }
73
+ };
74
+ return {
75
+ write(text) {
76
+ if (finalized || !text) return;
77
+ totalBytes += Buffer.byteLength(text, "utf8");
78
+ if (!stream && !failed) {
79
+ if (headBytes + text.length <= threshold) {
80
+ head += text;
81
+ headBytes += text.length;
82
+ return;
83
+ }
84
+ head += text;
85
+ open();
86
+ head = "";
87
+ return;
88
+ }
89
+ if (stream) {
90
+ if (stream.writableLength > SPOOL_WRITE_HWM_BYTES) {
91
+ droppedBytes += Buffer.byteLength(text, "utf8");
92
+ return;
93
+ }
94
+ stream.write(text);
95
+ }
96
+ },
97
+ finalize() {
98
+ if (finalized) {
99
+ return filePath ? { path: filePath, bytes: totalBytes, droppedBytes } : null;
100
+ }
101
+ finalized = true;
102
+ head = "";
103
+ if (!stream || !filePath) return null;
104
+ try {
105
+ stream.end();
106
+ } catch {
107
+ }
108
+ return { path: filePath, bytes: totalBytes, droppedBytes };
109
+ }
110
+ };
111
+ }
112
+
113
+ // src/circuit-breaker.ts
114
+ var DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
115
+ var DEFAULT_SLOW_CALL_THRESHOLD_MS = 18e4;
116
+ var DEFAULT_MAX_SLOW_CALLS = 3;
117
+ var DEFAULT_WINDOW_MS = 6e4;
118
+ var DEFAULT_MAX_CALLS_PER_WINDOW = 30;
119
+ var DEFAULT_COOLDOWN_MS = 3e4;
120
+ var CircuitBreaker = class {
121
+ maxConsecutiveFailures;
122
+ slowCallThresholdMs;
123
+ maxSlowCalls;
124
+ windowMs;
125
+ maxCallsPerWindow;
126
+ cooldownMs;
127
+ state = "closed";
128
+ consecutiveFailures = 0;
129
+ window = [];
130
+ lastFailureAt = null;
131
+ lastSlowAt = null;
132
+ /** Timestamp when the breaker was opened (for cooldown calculation). */
133
+ openedAt = null;
134
+ constructor(config = {}) {
135
+ this.maxConsecutiveFailures = config.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES;
136
+ this.slowCallThresholdMs = config.slowCallThresholdMs ?? DEFAULT_SLOW_CALL_THRESHOLD_MS;
137
+ this.maxSlowCalls = config.maxSlowCalls ?? DEFAULT_MAX_SLOW_CALLS;
138
+ this.windowMs = config.windowMs ?? DEFAULT_WINDOW_MS;
139
+ this.maxCallsPerWindow = config.maxCallsPerWindow ?? DEFAULT_MAX_CALLS_PER_WINDOW;
140
+ this.cooldownMs = config.cooldownMs ?? DEFAULT_COOLDOWN_MS;
141
+ }
142
+ /**
143
+ * Returns true if the circuit allows a new call to proceed.
144
+ * When false, callers should abort the tool call and return a
145
+ * circuit-breaker error instead of spawning a process.
146
+ */
147
+ get canProceed() {
148
+ this._checkStateTransition();
149
+ return this.state !== "open";
150
+ }
151
+ /**
152
+ * Snapshot of the current breaker state for observability (`/kill`).
153
+ */
154
+ snapshot() {
155
+ this._checkStateTransition();
156
+ const now = Date.now();
157
+ let cooldownRemaining = null;
158
+ if (this.openedAt !== null && this.state === "open") {
159
+ const elapsed = now - this.openedAt;
160
+ cooldownRemaining = Math.max(0, this.cooldownMs - elapsed);
161
+ }
162
+ return {
163
+ state: this.state,
164
+ consecutiveFailures: this.consecutiveFailures,
165
+ slowCallsInWindow: this.window.filter((c) => c.slow).length,
166
+ callsInWindow: this.window.length,
167
+ windowMs: this.windowMs,
168
+ cooldownRemainingMs: cooldownRemaining,
169
+ lastFailureAt: this.lastFailureAt,
170
+ lastSlowAt: this.lastSlowAt
171
+ };
172
+ }
173
+ /**
174
+ * Call this BEFORE spawning a bash/exec process.
175
+ * Returns true if the call is allowed; false if the breaker is open.
176
+ * When false, callers MUST NOT spawn a process.
177
+ *
178
+ * @param bypass - If true, skip the circuit breaker check entirely.
179
+ * Use for background/fire-and-forget processes that should
180
+ * not affect breaker state.
181
+ */
182
+ beforeCall(bypass = false) {
183
+ if (bypass) return true;
184
+ this._checkStateTransition();
185
+ if (this.state === "open") return false;
186
+ return true;
187
+ }
188
+ /**
189
+ * Call this AFTER a bash/exec process finishes (success or failure).
190
+ * `durationMs` is the wall-clock time the process ran.
191
+ * `failed` is true when the process returned a non-zero exit code or
192
+ * threw an exception before spawning.
193
+ *
194
+ * @param bypass - If true, do not update breaker state.
195
+ * Use for background/fire-and-forget processes.
196
+ */
197
+ afterCall(durationMs, failed, bypass = false) {
198
+ if (bypass) return;
199
+ const now = Date.now();
200
+ if (this.state === "half-open") {
201
+ if (failed) {
202
+ this._trip();
203
+ return;
204
+ }
205
+ this._reset();
206
+ return;
207
+ }
208
+ this._pruneWindow(now);
209
+ const slow = durationMs >= this.slowCallThresholdMs;
210
+ this.window.push({ at: now, failed, slow });
211
+ if (failed) {
212
+ this.consecutiveFailures++;
213
+ this.lastFailureAt = now;
214
+ if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
215
+ this._trip();
216
+ }
217
+ return;
218
+ }
219
+ this.consecutiveFailures = 0;
220
+ if (slow) {
221
+ this.lastSlowAt = now;
222
+ const slowCount = this.window.filter((c) => c.slow).length;
223
+ if (slowCount >= this.maxSlowCalls) {
224
+ this._trip();
225
+ }
226
+ }
227
+ const callCount = this.window.length;
228
+ if (callCount >= this.maxCallsPerWindow) {
229
+ this._trip();
230
+ }
231
+ }
232
+ /** Force the breaker open. Used by /kill force and Ctrl+C. */
233
+ forceOpen() {
234
+ this._trip();
235
+ }
236
+ /** Force a reset to closed. Used by tests and /kill reset. */
237
+ forceReset() {
238
+ this._reset();
239
+ }
240
+ _trip() {
241
+ if (this.state === "open") return;
242
+ this.state = "open";
243
+ this.openedAt = Date.now();
244
+ }
245
+ _reset() {
246
+ this.state = "closed";
247
+ this.consecutiveFailures = 0;
248
+ this.window = [];
249
+ this.openedAt = null;
250
+ }
251
+ /** Transition from open → half-open when cooldown elapses. */
252
+ _checkStateTransition() {
253
+ if (this.state !== "open" || this.openedAt === null) return;
254
+ const elapsed = Date.now() - this.openedAt;
255
+ if (elapsed >= this.cooldownMs) {
256
+ this.state = "half-open";
257
+ this.openedAt = null;
258
+ }
259
+ }
260
+ _pruneWindow(now) {
261
+ const cutoff = now - this.windowMs;
262
+ this.window = this.window.filter((c) => c.at >= cutoff);
263
+ }
264
+ };
265
+
266
+ // src/process-registry.ts
267
+ var SENSITIVE_FLAG_PATTERNS = [
268
+ // --flag=value or --flag "value" (value captured up to next space or comma)
269
+ /--(?:token|password|passwd|pwd|secret|api[-_]?key|api[-_]?secret|auth|credential|private[-_]?key|access[-_]?key|github[-_]?token|gh[-_]?token|bearer|jwt|oauth|pin|pincode|passphrase|access[-_]?token)(?:[=\s,][^\s]*)?/gi,
270
+ // -f "value" style short flags
271
+ /(?<!\w)-t(?:\s+|\s*=\s*)[^\s,]+/g,
272
+ /(?<!\w)-p(?:ssword)?(?:\s+|\s*=\s*)[^\s,]+/gi,
273
+ // env var–style secrets: TOKEN=x, API_KEY=y, etc.
274
+ /(?:TOKEN|API_KEY|API_SECRET|AUTH_TOKEN|GITHUB_TOKEN|GH_TOKEN|BEARER|JWT|OAUTH|CREDENTIAL|SECRET|PRIVATE_KEY|PASSWORD|PASSWD)\s*[=:]\s*[^\s,]+/gi,
275
+ // Generic high-entropy look: base64 strings >32 chars or hex strings >32 digits — but only
276
+ // when preceded by a flag name (e.g. --github-token=EyJ...).
277
+ /--\w*(?:token|key|secret|password|passwd|auth|credential)\w*[=\s,][A-Za-z0-9+/=]{32,}/
278
+ ];
279
+ function redactCommand(cmd) {
280
+ let result = cmd;
281
+ for (const pattern of SENSITIVE_FLAG_PATTERNS) {
282
+ result = result.replace(pattern, (match) => {
283
+ const eq = match.indexOf("=");
284
+ const sp = match.search(/\s/);
285
+ const delim = eq !== -1 ? "=" : sp !== -1 ? match[sp] : null;
286
+ if (delim !== null) {
287
+ const flag = match.slice(0, match.indexOf(expectDefined(delim)) + 1);
288
+ return `${flag}[REDACTED]`;
289
+ }
290
+ const flagEnd = match.match(/^--?[a-zA-Z][a-zA-Z0-9_-]*/)?.[0] ?? match;
291
+ return `${flagEnd}=**redacted**`;
292
+ });
293
+ }
294
+ return result;
295
+ }
296
+ var DEFAULT_GRACE_MS = 2e3;
297
+ function killWin32Tree(pid) {
298
+ try {
299
+ spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
300
+ stdio: "ignore",
301
+ windowsHide: true
302
+ }).unref();
303
+ return true;
304
+ } catch {
305
+ return false;
306
+ }
307
+ }
308
+ var ProcessRegistryImpl = class {
309
+ processes = /* @__PURE__ */ new Map();
310
+ breaker;
311
+ constructor(breakerConfig) {
312
+ this.breaker = new CircuitBreaker(breakerConfig);
313
+ }
314
+ register(info) {
315
+ this.processes.set(info.pid, { ...info, killed: false, protected: info.protected ?? false });
316
+ }
317
+ /** Unregister a process by PID. Called on 'close' / 'exit' events. */
318
+ unregister(pid) {
319
+ this.processes.delete(pid);
320
+ }
321
+ /** Get a single process by PID. */
322
+ get(pid) {
323
+ return this.processes.get(pid);
324
+ }
325
+ /** Get all tracked processes. */
326
+ list() {
327
+ return Array.from(this.processes.values());
328
+ }
329
+ /** Get processes filtered by name (e.g. 'bash', 'exec'). */
330
+ byName(name) {
331
+ return this.list().filter((p) => p.name === name);
332
+ }
333
+ /** Get processes filtered by session. */
334
+ bySession(sessionId) {
335
+ return this.list().filter((p) => p.sessionId === sessionId);
336
+ }
337
+ /** Count of active (non-killed) processes. */
338
+ get activeCount() {
339
+ let n = 0;
340
+ for (const p of this.processes.values()) {
341
+ if (!p.killed) n++;
342
+ }
343
+ return n;
344
+ }
345
+ /**
346
+ * Combined stats for observability — used by /ps and the TUI status bar.
347
+ */
348
+ stats() {
349
+ return {
350
+ activeCount: this.activeCount,
351
+ totalCount: this.processes.size,
352
+ breaker: this.breaker.snapshot()
353
+ };
354
+ }
355
+ /**
356
+ * Returns true if the circuit allows a new bash/exec call to proceed.
357
+ * When false, callers MUST NOT spawn a process.
358
+ */
359
+ get canProceed() {
360
+ return this.breaker.canProceed;
361
+ }
362
+ /**
363
+ * Called before spawning a process. Returns true if allowed; false if
364
+ * the circuit breaker is open.
365
+ *
366
+ * @param bypass - If true, skip circuit breaker check (for background processes).
367
+ */
368
+ beforeCall(bypass = false) {
369
+ return this.breaker.beforeCall(bypass);
370
+ }
371
+ /**
372
+ * Called after a process finishes. `durationMs` is wall-clock time;
373
+ * `failed` is true for non-zero exit codes.
374
+ *
375
+ * @param bypass - If true, do not update circuit breaker state (for background processes).
376
+ */
377
+ afterCall(durationMs, failed, bypass = false) {
378
+ this.breaker.afterCall(durationMs, failed, bypass);
379
+ }
380
+ /** Force-open the circuit breaker (Ctrl+C, /kill force). */
381
+ forceBreakerOpen() {
382
+ this.breaker.forceOpen();
383
+ }
384
+ /** Force-reset the circuit breaker to closed (/kill reset). */
385
+ forceBreakerReset() {
386
+ this.breaker.forceReset();
387
+ }
388
+ /** Kill a single process by PID.
389
+ *
390
+ * On POSIX: sends SIGTERM to the *process group* (-pid) so that
391
+ * runaway grandchild processes (`sleep 9999 & disown`) are also killed.
392
+ * After `graceMs` a SIGKILL is sent if the process hasn't exited.
393
+ *
394
+ * On Windows: `child.kill()` maps to TerminateProcess — process groups
395
+ * are not meaningfully supported. A second `force=true` call sends
396
+ * SIGKILL (which maps to TerminateProcess again — the distinction is
397
+ * in the exit code, not the signal).
398
+ *
399
+ * Returns true if the process was found and kill was attempted.
400
+ */
401
+ kill(pid, opts = {}) {
402
+ const p = this.processes.get(pid);
403
+ if (!p) return false;
404
+ if (p.killed) return true;
405
+ if (p.protected) return false;
406
+ const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;
407
+ const isWin = os.platform() === "win32";
408
+ if (isWin) {
409
+ const liveRealChild = p.child.exitCode === null && typeof p.child.pid === "number";
410
+ if (liveRealChild && killWin32Tree(pid)) {
411
+ const fallback = setTimeout(() => {
412
+ if (p.child.exitCode === null) {
413
+ try {
414
+ p.child.kill("SIGKILL");
415
+ } catch {
416
+ }
417
+ }
418
+ }, graceMs);
419
+ fallback.unref?.();
420
+ } else {
421
+ try {
422
+ p.child.kill(force ? "SIGKILL" : "SIGTERM");
423
+ } catch {
424
+ }
425
+ }
426
+ p.killed = true;
427
+ return true;
428
+ }
429
+ try {
430
+ if (force) {
431
+ try {
432
+ process.kill(-pid, "SIGKILL");
433
+ } catch {
434
+ p.child.kill("SIGKILL");
435
+ }
436
+ } else {
437
+ try {
438
+ process.kill(-pid, "SIGTERM");
439
+ } catch {
440
+ p.child.kill("SIGTERM");
441
+ }
442
+ const timer = setTimeout(() => {
443
+ if (this.processes.has(pid) && !p.child.killed) {
444
+ try {
445
+ process.kill(-pid, "SIGKILL");
446
+ } catch {
447
+ try {
448
+ p.child.kill("SIGKILL");
449
+ } catch {
450
+ }
451
+ }
452
+ }
453
+ }, graceMs);
454
+ timer.unref?.();
455
+ }
456
+ } catch {
457
+ }
458
+ p.killed = true;
459
+ return true;
460
+ }
461
+ /**
462
+ * Kill all tracked processes.
463
+ * Returns the PIDs that were kill()ed.
464
+ */
465
+ killAll(opts = {}) {
466
+ const pids = Array.from(this.processes.keys());
467
+ const killed = [];
468
+ for (const pid of pids) {
469
+ const p = this.processes.get(pid);
470
+ if (p && !p.protected && this.kill(pid, opts)) killed.push(pid);
471
+ }
472
+ return killed;
473
+ }
474
+ /**
475
+ * Kill all processes for a specific session.
476
+ * Returns the PIDs that were kill()ed.
477
+ */
478
+ killSession(sessionId, opts = {}) {
479
+ const pids = this.bySession(sessionId).map((p) => p.pid);
480
+ const killed = [];
481
+ for (const pid of pids) {
482
+ if (this.kill(pid, opts)) killed.push(pid);
483
+ }
484
+ return killed;
485
+ }
486
+ };
487
+ var _registry;
488
+ function getProcessRegistry() {
489
+ if (!_registry) {
490
+ _registry = new ProcessRegistryImpl();
491
+ }
492
+ return _registry;
493
+ }
8
494
  function resolveWin32Command(cmd) {
9
495
  if (process.platform !== "win32") return cmd;
10
- if (cmd.includes("/") || cmd.includes("\\") || path2.extname(cmd)) {
496
+ if (cmd.includes("/") || cmd.includes("\\") || path3.extname(cmd)) {
11
497
  return cmd;
12
498
  }
13
499
  const pathext = (process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.WS;.MSC").toLowerCase().split(";");
14
- const pathDirs = (process.env["PATH"] ?? "").split(path2.delimiter);
500
+ const pathDirs = (process.env["PATH"] ?? "").split(path3.delimiter);
15
501
  for (const dir of pathDirs) {
16
- const base = path2.join(dir, cmd);
502
+ const base = path3.join(dir, cmd);
17
503
  for (const ext of pathext) {
18
504
  const full = `${base}${ext}`;
19
505
  try {
@@ -35,15 +521,29 @@ async function* spawnStream(opts) {
35
521
  let stderr = "";
36
522
  let pending = "";
37
523
  let error;
524
+ const spool = createOutputSpool({ tool: opts.cmd, thresholdBytes: max });
38
525
  const cmd = resolveWin32Command(opts.cmd);
39
- const needsShell = process.platform === "win32" && (cmd.endsWith(".cmd") || cmd.endsWith(".bat"));
526
+ const isWin = process.platform === "win32";
527
+ const needsShell = isWin && (cmd.endsWith(".cmd") || cmd.endsWith(".bat"));
40
528
  const child = spawn(cmd, opts.args, {
41
529
  cwd: opts.cwd,
42
- signal: opts.signal,
43
530
  env: buildChildEnv(),
44
531
  stdio: ["ignore", "pipe", "pipe"],
532
+ windowsHide: true,
533
+ ...isWin ? {} : { signal: opts.signal },
45
534
  ...needsShell ? { shell: true, windowsVerbatimArguments: true } : {}
46
535
  });
536
+ const registry = getProcessRegistry();
537
+ const pid = child.pid;
538
+ if (typeof pid === "number") {
539
+ registry.register({
540
+ pid,
541
+ name: opts.cmd,
542
+ command: redactCommand(`${opts.cmd} ${opts.args.join(" ")}`),
543
+ startedAt: Date.now(),
544
+ child
545
+ });
546
+ }
47
547
  const queue = [];
48
548
  let waiter;
49
549
  let paused = false;
@@ -61,9 +561,10 @@ async function* spawnStream(opts) {
61
561
  child.stderr?.resume();
62
562
  }
63
563
  };
64
- child.stdout?.on("data", (c) => {
564
+ const onOut = (c) => {
65
565
  const s = c.toString();
66
566
  if (stdout.length < max) stdout += s;
567
+ spool.write(s);
67
568
  queue.push({ kind: "out", data: s });
68
569
  wake();
69
570
  if (!paused && queue.length >= maxQueue) {
@@ -71,10 +572,11 @@ async function* spawnStream(opts) {
71
572
  child.stdout?.pause();
72
573
  child.stderr?.pause();
73
574
  }
74
- });
75
- child.stderr?.on("data", (c) => {
575
+ };
576
+ const onErr = (c) => {
76
577
  const s = c.toString();
77
578
  if (stderr.length < max) stderr += s;
579
+ spool.write(s);
78
580
  queue.push({ kind: "err", data: s });
79
581
  wake();
80
582
  if (!paused && queue.length >= maxQueue) {
@@ -82,60 +584,101 @@ async function* spawnStream(opts) {
82
584
  child.stdout?.pause();
83
585
  child.stderr?.pause();
84
586
  }
85
- });
587
+ };
588
+ child.stdout?.on("data", onOut);
589
+ child.stderr?.on("data", onErr);
86
590
  child.on("error", (e) => {
87
591
  error = e.message;
88
592
  queue.push({ kind: "error", data: e.message });
89
593
  wake();
90
594
  });
91
595
  child.on("close", (code) => {
596
+ if (typeof pid === "number") registry.unregister(pid);
92
597
  queue.push({ kind: "close", data: "", code: code ?? 0 });
93
598
  wake();
94
599
  });
600
+ const onAbort = () => {
601
+ if (typeof pid === "number") {
602
+ registry.kill(pid, { force: true });
603
+ } else {
604
+ try {
605
+ child.kill("SIGKILL");
606
+ } catch {
607
+ }
608
+ }
609
+ queue.push({ kind: "close", data: "", code: 124 });
610
+ wake();
611
+ };
612
+ if (opts.signal.aborted) onAbort();
613
+ else opts.signal.addEventListener("abort", onAbort, { once: true });
95
614
  let exitCode = 0;
96
615
  let spawnFailed = false;
97
- for (; ; ) {
98
- while (queue.length === 0) {
99
- await new Promise((resolve2) => {
100
- waiter = resolve2;
101
- });
102
- }
103
- const chunk = queue.shift();
104
- resume();
105
- if (chunk.kind === "close") {
106
- if (!spawnFailed) exitCode = chunk.code ?? 0;
107
- break;
108
- }
109
- if (chunk.kind === "error") {
110
- spawnFailed = true;
111
- exitCode = 1;
112
- continue;
616
+ try {
617
+ for (; ; ) {
618
+ while (queue.length === 0) {
619
+ await new Promise((resolve2) => {
620
+ waiter = resolve2;
621
+ });
622
+ }
623
+ const chunk = queue.shift();
624
+ resume();
625
+ if (chunk.kind === "close") {
626
+ if (!spawnFailed) exitCode = chunk.code ?? 0;
627
+ break;
628
+ }
629
+ if (chunk.kind === "error") {
630
+ spawnFailed = true;
631
+ exitCode = 1;
632
+ continue;
633
+ }
634
+ pending += chunk.data;
635
+ if (pending.length >= flushAt) {
636
+ yield { type: "partial_output", text: pending };
637
+ pending = "";
638
+ }
113
639
  }
114
- pending += chunk.data;
115
- if (pending.length >= flushAt) {
640
+ if (pending.length > 0) {
116
641
  yield { type: "partial_output", text: pending };
117
- pending = "";
642
+ }
643
+ const spooled = spool.finalize();
644
+ return {
645
+ // The marker rides on stdout's tail so every consumer's head+tail
646
+ // normalization keeps it without per-tool changes.
647
+ stdout: spooled ? stdout + spoolNote(spooled) : stdout,
648
+ stderr,
649
+ exitCode,
650
+ truncated: stdout.length >= max || stderr.length >= max,
651
+ error,
652
+ spoolPath: spooled?.path,
653
+ spoolBytes: spooled?.bytes
654
+ };
655
+ } finally {
656
+ spool.finalize();
657
+ opts.signal.removeEventListener("abort", onAbort);
658
+ child.stdout?.off("data", onOut);
659
+ child.stderr?.off("data", onErr);
660
+ child.stdout?.destroy();
661
+ child.stderr?.destroy();
662
+ if (child.exitCode === null && !child.killed) {
663
+ if (typeof pid === "number") {
664
+ registry.kill(pid, { force: true });
665
+ } else {
666
+ try {
667
+ child.kill("SIGKILL");
668
+ } catch {
669
+ }
670
+ }
118
671
  }
119
672
  }
120
- if (pending.length > 0) {
121
- yield { type: "partial_output", text: pending };
122
- }
123
- return {
124
- stdout,
125
- stderr,
126
- exitCode,
127
- truncated: stdout.length >= max || stderr.length >= max,
128
- error
129
- };
130
673
  }
131
674
  function resolvePath(input, ctx) {
132
- return path2.isAbsolute(input) ? path2.normalize(input) : path2.resolve(ctx.workingDir ?? ctx.cwd, input);
675
+ return path3.isAbsolute(input) ? path3.normalize(input) : path3.resolve(ctx.workingDir ?? ctx.cwd, input);
133
676
  }
134
677
  function ensureInsideRoot(absPath, ctx) {
135
- const root = path2.resolve(ctx.projectRoot);
136
- const target = path2.resolve(absPath);
137
- const rel = path2.relative(root, target);
138
- if (rel.startsWith("..") || path2.isAbsolute(rel)) {
678
+ const root = path3.resolve(ctx.projectRoot);
679
+ const target = path3.resolve(absPath);
680
+ const rel = path3.relative(root, target);
681
+ if (rel.startsWith("..") || path3.isAbsolute(rel)) {
139
682
  throw new Error(`Path "${absPath}" is outside project root "${root}"`);
140
683
  }
141
684
  return target;
@@ -302,13 +845,13 @@ var formatTool = {
302
845
  }
303
846
  };
304
847
  async function detectFixer(cwd) {
305
- const { stat } = await import('node:fs/promises');
848
+ const { stat: stat2 } = await import('node:fs/promises');
306
849
  try {
307
- await stat(`${cwd}/biome.json`);
850
+ await stat2(`${cwd}/biome.json`);
308
851
  return "biome";
309
852
  } catch {
310
853
  try {
311
- await stat(`${cwd}/.prettierrc`);
854
+ await stat2(`${cwd}/.prettierrc`);
312
855
  return "prettier";
313
856
  } catch {
314
857
  return "biome";