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