@livx.cc/agentx 0.99.46 → 0.99.48
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/README.md +2 -1
- package/dist/{Agent-OcAg2yxA.d.ts → Agent-BeoEFl2T.d.ts} +22 -7
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +904 -417
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.js +712 -304
- package/dist/index.js.map +1 -1
- package/dist/{mcp-kgG6rtsd.d.ts → mcp-DjYZzGGW.d.ts} +13 -4
- package/dist/mcp.client.d.ts +33 -7
- package/dist/mcp.client.js +147 -40
- package/dist/mcp.client.js.map +1 -1
- package/dist/{tools-BqL8Lk4J.d.ts → tools-MNdqlJIa.d.ts} +180 -40
- package/dist/tools.shell.d.ts +40 -11
- package/dist/tools.shell.js +452 -91
- package/dist/tools.shell.js.map +1 -1
- package/package.json +2 -2
package/dist/tools.shell.js
CHANGED
|
@@ -533,8 +533,331 @@ async function findSandboxWrapper(platform = process.platform) {
|
|
|
533
533
|
return null;
|
|
534
534
|
}
|
|
535
535
|
|
|
536
|
+
// src/tools.jobs.ts
|
|
537
|
+
var log5 = forComponent("jobs");
|
|
538
|
+
var JobRegistryOptions = class {
|
|
539
|
+
/** Tail buffer cap per job (chars). */
|
|
540
|
+
maxBuffer = 256 * 1024;
|
|
541
|
+
/** Idle window per kind: no output/progress/CPU for this long ⇒ stuck. Kinds without an activity channel
|
|
542
|
+
* (host, native) are absent on purpose — silence is all they can ever show, so it proves nothing; their
|
|
543
|
+
* bound is the hard cap. Explicit `background:true` shell jobs opt out per job (a quiet server is healthy). */
|
|
544
|
+
idleMs = { shell: 10 * 6e4, mcp: 10 * 6e4 };
|
|
545
|
+
/** Absolute ceiling per kind (0/absent = none). */
|
|
546
|
+
hardCapMs = { mcp: 30 * 6e4 };
|
|
547
|
+
/** Soft foreground timeout per kind: past it the caller stops waiting and gets a job id instead. */
|
|
548
|
+
foregroundMs = { mcp: 3e4 };
|
|
549
|
+
/** How often the reaper runs while reapable jobs exist. */
|
|
550
|
+
sweepMs = 15e3;
|
|
551
|
+
/** Grace between the kill signal and a forced kill (producers that escalate read this). */
|
|
552
|
+
killGraceMs = 5e3;
|
|
553
|
+
/** Finished jobs kept for inspection; older finished ones are pruned (every delegated tool call is a job). */
|
|
554
|
+
keepFinished = 200;
|
|
555
|
+
/** Clock (injectable for tests). Late-bound so a patched Date.now is honoured. */
|
|
556
|
+
now = () => Date.now();
|
|
557
|
+
/** Terminal-state notification for jobs with `notify` (the Agent wires this to `inject`). */
|
|
558
|
+
onExit;
|
|
559
|
+
};
|
|
560
|
+
var secs = (ms) => `${Math.round(ms / 1e3)}s`;
|
|
561
|
+
var JobRegistry = class {
|
|
562
|
+
options;
|
|
563
|
+
jobs = /* @__PURE__ */ new Map();
|
|
564
|
+
seq = 0;
|
|
565
|
+
timer;
|
|
566
|
+
constructor(options) {
|
|
567
|
+
this.options = { ...new JobRegistryOptions(), ...options };
|
|
568
|
+
}
|
|
569
|
+
/** Register externally-driven work (a process, a request, a native tool) and get a handle to feed it. */
|
|
570
|
+
track(opts = {}) {
|
|
571
|
+
const o = this.options;
|
|
572
|
+
const kind = opts.kind ?? "task";
|
|
573
|
+
const now = o.now();
|
|
574
|
+
let settle;
|
|
575
|
+
const done = new Promise((r) => {
|
|
576
|
+
settle = r;
|
|
577
|
+
});
|
|
578
|
+
const job = {
|
|
579
|
+
id: `job-${++this.seq}`,
|
|
580
|
+
kind,
|
|
581
|
+
label: opts.label ?? "",
|
|
582
|
+
status: "running",
|
|
583
|
+
startedAt: now,
|
|
584
|
+
lastActivityAt: now,
|
|
585
|
+
bytes: 0,
|
|
586
|
+
holds: !!opts.holds,
|
|
587
|
+
buf: "",
|
|
588
|
+
dropped: 0,
|
|
589
|
+
idleMs: opts.idleMs ?? o.idleMs[kind] ?? 0,
|
|
590
|
+
hardCapMs: opts.hardCapMs ?? o.hardCapMs[kind] ?? 0,
|
|
591
|
+
drain: !!opts.drain,
|
|
592
|
+
notify: !!opts.notify,
|
|
593
|
+
idleAfterActivity: !!opts.idleAfterActivity,
|
|
594
|
+
active: false,
|
|
595
|
+
cpuTime: opts.cpuTime,
|
|
596
|
+
onKill: opts.onKill,
|
|
597
|
+
onExit: opts.onExit,
|
|
598
|
+
done,
|
|
599
|
+
settle
|
|
600
|
+
};
|
|
601
|
+
this.jobs.set(job.id, job);
|
|
602
|
+
if (opts.seed) this.append(job, opts.seed);
|
|
603
|
+
this.arm();
|
|
604
|
+
return {
|
|
605
|
+
id: job.id,
|
|
606
|
+
chunk: (s) => {
|
|
607
|
+
if (s) {
|
|
608
|
+
this.append(job, s);
|
|
609
|
+
job.lastActivityAt = o.now();
|
|
610
|
+
job.active = true;
|
|
611
|
+
}
|
|
612
|
+
},
|
|
613
|
+
touch: () => {
|
|
614
|
+
job.lastActivityAt = o.now();
|
|
615
|
+
job.active = true;
|
|
616
|
+
},
|
|
617
|
+
finish: (status, result, reason) => this.end(job, status, result, reason),
|
|
618
|
+
detach: () => {
|
|
619
|
+
job.holds = false;
|
|
620
|
+
job.notify = true;
|
|
621
|
+
job.drain = true;
|
|
622
|
+
},
|
|
623
|
+
get status() {
|
|
624
|
+
return job.status;
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
/** Start `fn` in the background and return a job id immediately (sandbox/subagent semantics: drained at turn end). */
|
|
629
|
+
start(fn, opts = {}) {
|
|
630
|
+
return this.run(fn, { drain: true, ...opts }).handle.id;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Run `fn` in the FOREGROUND with a soft timeout: resolves `{done:true,value}` if it settles in time, else
|
|
634
|
+
* `{done:false,id}` — the work keeps going as a detached job whose completion is reported via `onExit`.
|
|
635
|
+
* A rejection before the timeout is rethrown (the caller's normal error path). `softTimeoutMs` ≤ 0 waits forever.
|
|
636
|
+
*/
|
|
637
|
+
async runForeground(fn, opts = {}) {
|
|
638
|
+
const { handle, promise } = this.run(fn, opts);
|
|
639
|
+
const soft = opts.softTimeoutMs ?? this.options.foregroundMs[opts.kind ?? "task"] ?? 0;
|
|
640
|
+
let t;
|
|
641
|
+
const late = soft > 0 ? new Promise((r) => {
|
|
642
|
+
t = setTimeout(() => r("late"), soft);
|
|
643
|
+
}) : new Promise(() => {
|
|
644
|
+
});
|
|
645
|
+
try {
|
|
646
|
+
const r = await Promise.race([promise.then((v) => ({ v }), (e) => ({ e })), late]);
|
|
647
|
+
if (r !== "late" && handle.status !== "running") this.jobs.delete(handle.id);
|
|
648
|
+
if (r === "late") {
|
|
649
|
+
if (handle.status === "running") {
|
|
650
|
+
handle.detach();
|
|
651
|
+
return { done: false, id: handle.id };
|
|
652
|
+
}
|
|
653
|
+
const settled = await promise.then((v) => ({ v }), (e) => ({ e }));
|
|
654
|
+
if ("e" in settled) throw settled.e;
|
|
655
|
+
return { done: true, value: settled.v };
|
|
656
|
+
}
|
|
657
|
+
if ("e" in r) throw r.e;
|
|
658
|
+
return { done: true, value: r.v };
|
|
659
|
+
} finally {
|
|
660
|
+
if (t) clearTimeout(t);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
run(fn, opts) {
|
|
664
|
+
const ctl = new AbortController();
|
|
665
|
+
const handle = this.track({ ...opts, onKill: (reason) => {
|
|
666
|
+
ctl.abort(Object.assign(new Error(reason), { name: "AbortError", code: "job_killed" }));
|
|
667
|
+
opts.onKill?.(reason);
|
|
668
|
+
} });
|
|
669
|
+
let promise;
|
|
670
|
+
try {
|
|
671
|
+
promise = fn({ signal: ctl.signal, onChunk: handle.chunk, touch: handle.touch });
|
|
672
|
+
} catch (e) {
|
|
673
|
+
promise = Promise.reject(e);
|
|
674
|
+
}
|
|
675
|
+
promise.then(
|
|
676
|
+
(res) => {
|
|
677
|
+
if (handle.status === "running" && typeof res === "string" && res) handle.chunk(res);
|
|
678
|
+
handle.finish("done", res);
|
|
679
|
+
},
|
|
680
|
+
(e) => {
|
|
681
|
+
if (handle.status === "running") handle.chunk(`
|
|
682
|
+
[error] ${e?.message ?? e}`);
|
|
683
|
+
handle.finish("error", void 0, e?.message ?? String(e));
|
|
684
|
+
}
|
|
685
|
+
);
|
|
686
|
+
return { handle, promise };
|
|
687
|
+
}
|
|
688
|
+
append(job, s) {
|
|
689
|
+
job.buf += s;
|
|
690
|
+
const over = job.buf.length - this.options.maxBuffer;
|
|
691
|
+
if (over > 0) {
|
|
692
|
+
job.buf = job.buf.slice(over);
|
|
693
|
+
job.dropped += over;
|
|
694
|
+
}
|
|
695
|
+
job.bytes = job.buf.length;
|
|
696
|
+
}
|
|
697
|
+
end(job, status, result, reason) {
|
|
698
|
+
if (job.status !== "running") return;
|
|
699
|
+
job.status = status;
|
|
700
|
+
job.endedAt = this.options.now();
|
|
701
|
+
job.result = result;
|
|
702
|
+
if (reason && status !== "done") job.reason = reason;
|
|
703
|
+
job.holds = false;
|
|
704
|
+
job.settle();
|
|
705
|
+
this.prune();
|
|
706
|
+
const onExit = job.onExit ?? this.options.onExit;
|
|
707
|
+
if (job.notify && onExit) {
|
|
708
|
+
try {
|
|
709
|
+
onExit({ id: job.id, kind: job.kind, label: job.label, status, reason: job.reason, tail: job.buf.slice(-4e3), result });
|
|
710
|
+
} catch (e) {
|
|
711
|
+
log5.warn(`job onExit handler threw for ${job.id}: ${e}`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
prune() {
|
|
716
|
+
const finished = [...this.jobs.values()].filter((j) => j.status !== "running");
|
|
717
|
+
for (const j of finished.slice(0, Math.max(0, finished.length - this.options.keepFinished))) this.jobs.delete(j.id);
|
|
718
|
+
}
|
|
719
|
+
/** Current tail output (null = no such job). */
|
|
720
|
+
output(id) {
|
|
721
|
+
return this.jobs.get(id)?.buf ?? null;
|
|
722
|
+
}
|
|
723
|
+
/** Incremental read: output from absolute char `offset` (clamped to what the ring still holds). */
|
|
724
|
+
read(id, offset = 0) {
|
|
725
|
+
const j = this.jobs.get(id);
|
|
726
|
+
if (!j) return null;
|
|
727
|
+
const start = Math.max(0, offset - j.dropped);
|
|
728
|
+
return { text: j.buf.slice(start), next: j.dropped + j.buf.length, truncated: offset < j.dropped };
|
|
729
|
+
}
|
|
730
|
+
get(id) {
|
|
731
|
+
const j = this.jobs.get(id);
|
|
732
|
+
return j ? this.info(j) : null;
|
|
733
|
+
}
|
|
734
|
+
status(id) {
|
|
735
|
+
const j = this.jobs.get(id);
|
|
736
|
+
return j ? { status: j.status, bytes: j.bytes, ...j.reason ? { reason: j.reason } : {} } : null;
|
|
737
|
+
}
|
|
738
|
+
list() {
|
|
739
|
+
return [...this.jobs.values()].map((j) => this.info(j));
|
|
740
|
+
}
|
|
741
|
+
info(j) {
|
|
742
|
+
const { id, kind, label, status, startedAt, lastActivityAt, endedAt, reason, bytes, holds, result } = j;
|
|
743
|
+
return { id, kind, label, status, startedAt, lastActivityAt, endedAt, reason, bytes, holds, result };
|
|
744
|
+
}
|
|
745
|
+
/** Wait (bounded) for a job to settle. Resolves the job's info either way; null for an unknown id. */
|
|
746
|
+
async wait(id, timeoutMs) {
|
|
747
|
+
const j = this.jobs.get(id);
|
|
748
|
+
if (!j) return null;
|
|
749
|
+
let t;
|
|
750
|
+
await Promise.race([j.done, new Promise((r) => {
|
|
751
|
+
t = setTimeout(r, Math.max(0, timeoutMs));
|
|
752
|
+
})]);
|
|
753
|
+
if (t) clearTimeout(t);
|
|
754
|
+
return this.info(j);
|
|
755
|
+
}
|
|
756
|
+
/** Stop a job: runs its `onKill` (abort/SIGTERM) and marks it killed with `reason`. */
|
|
757
|
+
kill(id, reason = "killed by request") {
|
|
758
|
+
const j = this.jobs.get(id);
|
|
759
|
+
if (!j) return false;
|
|
760
|
+
if (j.status === "running") {
|
|
761
|
+
this.end(j, "killed", void 0, reason);
|
|
762
|
+
try {
|
|
763
|
+
j.onKill?.(reason);
|
|
764
|
+
} catch (e) {
|
|
765
|
+
log5.warn(`onKill for ${id} threw: ${e}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return true;
|
|
769
|
+
}
|
|
770
|
+
/** Kill every running job — or, with `onlyDrainable`, only turn-scoped ones (sandbox/sub-agent/handed-off MCP),
|
|
771
|
+
* leaving jobs meant to outlive a run (a background dev server) alone. */
|
|
772
|
+
killAll(reason = "run ended", onlyDrainable = false) {
|
|
773
|
+
for (const j of this.jobs.values()) if (!onlyDrainable || j.drain) this.kill(j.id, reason);
|
|
774
|
+
}
|
|
775
|
+
/** Jobs something is synchronously waiting on (see `JobTrackOptions.holds`) that are still running. */
|
|
776
|
+
holding() {
|
|
777
|
+
return [...this.jobs.values()].filter((j) => j.status === "running" && j.holds).map((j) => this.info(j));
|
|
778
|
+
}
|
|
779
|
+
/** Await every drainable running job (turn end). Returns the ids that were still running when drain began. */
|
|
780
|
+
async drain(signal) {
|
|
781
|
+
const pending = [...this.jobs.values()].filter((j) => j.status === "running" && j.drain);
|
|
782
|
+
const all = Promise.all(pending.map((j) => j.done));
|
|
783
|
+
await (signal ? Promise.race([all, new Promise((r) => signal.aborted ? r() : signal.addEventListener("abort", () => r(), { once: true }))]) : all);
|
|
784
|
+
return pending.map((j) => j.id);
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* The stuck-job decision, synchronous part: kill every running job past its hard cap, or past its idle
|
|
788
|
+
* window when it has no CPU probe to argue otherwise. Cheap — the stall watchdog calls it on every tick.
|
|
789
|
+
* Returns the killed ids. Jobs WITH a probe are left to `sweep()`.
|
|
790
|
+
*/
|
|
791
|
+
reap() {
|
|
792
|
+
const now = this.options.now();
|
|
793
|
+
const killed = [];
|
|
794
|
+
for (const j of this.jobs.values()) {
|
|
795
|
+
if (j.status !== "running") continue;
|
|
796
|
+
const verdict = this.verdict(j, now);
|
|
797
|
+
if (!verdict || verdict.idle && j.cpuTime) continue;
|
|
798
|
+
this.stuck(j, verdict.reason, now);
|
|
799
|
+
killed.push(j.id);
|
|
800
|
+
}
|
|
801
|
+
return killed;
|
|
802
|
+
}
|
|
803
|
+
/** Full sweep: sample CPU probes (advancing CPU = activity), then `reap()` including probed jobs. */
|
|
804
|
+
async sweep() {
|
|
805
|
+
const now = this.options.now();
|
|
806
|
+
for (const j of [...this.jobs.values()]) {
|
|
807
|
+
if (j.status !== "running" || !j.cpuTime || !j.idleMs) continue;
|
|
808
|
+
let cpu;
|
|
809
|
+
try {
|
|
810
|
+
cpu = await j.cpuTime();
|
|
811
|
+
} catch (e) {
|
|
812
|
+
log5.debug(`cpu probe for ${j.id} failed: ${e}`);
|
|
813
|
+
}
|
|
814
|
+
if (cpu != null && j.lastCpu != null && cpu > j.lastCpu) j.lastActivityAt = this.options.now();
|
|
815
|
+
const prev = j.lastCpu;
|
|
816
|
+
if (cpu != null) j.lastCpu = cpu;
|
|
817
|
+
const verdict = this.verdict(j, now);
|
|
818
|
+
if (!verdict?.idle || j.status !== "running") continue;
|
|
819
|
+
if (cpu == null) {
|
|
820
|
+
log5.debug(`not reaping ${j.id}: ${verdict.reason} but CPU unknown`);
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
if (prev == null) continue;
|
|
824
|
+
this.stuck(j, `${verdict.reason}, no CPU progress (${prev}s \u2192 ${cpu}s)`, now);
|
|
825
|
+
}
|
|
826
|
+
return this.reap();
|
|
827
|
+
}
|
|
828
|
+
verdict(j, now) {
|
|
829
|
+
if (j.hardCapMs && now - j.startedAt >= j.hardCapMs) return { reason: `stuck: exceeded hard cap ${secs(j.hardCapMs)}`, idle: false };
|
|
830
|
+
if (j.idleMs && (!j.idleAfterActivity || j.active) && now - j.lastActivityAt >= j.idleMs) return { reason: `stuck: idle ${secs(now - j.lastActivityAt)}`, idle: true };
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
stuck(j, reason, now) {
|
|
834
|
+
log5.warn(`reaping ${j.id} (${j.kind} "${j.label}"): ${reason} \u2014 age ${secs(now - j.startedAt)}, last activity ${secs(now - j.lastActivityAt)} ago, idleMs=${j.idleMs}, hardCapMs=${j.hardCapMs}, ${j.bytes} byte(s) output`);
|
|
835
|
+
if (j.onKill) j.notify = true;
|
|
836
|
+
this.kill(j.id, reason);
|
|
837
|
+
}
|
|
838
|
+
/** Keep a reaper interval alive only while a reapable job is running (unref'd: never holds the process open). */
|
|
839
|
+
arm() {
|
|
840
|
+
if (this.timer || !this.options.sweepMs) return;
|
|
841
|
+
this.timer = setInterval(() => {
|
|
842
|
+
const live = [...this.jobs.values()].some((j) => j.status === "running" && (j.idleMs || j.hardCapMs));
|
|
843
|
+
if (!live) {
|
|
844
|
+
clearInterval(this.timer);
|
|
845
|
+
this.timer = void 0;
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
void this.sweep().catch((e) => log5.warn(`job sweep failed: ${e}`));
|
|
849
|
+
}, this.options.sweepMs);
|
|
850
|
+
this.timer.unref?.();
|
|
851
|
+
}
|
|
852
|
+
/** Stop the reaper timer (tests / teardown). */
|
|
853
|
+
dispose() {
|
|
854
|
+
if (this.timer) clearInterval(this.timer);
|
|
855
|
+
this.timer = void 0;
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
|
|
536
859
|
// src/tools.shell.ts
|
|
537
|
-
var
|
|
860
|
+
var log6 = forComponent("shell");
|
|
538
861
|
var clean = (s) => truncateOutput(redactSecrets(s.replace(/\n+$/, "")));
|
|
539
862
|
var DETACHED = { stdio: ["ignore", "pipe", "pipe"], detached: true };
|
|
540
863
|
function killGroup(proc, signal) {
|
|
@@ -567,62 +890,122 @@ async function nodeSpawn() {
|
|
|
567
890
|
return _spawn;
|
|
568
891
|
}
|
|
569
892
|
function formatJobExit(n) {
|
|
570
|
-
return `[background job ${n.id} ${n.status}${n.exitCode != null ? ` exit ${n.exitCode}` : ""}] \`${n.command}\`
|
|
893
|
+
return `[background job ${n.id} ${n.status}${n.exitCode != null ? ` exit ${n.exitCode}` : ""}${n.reason ? `: ${n.reason}` : ""}] \`${n.command}\`
|
|
571
894
|
` + (n.tail ? `${n.tail}
|
|
572
895
|
` : "(no output)\n") + `Read the full output with ShellOutput({id:"${n.id}"}).`;
|
|
573
896
|
}
|
|
897
|
+
function parsePsTime(t) {
|
|
898
|
+
const m = t.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/);
|
|
899
|
+
if (!m) return void 0;
|
|
900
|
+
return Number(m[1] ?? 0) * 86400 + Number(m[2] ?? 0) * 3600 + Number(m[3]) * 60 + Number(m[4]);
|
|
901
|
+
}
|
|
902
|
+
async function psGroupCpuSeconds(pgid) {
|
|
903
|
+
try {
|
|
904
|
+
const { execFile } = await import("child_process");
|
|
905
|
+
const out = await new Promise((res, rej) => execFile("ps", ["-A", "-o", "pid=,ppid=,pgid=,time="], { timeout: 5e3, env: { ...process.env, LC_ALL: "C" } }, (e, so) => e ? rej(e) : res(String(so))));
|
|
906
|
+
return sumTreeCpu(out, pgid);
|
|
907
|
+
} catch (e) {
|
|
908
|
+
log6.debug(`ps cpu probe failed for group ${pgid}: ${e}`);
|
|
909
|
+
return void 0;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
function sumTreeCpu(psOut, root) {
|
|
913
|
+
const rows = psOut.split("\n").map((l) => l.trim().split(/\s+/)).filter((r) => r.length >= 4).map(([pid, ppid, pgid, time]) => ({ pid: Number(pid), ppid: Number(ppid), pgid: Number(pgid), sec: parsePsTime(time) }));
|
|
914
|
+
const inJob = new Set(rows.filter((r) => r.pgid === root || r.pid === root).map((r) => r.pid));
|
|
915
|
+
for (let grew = true; grew; ) {
|
|
916
|
+
grew = false;
|
|
917
|
+
for (const r of rows) if (!inJob.has(r.pid) && inJob.has(r.ppid)) {
|
|
918
|
+
inJob.add(r.pid);
|
|
919
|
+
grew = true;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
let total;
|
|
923
|
+
for (const r of rows) if (inJob.has(r.pid) && r.sec != null) total = (total ?? 0) + r.sec;
|
|
924
|
+
return total;
|
|
925
|
+
}
|
|
574
926
|
var ShellJobRegistry = class {
|
|
575
927
|
constructor(cfg) {
|
|
576
928
|
this.cfg = cfg;
|
|
929
|
+
this.jobs = cfg.jobs ?? new JobRegistry(cfg.maxBuffer ? { maxBuffer: cfg.maxBuffer } : {});
|
|
577
930
|
if (cfg.killOnExit && typeof process !== "undefined") process.once("exit", () => this.killAll());
|
|
578
931
|
}
|
|
579
932
|
cfg;
|
|
580
|
-
jobs
|
|
581
|
-
|
|
933
|
+
jobs;
|
|
934
|
+
meta = /* @__PURE__ */ new Map();
|
|
582
935
|
async start(command) {
|
|
583
|
-
const
|
|
584
|
-
const max = this.cfg.maxBuffer ?? 256 * 1024;
|
|
585
|
-
const job = { command, buf: "", status: "running" };
|
|
586
|
-
const append = (chunk) => {
|
|
587
|
-
const s = typeof chunk === "string" ? chunk : chunk?.toString?.("utf8") ?? "";
|
|
588
|
-
job.buf = (job.buf + s).slice(-max);
|
|
589
|
-
};
|
|
936
|
+
const h = this.track(command, { idleMs: 0 });
|
|
590
937
|
try {
|
|
591
938
|
const spawn = this.cfg.spawn ?? await nodeSpawn();
|
|
592
939
|
const argv = this.cfg.osSandbox ? await spawnArgvFor(command, this.cfg.cwd, this.cfg.osSandbox) : { bin: "/bin/sh", args: ["-c", command] };
|
|
593
|
-
|
|
594
|
-
job.proc = proc;
|
|
595
|
-
proc.stdout?.on("data", append);
|
|
596
|
-
proc.stderr?.on("data", append);
|
|
597
|
-
proc.on("error", (err) => {
|
|
598
|
-
if (job.status === "running") {
|
|
599
|
-
job.status = "error";
|
|
600
|
-
append(`
|
|
601
|
-
[error] ${err?.message ?? err}`);
|
|
602
|
-
this.notifyExit(id, job);
|
|
603
|
-
}
|
|
604
|
-
});
|
|
605
|
-
proc.on("close", (code) => {
|
|
606
|
-
if (job.status === "running") {
|
|
607
|
-
job.status = "exited";
|
|
608
|
-
job.exitCode = code ?? void 0;
|
|
609
|
-
this.notifyExit(id, job);
|
|
610
|
-
}
|
|
611
|
-
});
|
|
940
|
+
this.attach(h, spawn(argv.bin, argv.args, { cwd: this.cfg.cwd, env: childEnv(this.cfg), ...DETACHED }));
|
|
612
941
|
} catch (e) {
|
|
613
|
-
|
|
614
|
-
|
|
942
|
+
h.chunk(`failed to spawn: ${e?.message ?? e}`);
|
|
943
|
+
h.finish("error", void 0, e?.message ?? String(e));
|
|
944
|
+
}
|
|
945
|
+
return h.id;
|
|
946
|
+
}
|
|
947
|
+
track(command, extra) {
|
|
948
|
+
const meta = { command };
|
|
949
|
+
let id = "";
|
|
950
|
+
const h = this.jobs.track({
|
|
951
|
+
kind: "shell",
|
|
952
|
+
label: command,
|
|
953
|
+
seed: extra.seed,
|
|
954
|
+
idleMs: extra.idleMs,
|
|
955
|
+
cpuTime: extra.cpu ? async () => meta.proc?.pid ? (this.cfg.cpuTime ?? psGroupCpuSeconds)(meta.proc.pid) : void 0 : void 0,
|
|
956
|
+
onKill: () => this.signal(meta),
|
|
957
|
+
onExit: (n) => this.notifyExit(id, meta, n.status, n.reason),
|
|
958
|
+
notify: true
|
|
959
|
+
});
|
|
960
|
+
id = h.id;
|
|
961
|
+
this.meta.set(id, meta);
|
|
962
|
+
return h;
|
|
963
|
+
}
|
|
964
|
+
attach(h, proc) {
|
|
965
|
+
const meta = this.meta.get(h.id);
|
|
966
|
+
meta.proc = proc;
|
|
967
|
+
const append = (chunk) => h.chunk(typeof chunk === "string" ? chunk : chunk?.toString?.("utf8") ?? "");
|
|
968
|
+
proc.stdout?.on("data", append);
|
|
969
|
+
proc.stderr?.on("data", append);
|
|
970
|
+
proc.on("error", (err) => {
|
|
971
|
+
if (h.status === "running") {
|
|
972
|
+
append(`
|
|
973
|
+
[error] ${err?.message ?? err}`);
|
|
974
|
+
h.finish("error", void 0, err?.message ?? String(err));
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
proc.on("close", (code) => {
|
|
978
|
+
if (h.status === "running") {
|
|
979
|
+
meta.exitCode = code ?? void 0;
|
|
980
|
+
h.finish("done");
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
/** SIGTERM the whole group (bg children are detached group leaders — /bin/sh alone would orphan a forked
|
|
985
|
+
* server), then SIGKILL after the grace period if it is still there. Falls back to the pid for fakes. */
|
|
986
|
+
signal(meta) {
|
|
987
|
+
const proc = meta.proc;
|
|
988
|
+
if (!killGroup(proc, "SIGTERM")) {
|
|
989
|
+
try {
|
|
990
|
+
proc?.kill("SIGTERM");
|
|
991
|
+
} catch {
|
|
992
|
+
}
|
|
615
993
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
this.
|
|
994
|
+
if (!proc?.pid) return;
|
|
995
|
+
let closed = false;
|
|
996
|
+
proc.on("close", () => {
|
|
997
|
+
closed = true;
|
|
998
|
+
});
|
|
999
|
+
const t = setTimeout(() => {
|
|
1000
|
+
if (!closed) killGroup(proc, "SIGKILL");
|
|
1001
|
+
}, this.jobs.options.killGraceMs);
|
|
1002
|
+
t.unref?.();
|
|
1003
|
+
}
|
|
1004
|
+
/** Fire `onExit` at most once per job (the registry guarantees one terminal state), with the tail so the model can act without a second round-trip. */
|
|
1005
|
+
notifyExit(id, meta, status, reason) {
|
|
1006
|
+
if (!this.cfg.onExit || status === "killed" && !reason?.startsWith("stuck")) return;
|
|
624
1007
|
try {
|
|
625
|
-
this.cfg.onExit({ id, command:
|
|
1008
|
+
this.cfg.onExit({ id, command: meta.command, status: shellStatus(status), exitCode: meta.exitCode, tail: clean(this.jobs.output(id) ?? "").slice(-4e3), ...reason && status !== "done" ? { reason } : {} });
|
|
626
1009
|
} catch {
|
|
627
1010
|
}
|
|
628
1011
|
}
|
|
@@ -641,14 +1024,20 @@ var ShellJobRegistry = class {
|
|
|
641
1024
|
}
|
|
642
1025
|
/** Current tail output for a job (null = no such job). */
|
|
643
1026
|
output(id) {
|
|
644
|
-
return this.
|
|
1027
|
+
return this.meta.has(id) ? this.jobs.output(id) : null;
|
|
645
1028
|
}
|
|
1029
|
+
// null too once the shared registry pruned it
|
|
646
1030
|
status(id) {
|
|
647
|
-
const
|
|
648
|
-
|
|
1031
|
+
const m = this.meta.get(id);
|
|
1032
|
+
const j = m && this.jobs.get(id);
|
|
1033
|
+
if (!m || !j) {
|
|
1034
|
+
this.meta.delete(id);
|
|
1035
|
+
return null;
|
|
1036
|
+
}
|
|
1037
|
+
return { status: shellStatus(j.status), exitCode: m.exitCode, bytes: j.bytes, ...j.reason ? { reason: j.reason } : {} };
|
|
649
1038
|
}
|
|
650
1039
|
list() {
|
|
651
|
-
return [...this.
|
|
1040
|
+
return [...this.meta].filter(([id]) => this.jobs.get(id)).map(([id, m]) => ({ id, command: m.command, status: shellStatus(this.jobs.get(id).status) }));
|
|
652
1041
|
}
|
|
653
1042
|
/**
|
|
654
1043
|
* Take over an ALREADY-RUNNING child as a background job. A foreground command that outruns its
|
|
@@ -656,53 +1045,22 @@ var ShellJobRegistry = class {
|
|
|
656
1045
|
* worse, a launcher that spawned its own detached worker leaves that worker running with nothing
|
|
657
1046
|
* tracking it. Adopting hands the model a handle instead: the command keeps going, its completion
|
|
658
1047
|
* is reported like any other job, and `seed` carries the output produced before the handoff.
|
|
1048
|
+
* Unlike an explicit background job it IS liveness-checked (idle window + CPU probe): nobody asked for
|
|
1049
|
+
* a long-lived process here, so one that goes silent and CPU-idle is stuck, not serving.
|
|
659
1050
|
*/
|
|
660
1051
|
adopt(command, proc, seed = "") {
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
const append = (chunk) => {
|
|
665
|
-
const s = typeof chunk === "string" ? chunk : chunk?.toString?.("utf8") ?? "";
|
|
666
|
-
job.buf = (job.buf + s).slice(-max);
|
|
667
|
-
};
|
|
668
|
-
proc.stdout?.on("data", append);
|
|
669
|
-
proc.stderr?.on("data", append);
|
|
670
|
-
proc.on("error", (err) => {
|
|
671
|
-
if (job.status === "running") {
|
|
672
|
-
job.status = "error";
|
|
673
|
-
append(`
|
|
674
|
-
[error] ${err?.message ?? err}`);
|
|
675
|
-
this.notifyExit(id, job);
|
|
676
|
-
}
|
|
677
|
-
});
|
|
678
|
-
proc.on("close", (code) => {
|
|
679
|
-
if (job.status === "running") {
|
|
680
|
-
job.status = "exited";
|
|
681
|
-
job.exitCode = code ?? void 0;
|
|
682
|
-
this.notifyExit(id, job);
|
|
683
|
-
}
|
|
684
|
-
});
|
|
685
|
-
this.jobs.set(id, job);
|
|
686
|
-
return id;
|
|
1052
|
+
const h = this.track(command, { seed, cpu: true });
|
|
1053
|
+
this.attach(h, proc);
|
|
1054
|
+
return h.id;
|
|
687
1055
|
}
|
|
688
|
-
kill(id) {
|
|
689
|
-
|
|
690
|
-
if (!j) return false;
|
|
691
|
-
if (j.status === "running") {
|
|
692
|
-
if (!killGroup(j.proc, "SIGTERM")) {
|
|
693
|
-
try {
|
|
694
|
-
j.proc?.kill("SIGTERM");
|
|
695
|
-
} catch {
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
j.status = "killed";
|
|
699
|
-
}
|
|
700
|
-
return true;
|
|
1056
|
+
kill(id, reason = "killed by ShellKill") {
|
|
1057
|
+
return this.meta.has(id) && this.jobs.kill(id, reason);
|
|
701
1058
|
}
|
|
702
1059
|
killAll() {
|
|
703
|
-
for (const id of this.
|
|
1060
|
+
for (const id of this.meta.keys()) this.jobs.kill(id, "agent exiting");
|
|
704
1061
|
}
|
|
705
1062
|
};
|
|
1063
|
+
var shellStatus = (s) => s === "done" ? "exited" : s === "running" ? "running" : s === "killed" ? "killed" : "error";
|
|
706
1064
|
function makeRealShellTool(options) {
|
|
707
1065
|
const defaultTimeoutMs = options.timeoutMs ?? 12e4;
|
|
708
1066
|
const maxTimeoutMs = Math.max(options.maxTimeoutMs ?? 6e5, defaultTimeoutMs);
|
|
@@ -833,7 +1191,7 @@ function makeRealShellTool(options) {
|
|
|
833
1191
|
proc.stderr?.on("data", collect);
|
|
834
1192
|
proc.on("error", (err) => {
|
|
835
1193
|
if (err?.name === "AbortError" || ctl.signal.aborted) return finish(reasonFor(timedOut, timeoutMs, clean(out)));
|
|
836
|
-
|
|
1194
|
+
log6.debug("shell spawn error", err);
|
|
837
1195
|
finish(`[exit 1] ${err?.message ?? err}${out ? "\n" + clean(out) : ""}`);
|
|
838
1196
|
});
|
|
839
1197
|
proc.on("close", (code) => {
|
|
@@ -853,7 +1211,7 @@ function makeRealShellTool(options) {
|
|
|
853
1211
|
};
|
|
854
1212
|
}
|
|
855
1213
|
function handoffFor(timeoutMs, id, body, notifies) {
|
|
856
|
-
const head = `[still running] exceeded ${timeoutMs}ms, so it was handed to background job ${id} \u2014 NOT killed, it is still going. Check on it with ShellOutput({id:"${id}"}) / ShellStatus({id:"${id}"}), stop it with ShellKill({id:"${id}"}). ` + (notifies ? "Its completion will be reported to you, so you may continue with other work meanwhile." : "Nothing will tell you when it finishes \u2014 poll it yourself before you rely on its result.");
|
|
1214
|
+
const head = `[still running] exceeded ${timeoutMs}ms, so it was handed to background job ${id} \u2014 NOT killed, it is still going (it is only stopped if it goes silent with no CPU activity for a long time, and then you are told why). Check on it with ShellOutput({id:"${id}"}) / ShellStatus({id:"${id}"}), stop it with ShellKill({id:"${id}"}). ` + (notifies ? "Its completion will be reported to you, so you may continue with other work meanwhile." : "Nothing will tell you when it finishes \u2014 poll it yourself before you rely on its result.");
|
|
857
1215
|
return body ? `${head}
|
|
858
1216
|
Output so far:
|
|
859
1217
|
${body}` : head;
|
|
@@ -878,7 +1236,7 @@ function makeShellJobTools(registry) {
|
|
|
878
1236
|
const out = registry.output(String(id));
|
|
879
1237
|
if (out == null) return NO_JOB(String(id));
|
|
880
1238
|
const st = registry.status(String(id));
|
|
881
|
-
return `[${st.status}${st.exitCode != null ? ` exit ${st.exitCode}` : ""}]
|
|
1239
|
+
return `[${st.status}${st.exitCode != null ? ` exit ${st.exitCode}` : ""}${st.reason ? `: ${st.reason}` : ""}]
|
|
882
1240
|
${clean(out) || "(no output yet)"}`;
|
|
883
1241
|
}
|
|
884
1242
|
},
|
|
@@ -893,13 +1251,13 @@ ${clean(out) || "(no output yet)"}`;
|
|
|
893
1251
|
return jobs.length ? jobs.map((j) => `${j.id} ${j.status} ${j.command}`).join("\n") : "(no background jobs)";
|
|
894
1252
|
}
|
|
895
1253
|
const st = registry.status(String(id));
|
|
896
|
-
return st ? `${st.status}${st.exitCode != null ? ` (exit ${st.exitCode})` : ""} \xB7 ${st.bytes} byte(s) buffered` : NO_JOB(String(id));
|
|
1254
|
+
return st ? `${st.status}${st.exitCode != null ? ` (exit ${st.exitCode})` : ""}${st.reason ? ` \u2014 ${st.reason}` : ""} \xB7 ${st.bytes} byte(s) buffered` : NO_JOB(String(id));
|
|
897
1255
|
}
|
|
898
1256
|
},
|
|
899
1257
|
{
|
|
900
1258
|
...dropOnRebind,
|
|
901
1259
|
name: "ShellKill",
|
|
902
|
-
description: "Stop a running background Shell job by id (SIGTERM).",
|
|
1260
|
+
description: "Stop a running background Shell job by id (SIGTERM to its process group, SIGKILL if it ignores that).",
|
|
903
1261
|
parameters: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
|
|
904
1262
|
async run({ id }) {
|
|
905
1263
|
return registry.kill(String(id)) ? `Killed job ${id}.` : NO_JOB(String(id));
|
|
@@ -911,6 +1269,9 @@ export {
|
|
|
911
1269
|
ShellJobRegistry,
|
|
912
1270
|
formatJobExit,
|
|
913
1271
|
makeRealShellTool,
|
|
914
|
-
makeShellJobTools
|
|
1272
|
+
makeShellJobTools,
|
|
1273
|
+
parsePsTime,
|
|
1274
|
+
psGroupCpuSeconds,
|
|
1275
|
+
sumTreeCpu
|
|
915
1276
|
};
|
|
916
1277
|
//# sourceMappingURL=tools.shell.js.map
|