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