@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.
- package/dist/audit.js +591 -48
- package/dist/audit.js.map +1 -1
- package/dist/background-indexer-CJ5JiV5i.d.ts +365 -0
- package/dist/bash.js +135 -20
- package/dist/bash.js.map +1 -1
- package/dist/builtin.js +1840 -1109
- package/dist/builtin.js.map +1 -1
- package/dist/codebase-index/index.d.ts +53 -2
- package/dist/codebase-index/index.js +870 -364
- package/dist/codebase-index/index.js.map +1 -1
- package/dist/codebase-index/worker.d.ts +2 -0
- package/dist/codebase-index/worker.js +2326 -0
- package/dist/codebase-index/worker.js.map +1 -0
- package/dist/diff.js +2 -1
- package/dist/diff.js.map +1 -1
- package/dist/exec.js +116 -5
- package/dist/exec.js.map +1 -1
- package/dist/format.js +591 -48
- package/dist/format.js.map +1 -1
- package/dist/git.js +2 -1
- package/dist/git.js.map +1 -1
- package/dist/grep.js +2 -2
- package/dist/grep.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1189 -496
- package/dist/index.js.map +1 -1
- package/dist/install.js +591 -48
- package/dist/install.js.map +1 -1
- package/dist/lint.js +590 -47
- package/dist/lint.js.map +1 -1
- package/dist/logs.js +1 -1
- package/dist/logs.js.map +1 -1
- package/dist/outdated.js +1 -1
- package/dist/outdated.js.map +1 -1
- package/dist/pack.js +1840 -1109
- package/dist/pack.js.map +1 -1
- package/dist/patch.js +1 -1
- package/dist/patch.js.map +1 -1
- package/dist/replace.js +3 -2
- package/dist/replace.js.map +1 -1
- package/dist/test.d.ts +1 -0
- package/dist/test.js +605 -55
- package/dist/test.js.map +1 -1
- package/dist/typecheck.js +591 -48
- package/dist/typecheck.js.map +1 -1
- package/package.json +3 -3
- package/dist/background-indexer-CtbgPExj.d.ts +0 -228
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
|
|
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("\\") ||
|
|
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(
|
|
499
|
+
const pathDirs = (process.env["PATH"] ?? "").split(path3.delimiter);
|
|
14
500
|
for (const dir of pathDirs) {
|
|
15
|
-
const base =
|
|
501
|
+
const base = path3.join(dir, cmd);
|
|
16
502
|
for (const ext of pathext) {
|
|
17
503
|
const full = `${base}${ext}`;
|
|
18
504
|
try {
|
|
@@ -34,15 +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
|
|
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"],
|
|
531
|
+
windowsHide: true,
|
|
532
|
+
...isWin ? {} : { signal: opts.signal },
|
|
44
533
|
...needsShell ? { shell: true, windowsVerbatimArguments: true } : {}
|
|
45
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
|
+
}
|
|
46
546
|
const queue = [];
|
|
47
547
|
let waiter;
|
|
48
548
|
let paused = false;
|
|
@@ -60,9 +560,10 @@ async function* spawnStream(opts) {
|
|
|
60
560
|
child.stderr?.resume();
|
|
61
561
|
}
|
|
62
562
|
};
|
|
63
|
-
|
|
563
|
+
const onOut = (c) => {
|
|
64
564
|
const s = c.toString();
|
|
65
565
|
if (stdout.length < max) stdout += s;
|
|
566
|
+
spool.write(s);
|
|
66
567
|
queue.push({ kind: "out", data: s });
|
|
67
568
|
wake();
|
|
68
569
|
if (!paused && queue.length >= maxQueue) {
|
|
@@ -70,10 +571,11 @@ async function* spawnStream(opts) {
|
|
|
70
571
|
child.stdout?.pause();
|
|
71
572
|
child.stderr?.pause();
|
|
72
573
|
}
|
|
73
|
-
}
|
|
74
|
-
|
|
574
|
+
};
|
|
575
|
+
const onErr = (c) => {
|
|
75
576
|
const s = c.toString();
|
|
76
577
|
if (stderr.length < max) stderr += s;
|
|
578
|
+
spool.write(s);
|
|
77
579
|
queue.push({ kind: "err", data: s });
|
|
78
580
|
wake();
|
|
79
581
|
if (!paused && queue.length >= maxQueue) {
|
|
@@ -81,74 +583,115 @@ async function* spawnStream(opts) {
|
|
|
81
583
|
child.stdout?.pause();
|
|
82
584
|
child.stderr?.pause();
|
|
83
585
|
}
|
|
84
|
-
}
|
|
586
|
+
};
|
|
587
|
+
child.stdout?.on("data", onOut);
|
|
588
|
+
child.stderr?.on("data", onErr);
|
|
85
589
|
child.on("error", (e) => {
|
|
86
590
|
error = e.message;
|
|
87
591
|
queue.push({ kind: "error", data: e.message });
|
|
88
592
|
wake();
|
|
89
593
|
});
|
|
90
594
|
child.on("close", (code) => {
|
|
595
|
+
if (typeof pid === "number") registry.unregister(pid);
|
|
91
596
|
queue.push({ kind: "close", data: "", code: code ?? 0 });
|
|
92
597
|
wake();
|
|
93
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 });
|
|
94
613
|
let exitCode = 0;
|
|
95
614
|
let spawnFailed = false;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
}
|
|
112
638
|
}
|
|
113
|
-
pending
|
|
114
|
-
if (pending.length >= flushAt) {
|
|
639
|
+
if (pending.length > 0) {
|
|
115
640
|
yield { type: "partial_output", text: pending };
|
|
116
|
-
|
|
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
|
+
}
|
|
117
670
|
}
|
|
118
671
|
}
|
|
119
|
-
if (pending.length > 0) {
|
|
120
|
-
yield { type: "partial_output", text: pending };
|
|
121
|
-
}
|
|
122
|
-
return {
|
|
123
|
-
stdout,
|
|
124
|
-
stderr,
|
|
125
|
-
exitCode,
|
|
126
|
-
truncated: stdout.length >= max || stderr.length >= max,
|
|
127
|
-
error
|
|
128
|
-
};
|
|
129
672
|
}
|
|
130
673
|
async function detectPackageManager(cwd) {
|
|
131
|
-
const { stat } = await import('node:fs/promises');
|
|
674
|
+
const { stat: stat2 } = await import('node:fs/promises');
|
|
132
675
|
try {
|
|
133
|
-
await
|
|
676
|
+
await stat2(`${cwd}/pnpm-lock.yaml`);
|
|
134
677
|
return "pnpm";
|
|
135
678
|
} catch {
|
|
136
679
|
}
|
|
137
680
|
try {
|
|
138
|
-
await
|
|
681
|
+
await stat2(`${cwd}/yarn.lock`);
|
|
139
682
|
return "yarn";
|
|
140
683
|
} catch {
|
|
141
684
|
}
|
|
142
685
|
return "npm";
|
|
143
686
|
}
|
|
144
687
|
function resolvePath(input, ctx) {
|
|
145
|
-
return
|
|
688
|
+
return path3.isAbsolute(input) ? path3.normalize(input) : path3.resolve(ctx.workingDir ?? ctx.cwd, input);
|
|
146
689
|
}
|
|
147
690
|
function ensureInsideRoot(absPath, ctx) {
|
|
148
|
-
const root =
|
|
149
|
-
const target =
|
|
150
|
-
const rel =
|
|
151
|
-
if (rel.startsWith("..") ||
|
|
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)) {
|
|
152
695
|
throw new Error(`Path "${absPath}" is outside project root "${root}"`);
|
|
153
696
|
}
|
|
154
697
|
return target;
|