@wrongstack/tools 0.5.5 → 0.5.7
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/bash.d.ts +2 -1
- package/dist/bash.js +361 -3
- package/dist/bash.js.map +1 -1
- package/dist/builtin.js +435 -12
- package/dist/builtin.js.map +1 -1
- package/dist/circuit-breaker.d.ts +111 -0
- package/dist/circuit-breaker.js +150 -0
- package/dist/circuit-breaker.js.map +1 -0
- package/dist/exec.js +346 -2
- package/dist/exec.js.map +1 -1
- package/dist/index.d.ts +19 -5
- package/dist/index.js +439 -13
- package/dist/index.js.map +1 -1
- package/dist/pack.js +435 -12
- package/dist/pack.js.map +1 -1
- package/dist/process-registry.d.ts +112 -0
- package/dist/process-registry.js +327 -0
- package/dist/process-registry.js.map +1 -0
- package/package.json +10 -2
package/dist/builtin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
-
import { buildChildEnv, stripAnsi, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff, compileGlob, loadPlan, emptyPlan, clearPlan, savePlan, removePlanItem, setPlanItemStatus,
|
|
2
|
+
import { buildChildEnv, stripAnsi, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff, compileGlob, loadPlan, emptyPlan, clearPlan, savePlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, setPlanItemStatus, formatPlan } from '@wrongstack/core';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import { dirname } from 'path';
|
|
5
5
|
import * as os from 'os';
|
|
@@ -232,6 +232,323 @@ function parseAuditOutput(json, exitCode) {
|
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
// src/circuit-breaker.ts
|
|
236
|
+
var DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
|
|
237
|
+
var DEFAULT_SLOW_CALL_THRESHOLD_MS = 6e4;
|
|
238
|
+
var DEFAULT_MAX_SLOW_CALLS = 3;
|
|
239
|
+
var DEFAULT_WINDOW_MS = 6e4;
|
|
240
|
+
var DEFAULT_MAX_CALLS_PER_WINDOW = 30;
|
|
241
|
+
var DEFAULT_COOLDOWN_MS = 3e4;
|
|
242
|
+
var CircuitBreaker = class {
|
|
243
|
+
maxConsecutiveFailures;
|
|
244
|
+
slowCallThresholdMs;
|
|
245
|
+
maxSlowCalls;
|
|
246
|
+
windowMs;
|
|
247
|
+
maxCallsPerWindow;
|
|
248
|
+
cooldownMs;
|
|
249
|
+
state = "closed";
|
|
250
|
+
consecutiveFailures = 0;
|
|
251
|
+
window = [];
|
|
252
|
+
lastFailureAt = null;
|
|
253
|
+
lastSlowAt = null;
|
|
254
|
+
/** Timestamp when the breaker was opened (for cooldown calculation). */
|
|
255
|
+
openedAt = null;
|
|
256
|
+
/** Timestamp when the last call ran (for half-open gate). */
|
|
257
|
+
lastCallAt = null;
|
|
258
|
+
constructor(config = {}) {
|
|
259
|
+
this.maxConsecutiveFailures = config.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES;
|
|
260
|
+
this.slowCallThresholdMs = config.slowCallThresholdMs ?? DEFAULT_SLOW_CALL_THRESHOLD_MS;
|
|
261
|
+
this.maxSlowCalls = config.maxSlowCalls ?? DEFAULT_MAX_SLOW_CALLS;
|
|
262
|
+
this.windowMs = config.windowMs ?? DEFAULT_WINDOW_MS;
|
|
263
|
+
this.maxCallsPerWindow = config.maxCallsPerWindow ?? DEFAULT_MAX_CALLS_PER_WINDOW;
|
|
264
|
+
this.cooldownMs = config.cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Returns true if the circuit allows a new call to proceed.
|
|
268
|
+
* When false, callers should abort the tool call and return a
|
|
269
|
+
* circuit-breaker error instead of spawning a process.
|
|
270
|
+
*/
|
|
271
|
+
get canProceed() {
|
|
272
|
+
this._checkStateTransition();
|
|
273
|
+
return this.state !== "open";
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Snapshot of the current breaker state for observability (`/kill`).
|
|
277
|
+
*/
|
|
278
|
+
snapshot() {
|
|
279
|
+
this._checkStateTransition();
|
|
280
|
+
const now = Date.now();
|
|
281
|
+
let cooldownRemaining = null;
|
|
282
|
+
if (this.openedAt !== null && this.state === "open") {
|
|
283
|
+
const elapsed = now - this.openedAt;
|
|
284
|
+
cooldownRemaining = Math.max(0, this.cooldownMs - elapsed);
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
state: this.state,
|
|
288
|
+
consecutiveFailures: this.consecutiveFailures,
|
|
289
|
+
slowCallsInWindow: this.window.filter((c) => c.slow).length,
|
|
290
|
+
callsInWindow: this.window.length,
|
|
291
|
+
windowMs: this.windowMs,
|
|
292
|
+
cooldownRemainingMs: cooldownRemaining,
|
|
293
|
+
lastFailureAt: this.lastFailureAt,
|
|
294
|
+
lastSlowAt: this.lastSlowAt
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Call this BEFORE spawning a bash/exec process.
|
|
299
|
+
* Returns true if the call is allowed; false if the breaker is open.
|
|
300
|
+
* When false, callers MUST NOT spawn a process.
|
|
301
|
+
*/
|
|
302
|
+
beforeCall() {
|
|
303
|
+
this._checkStateTransition();
|
|
304
|
+
if (this.state === "open") return false;
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Call this AFTER a bash/exec process finishes (success or failure).
|
|
309
|
+
* `durationMs` is the wall-clock time the process ran.
|
|
310
|
+
* `failed` is true when the process returned a non-zero exit code or
|
|
311
|
+
* threw an exception before spawning.
|
|
312
|
+
*/
|
|
313
|
+
afterCall(durationMs, failed) {
|
|
314
|
+
const now = Date.now();
|
|
315
|
+
this.lastCallAt = now;
|
|
316
|
+
if (this.state === "half-open") {
|
|
317
|
+
if (failed) {
|
|
318
|
+
this._trip();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
this._reset();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
this._pruneWindow(now);
|
|
325
|
+
const slow = durationMs >= this.slowCallThresholdMs;
|
|
326
|
+
this.window.push({ at: now, failed, slow });
|
|
327
|
+
if (failed) {
|
|
328
|
+
this.consecutiveFailures++;
|
|
329
|
+
this.lastFailureAt = now;
|
|
330
|
+
if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
|
|
331
|
+
this._trip();
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
this.consecutiveFailures = 0;
|
|
336
|
+
if (slow) {
|
|
337
|
+
this.lastSlowAt = now;
|
|
338
|
+
const slowCount = this.window.filter((c) => c.slow).length;
|
|
339
|
+
if (slowCount >= this.maxSlowCalls) {
|
|
340
|
+
this._trip();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const callCount = this.window.length;
|
|
344
|
+
if (callCount >= this.maxCallsPerWindow) {
|
|
345
|
+
this._trip();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/** Force the breaker open. Used by /kill force and Ctrl+C. */
|
|
349
|
+
forceOpen() {
|
|
350
|
+
this._trip();
|
|
351
|
+
}
|
|
352
|
+
/** Force a reset to closed. Used by tests and /kill reset. */
|
|
353
|
+
forceReset() {
|
|
354
|
+
this._reset();
|
|
355
|
+
}
|
|
356
|
+
_trip() {
|
|
357
|
+
if (this.state === "open") return;
|
|
358
|
+
this.state = "open";
|
|
359
|
+
this.openedAt = Date.now();
|
|
360
|
+
}
|
|
361
|
+
_reset() {
|
|
362
|
+
this.state = "closed";
|
|
363
|
+
this.consecutiveFailures = 0;
|
|
364
|
+
this.window = [];
|
|
365
|
+
this.openedAt = null;
|
|
366
|
+
}
|
|
367
|
+
/** Transition from open → half-open when cooldown elapses. */
|
|
368
|
+
_checkStateTransition() {
|
|
369
|
+
if (this.state !== "open" || this.openedAt === null) return;
|
|
370
|
+
const elapsed = Date.now() - this.openedAt;
|
|
371
|
+
if (elapsed >= this.cooldownMs) {
|
|
372
|
+
this.state = "half-open";
|
|
373
|
+
this.openedAt = null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
_pruneWindow(now) {
|
|
377
|
+
const cutoff = now - this.windowMs;
|
|
378
|
+
this.window = this.window.filter((c) => c.at >= cutoff);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// src/process-registry.ts
|
|
383
|
+
var DEFAULT_GRACE_MS = 2e3;
|
|
384
|
+
var ProcessRegistryImpl = class {
|
|
385
|
+
processes = /* @__PURE__ */ new Map();
|
|
386
|
+
breaker;
|
|
387
|
+
constructor(breakerConfig) {
|
|
388
|
+
this.breaker = new CircuitBreaker(breakerConfig);
|
|
389
|
+
}
|
|
390
|
+
register(info) {
|
|
391
|
+
this.processes.set(info.pid, { ...info, killed: false });
|
|
392
|
+
}
|
|
393
|
+
/** Unregister a process by PID. Called on 'close' / 'exit' events. */
|
|
394
|
+
unregister(pid) {
|
|
395
|
+
this.processes.delete(pid);
|
|
396
|
+
}
|
|
397
|
+
/** Get a single process by PID. */
|
|
398
|
+
get(pid) {
|
|
399
|
+
return this.processes.get(pid);
|
|
400
|
+
}
|
|
401
|
+
/** Get all tracked processes. */
|
|
402
|
+
list() {
|
|
403
|
+
return Array.from(this.processes.values());
|
|
404
|
+
}
|
|
405
|
+
/** Get processes filtered by name (e.g. 'bash', 'exec'). */
|
|
406
|
+
byName(name) {
|
|
407
|
+
return this.list().filter((p) => p.name === name);
|
|
408
|
+
}
|
|
409
|
+
/** Get processes filtered by session. */
|
|
410
|
+
bySession(sessionId) {
|
|
411
|
+
return this.list().filter((p) => p.sessionId === sessionId);
|
|
412
|
+
}
|
|
413
|
+
/** Count of active (non-killed) processes. */
|
|
414
|
+
get activeCount() {
|
|
415
|
+
let n = 0;
|
|
416
|
+
for (const p of this.processes.values()) {
|
|
417
|
+
if (!p.killed) n++;
|
|
418
|
+
}
|
|
419
|
+
return n;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Combined stats for observability — used by /ps and the TUI status bar.
|
|
423
|
+
*/
|
|
424
|
+
stats() {
|
|
425
|
+
return {
|
|
426
|
+
activeCount: this.activeCount,
|
|
427
|
+
totalCount: this.processes.size,
|
|
428
|
+
breaker: this.breaker.snapshot()
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Returns true if the circuit allows a new bash/exec call to proceed.
|
|
433
|
+
* When false, callers MUST NOT spawn a process.
|
|
434
|
+
*/
|
|
435
|
+
get canProceed() {
|
|
436
|
+
return this.breaker.canProceed;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Called before spawning a process. Returns true if allowed; false if
|
|
440
|
+
* the circuit breaker is open.
|
|
441
|
+
*/
|
|
442
|
+
beforeCall() {
|
|
443
|
+
return this.breaker.beforeCall();
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Called after a process finishes. `durationMs` is wall-clock time;
|
|
447
|
+
* `failed` is true for non-zero exit codes.
|
|
448
|
+
*/
|
|
449
|
+
afterCall(durationMs, failed) {
|
|
450
|
+
this.breaker.afterCall(durationMs, failed);
|
|
451
|
+
}
|
|
452
|
+
/** Force-open the circuit breaker (Ctrl+C, /kill force). */
|
|
453
|
+
forceBreakerOpen() {
|
|
454
|
+
this.breaker.forceOpen();
|
|
455
|
+
}
|
|
456
|
+
/** Force-reset the circuit breaker to closed (/kill reset). */
|
|
457
|
+
forceBreakerReset() {
|
|
458
|
+
this.breaker.forceReset();
|
|
459
|
+
}
|
|
460
|
+
/** Kill a single process by PID.
|
|
461
|
+
*
|
|
462
|
+
* On POSIX: sends SIGTERM to the *process group* (-pid) so that
|
|
463
|
+
* runaway grandchild processes (`sleep 9999 & disown`) are also killed.
|
|
464
|
+
* After `graceMs` a SIGKILL is sent if the process hasn't exited.
|
|
465
|
+
*
|
|
466
|
+
* On Windows: `child.kill()` maps to TerminateProcess — process groups
|
|
467
|
+
* are not meaningfully supported. A second `force=true` call sends
|
|
468
|
+
* SIGKILL (which maps to TerminateProcess again — the distinction is
|
|
469
|
+
* in the exit code, not the signal).
|
|
470
|
+
*
|
|
471
|
+
* Returns true if the process was found and kill was attempted.
|
|
472
|
+
*/
|
|
473
|
+
kill(pid, opts = {}) {
|
|
474
|
+
const p = this.processes.get(pid);
|
|
475
|
+
if (!p) return false;
|
|
476
|
+
if (p.killed) return true;
|
|
477
|
+
const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;
|
|
478
|
+
const isWin = os.platform() === "win32";
|
|
479
|
+
if (isWin) {
|
|
480
|
+
try {
|
|
481
|
+
p.child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
482
|
+
} catch {
|
|
483
|
+
}
|
|
484
|
+
p.killed = true;
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
if (force) {
|
|
489
|
+
try {
|
|
490
|
+
process.kill(-pid, "SIGKILL");
|
|
491
|
+
} catch {
|
|
492
|
+
p.child.kill("SIGKILL");
|
|
493
|
+
}
|
|
494
|
+
} else {
|
|
495
|
+
try {
|
|
496
|
+
process.kill(-pid, "SIGTERM");
|
|
497
|
+
} catch {
|
|
498
|
+
p.child.kill("SIGTERM");
|
|
499
|
+
}
|
|
500
|
+
const timer = setTimeout(() => {
|
|
501
|
+
if (this.processes.has(pid) && !p.child.killed) {
|
|
502
|
+
try {
|
|
503
|
+
process.kill(-pid, "SIGKILL");
|
|
504
|
+
} catch {
|
|
505
|
+
try {
|
|
506
|
+
p.child.kill("SIGKILL");
|
|
507
|
+
} catch {
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}, graceMs);
|
|
512
|
+
timer.unref?.();
|
|
513
|
+
}
|
|
514
|
+
} catch {
|
|
515
|
+
}
|
|
516
|
+
p.killed = true;
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Kill all tracked processes.
|
|
521
|
+
* Returns the PIDs that were kill()ed.
|
|
522
|
+
*/
|
|
523
|
+
killAll(opts = {}) {
|
|
524
|
+
const pids = Array.from(this.processes.keys());
|
|
525
|
+
const killed = [];
|
|
526
|
+
for (const pid of pids) {
|
|
527
|
+
if (this.kill(pid, opts)) killed.push(pid);
|
|
528
|
+
}
|
|
529
|
+
return killed;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Kill all processes for a specific session.
|
|
533
|
+
* Returns the PIDs that were kill()ed.
|
|
534
|
+
*/
|
|
535
|
+
killSession(sessionId, opts = {}) {
|
|
536
|
+
const pids = this.bySession(sessionId).map((p) => p.pid);
|
|
537
|
+
const killed = [];
|
|
538
|
+
for (const pid of pids) {
|
|
539
|
+
if (this.kill(pid, opts)) killed.push(pid);
|
|
540
|
+
}
|
|
541
|
+
return killed;
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
var _registry;
|
|
545
|
+
function getProcessRegistry() {
|
|
546
|
+
if (!_registry) {
|
|
547
|
+
_registry = new ProcessRegistryImpl();
|
|
548
|
+
}
|
|
549
|
+
return _registry;
|
|
550
|
+
}
|
|
551
|
+
|
|
235
552
|
// src/bash.ts
|
|
236
553
|
var MAX_OUTPUT = 32768;
|
|
237
554
|
var DEFAULT_TIMEOUT = 3e4;
|
|
@@ -270,12 +587,27 @@ var bashTool = {
|
|
|
270
587
|
},
|
|
271
588
|
async *executeStream(input, ctx, opts) {
|
|
272
589
|
if (!input?.command) throw new Error("bash: command is required");
|
|
590
|
+
const registry = getProcessRegistry();
|
|
591
|
+
if (!registry.beforeCall()) {
|
|
592
|
+
yield {
|
|
593
|
+
type: "final",
|
|
594
|
+
output: {
|
|
595
|
+
output: "",
|
|
596
|
+
exit_code: 1,
|
|
597
|
+
timed_out: false,
|
|
598
|
+
pid: null,
|
|
599
|
+
error: "bash: circuit breaker open \u2014 too many consecutive failures or slow calls. Use /kill to inspect or /kill reset to recover."
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
273
604
|
const timeoutMs = Math.max(1, Math.min(input.timeout_ms ?? DEFAULT_TIMEOUT, 6e5));
|
|
274
605
|
const isWin = os.platform() === "win32";
|
|
275
606
|
const shell = isWin ? process.env["COMSPEC"] ?? "cmd.exe" : process.env["SHELL"] ?? "/bin/bash";
|
|
276
607
|
const args = isWin ? ["/c", input.command] : ["-c", input.command];
|
|
277
608
|
const env = buildChildEnv(ctx.session?.id);
|
|
278
609
|
const detached = isWin ? !!input.background : true;
|
|
610
|
+
const startedAt = Date.now();
|
|
279
611
|
if (input.background) {
|
|
280
612
|
let buf2 = "";
|
|
281
613
|
let truncated = false;
|
|
@@ -286,7 +618,18 @@ var bashTool = {
|
|
|
286
618
|
detached: true,
|
|
287
619
|
signal: opts.signal
|
|
288
620
|
});
|
|
289
|
-
const
|
|
621
|
+
const pid2 = child2.pid;
|
|
622
|
+
if (typeof pid2 === "number") {
|
|
623
|
+
registry.register({
|
|
624
|
+
pid: pid2,
|
|
625
|
+
name: "bash",
|
|
626
|
+
command: input.command,
|
|
627
|
+
startedAt: Date.now(),
|
|
628
|
+
sessionId: ctx.session?.id,
|
|
629
|
+
child: child2
|
|
630
|
+
});
|
|
631
|
+
child2.on("close", () => registry.unregister(pid2));
|
|
632
|
+
}
|
|
290
633
|
child2.stdout?.on("data", (chunk) => {
|
|
291
634
|
if (!truncated) {
|
|
292
635
|
const remain = MAX_OUTPUT - buf2.length;
|
|
@@ -306,15 +649,16 @@ var bashTool = {
|
|
|
306
649
|
}
|
|
307
650
|
});
|
|
308
651
|
child2.on("close", () => {
|
|
652
|
+
registry.afterCall(Date.now() - startedAt, false);
|
|
309
653
|
});
|
|
310
|
-
if (typeof
|
|
654
|
+
if (typeof pid2 === "number") child2.unref();
|
|
311
655
|
yield {
|
|
312
656
|
type: "final",
|
|
313
657
|
output: {
|
|
314
658
|
output: truncated ? buf2.slice(0, MAX_OUTPUT) + "\u2026[truncated]" : buf2,
|
|
315
659
|
exit_code: null,
|
|
316
660
|
timed_out: false,
|
|
317
|
-
pid
|
|
661
|
+
pid: pid2
|
|
318
662
|
}
|
|
319
663
|
};
|
|
320
664
|
return;
|
|
@@ -326,6 +670,17 @@ var bashTool = {
|
|
|
326
670
|
detached,
|
|
327
671
|
signal: opts.signal
|
|
328
672
|
});
|
|
673
|
+
const pid = child.pid;
|
|
674
|
+
if (typeof pid === "number") {
|
|
675
|
+
registry.register({
|
|
676
|
+
pid,
|
|
677
|
+
name: "bash",
|
|
678
|
+
command: input.command,
|
|
679
|
+
startedAt: Date.now(),
|
|
680
|
+
sessionId: ctx.session?.id,
|
|
681
|
+
child
|
|
682
|
+
});
|
|
683
|
+
}
|
|
329
684
|
let buf = "";
|
|
330
685
|
let pending = "";
|
|
331
686
|
let timedOut = false;
|
|
@@ -408,10 +763,13 @@ var bashTool = {
|
|
|
408
763
|
});
|
|
409
764
|
child.on("error", (err) => {
|
|
410
765
|
for (const t of timers) clearTimeout(t);
|
|
766
|
+
registry.afterCall(Date.now() - startedAt, true);
|
|
411
767
|
push({ kind: "error", err });
|
|
412
768
|
});
|
|
413
769
|
child.on("close", (code) => {
|
|
414
770
|
for (const t of timers) clearTimeout(t);
|
|
771
|
+
if (typeof pid === "number") registry.unregister(pid);
|
|
772
|
+
registry.afterCall(Date.now() - startedAt, code !== 0 && code !== null);
|
|
415
773
|
push({ kind: "end", code });
|
|
416
774
|
});
|
|
417
775
|
try {
|
|
@@ -1029,6 +1387,18 @@ var execTool = {
|
|
|
1029
1387
|
required: ["command"]
|
|
1030
1388
|
},
|
|
1031
1389
|
async execute(input, ctx, opts) {
|
|
1390
|
+
const registry = getProcessRegistry();
|
|
1391
|
+
if (!registry.canProceed) {
|
|
1392
|
+
return {
|
|
1393
|
+
command: input.command,
|
|
1394
|
+
args: input.args ?? [],
|
|
1395
|
+
stdout: "",
|
|
1396
|
+
stderr: "Circuit breaker is open \u2014 too many consecutive failures. Use /kill reset to recover.",
|
|
1397
|
+
exitCode: 1,
|
|
1398
|
+
truncated: false,
|
|
1399
|
+
allowed: false
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1032
1402
|
const cmd = input.command.trim();
|
|
1033
1403
|
if (!cmd)
|
|
1034
1404
|
return {
|
|
@@ -1088,15 +1458,23 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
|
|
|
1088
1458
|
let stdout = "";
|
|
1089
1459
|
let stderr = "";
|
|
1090
1460
|
let killed = false;
|
|
1461
|
+
const startedAt = Date.now();
|
|
1091
1462
|
const child = spawn(cmd, args, {
|
|
1092
1463
|
cwd,
|
|
1093
1464
|
signal,
|
|
1094
1465
|
env: buildChildEnv(sessionId),
|
|
1095
1466
|
stdio: ["ignore", "pipe", "pipe"]
|
|
1096
1467
|
});
|
|
1468
|
+
const registry = getProcessRegistry();
|
|
1469
|
+
const pid = child.pid;
|
|
1470
|
+
if (typeof pid === "number") {
|
|
1471
|
+
const fullCommand = `${cmd} ${args.join(" ")}`;
|
|
1472
|
+
registry.register({ pid, name: "exec", command: fullCommand, startedAt: Date.now(), sessionId, child });
|
|
1473
|
+
}
|
|
1097
1474
|
const timer = setTimeout(() => {
|
|
1098
1475
|
killed = true;
|
|
1099
|
-
|
|
1476
|
+
if (typeof pid === "number") registry.kill(pid);
|
|
1477
|
+
else child.kill("SIGTERM");
|
|
1100
1478
|
}, timeout);
|
|
1101
1479
|
child.stdout?.on("data", (chunk) => {
|
|
1102
1480
|
if (stdout.length < MAX_OUTPUT2) stdout += chunk.toString();
|
|
@@ -1106,18 +1484,24 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
|
|
|
1106
1484
|
});
|
|
1107
1485
|
child.on("close", (code) => {
|
|
1108
1486
|
clearTimeout(timer);
|
|
1487
|
+
if (typeof pid === "number") registry.unregister(pid);
|
|
1488
|
+
const durationMs = Date.now() - startedAt;
|
|
1489
|
+
const exitCode = killed ? 124 : code ?? 1;
|
|
1490
|
+
registry.afterCall(durationMs, exitCode !== 0);
|
|
1109
1491
|
resolve5({
|
|
1110
1492
|
command: cmd,
|
|
1111
1493
|
args,
|
|
1112
1494
|
stdout: stdout.slice(0, MAX_OUTPUT2),
|
|
1113
1495
|
stderr: stderr.slice(0, MAX_OUTPUT2),
|
|
1114
|
-
exitCode
|
|
1496
|
+
exitCode,
|
|
1115
1497
|
truncated: stdout.length >= MAX_OUTPUT2 || stderr.length >= MAX_OUTPUT2,
|
|
1116
1498
|
allowed: true
|
|
1117
1499
|
});
|
|
1118
1500
|
});
|
|
1119
1501
|
child.on("error", (err) => {
|
|
1120
1502
|
clearTimeout(timer);
|
|
1503
|
+
if (typeof pid === "number") registry.unregister(pid);
|
|
1504
|
+
registry.afterCall(Date.now() - startedAt, true);
|
|
1121
1505
|
resolve5({
|
|
1122
1506
|
command: cmd,
|
|
1123
1507
|
args,
|
|
@@ -2799,8 +3183,8 @@ function extractPatchedFiles(output) {
|
|
|
2799
3183
|
var planTool = {
|
|
2800
3184
|
name: "plan",
|
|
2801
3185
|
category: "Session",
|
|
2802
|
-
description: "Inspect or edit the strategic plan board for this session. Plans persist across resume (unlike todos). Use this to lay out the multi-step approach before diving in, then mark steps in_progress/done as the work proceeds.",
|
|
2803
|
-
usageHint:
|
|
3186
|
+
description: "Inspect or edit the strategic plan board for this session. Plans persist across resume (unlike todos). Use this to lay out the multi-step approach before diving in, then mark steps in_progress/done as the work proceeds. Promote a plan item to todos to start working on it. Apply templates for common workflows.",
|
|
3187
|
+
usageHint: 'Set action to one of: show | add | start | done | remove | promote | derive | template_use | clear. Pass `title` for add. Pass `target` (item id, 1-based index, or title substring) for start/done/remove/promote/derive. Pass `subtasks` for promote/derive to break the plan item into multiple todos. Pass `template` (e.g. "new-feature", "bug-fix", "refactor", "release") for template_use. Always returns the formatted plan plus open/total counts.',
|
|
2804
3188
|
permission: "auto",
|
|
2805
3189
|
mutating: false,
|
|
2806
3190
|
timeoutMs: 2e3,
|
|
@@ -2809,13 +3193,22 @@ var planTool = {
|
|
|
2809
3193
|
properties: {
|
|
2810
3194
|
action: {
|
|
2811
3195
|
type: "string",
|
|
2812
|
-
enum: ["show", "add", "start", "done", "remove", "clear"]
|
|
3196
|
+
enum: ["show", "add", "start", "done", "remove", "promote", "derive", "template_use", "clear"]
|
|
2813
3197
|
},
|
|
2814
3198
|
title: { type: "string", description: "Required when action = add." },
|
|
2815
3199
|
details: { type: "string", description: "Optional extra context for add." },
|
|
2816
3200
|
target: {
|
|
2817
3201
|
type: "string",
|
|
2818
|
-
description: "Plan item id, 1-based index, or title substring. Required for start/done/remove."
|
|
3202
|
+
description: "Plan item id, 1-based index, or title substring. Required for start/done/remove/promote/derive."
|
|
3203
|
+
},
|
|
3204
|
+
subtasks: {
|
|
3205
|
+
type: "array",
|
|
3206
|
+
items: { type: "string" },
|
|
3207
|
+
description: "Optional subtasks for promote/derive. If omitted, a single todo is created from the plan item title."
|
|
3208
|
+
},
|
|
3209
|
+
template: {
|
|
3210
|
+
type: "string",
|
|
3211
|
+
description: "Template name for template_use action. Available: new-feature, bug-fix, refactor, release, security-audit, onboarding."
|
|
2819
3212
|
}
|
|
2820
3213
|
},
|
|
2821
3214
|
required: ["action"]
|
|
@@ -2874,6 +3267,35 @@ var planTool = {
|
|
|
2874
3267
|
await savePlan(planPath, plan);
|
|
2875
3268
|
break;
|
|
2876
3269
|
}
|
|
3270
|
+
case "promote":
|
|
3271
|
+
case "derive": {
|
|
3272
|
+
if (!input.target) {
|
|
3273
|
+
return mkResult(plan, false, `${input.action} requires \`target\` (id|index|substring).`);
|
|
3274
|
+
}
|
|
3275
|
+
const derived = deriveTodosFromPlanItem(plan, input.target, input.subtasks);
|
|
3276
|
+
if (!derived) {
|
|
3277
|
+
return mkResult(plan, false, `No plan item matched "${input.target}".`);
|
|
3278
|
+
}
|
|
3279
|
+
plan = derived.plan;
|
|
3280
|
+
await savePlan(planPath, plan);
|
|
3281
|
+
ctx.state.replaceTodos(derived.todos);
|
|
3282
|
+
return mkResult(plan, true, `${input.action} ok \u2014 ${derived.todos.length} todo(s) created.`, derived.todos);
|
|
3283
|
+
}
|
|
3284
|
+
case "template_use": {
|
|
3285
|
+
const templateName = input.template?.trim();
|
|
3286
|
+
if (!templateName) {
|
|
3287
|
+
return mkResult(plan, false, "template_use requires `template` name.");
|
|
3288
|
+
}
|
|
3289
|
+
const template = getPlanTemplate(templateName);
|
|
3290
|
+
if (!template) {
|
|
3291
|
+
return mkResult(plan, false, `Unknown template "${templateName}".`);
|
|
3292
|
+
}
|
|
3293
|
+
for (const item of template.items) {
|
|
3294
|
+
({ plan } = addPlanItem(plan, item.title, item.details));
|
|
3295
|
+
}
|
|
3296
|
+
await savePlan(planPath, plan);
|
|
3297
|
+
return mkResult(plan, true, `Applied template "${template.name}" \u2014 ${template.items.length} items added.`);
|
|
3298
|
+
}
|
|
2877
3299
|
case "clear":
|
|
2878
3300
|
plan = clearPlan(plan);
|
|
2879
3301
|
await savePlan(planPath, plan);
|
|
@@ -2884,14 +3306,15 @@ var planTool = {
|
|
|
2884
3306
|
return mkResult(plan, true, `Plan ${input.action} ok.`);
|
|
2885
3307
|
}
|
|
2886
3308
|
};
|
|
2887
|
-
function mkResult(plan, ok, message) {
|
|
3309
|
+
function mkResult(plan, ok, message, todos) {
|
|
2888
3310
|
const open = plan.items.filter((i) => i.status !== "done").length;
|
|
2889
3311
|
return {
|
|
2890
3312
|
ok,
|
|
2891
3313
|
message,
|
|
2892
3314
|
plan: formatPlan(plan),
|
|
2893
3315
|
count: plan.items.length,
|
|
2894
|
-
open
|
|
3316
|
+
open,
|
|
3317
|
+
todos
|
|
2895
3318
|
};
|
|
2896
3319
|
}
|
|
2897
3320
|
var MAX_BYTES2 = 5 * 1024 * 1024;
|