@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.
@@ -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 log5 = forComponent("shell");
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 = /* @__PURE__ */ new Map();
581
- seq = 0;
933
+ jobs;
934
+ meta = /* @__PURE__ */ new Map();
582
935
  async start(command) {
583
- const id = `job-${++this.seq}`;
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
- const proc = spawn(argv.bin, argv.args, { cwd: this.cfg.cwd, env: childEnv(this.cfg), ...DETACHED });
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
- job.status = "error";
614
- job.buf = `failed to spawn: ${e?.message ?? e}`;
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
- this.jobs.set(id, job);
617
- return id;
618
- }
619
- /** Fire `onExit` at most once per job, with the tail so the model can act without a second round-trip. */
620
- notified = /* @__PURE__ */ new Set();
621
- notifyExit(id, job) {
622
- if (this.notified.has(id) || !this.cfg.onExit) return;
623
- this.notified.add(id);
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: job.command, status: job.status, exitCode: job.exitCode, tail: clean(job.buf).slice(-4e3) });
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.jobs.get(id)?.buf ?? (this.jobs.has(id) ? "" : null);
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 j = this.jobs.get(id);
648
- return j ? { status: j.status, exitCode: j.exitCode, bytes: j.buf.length } : null;
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.jobs].map(([id, j]) => ({ id, command: j.command, status: j.status }));
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 id = `job-${++this.seq}`;
662
- const max = this.cfg.maxBuffer ?? 256 * 1024;
663
- const job = { command, buf: seed.slice(-max), status: "running", proc };
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
- const j = this.jobs.get(id);
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.jobs.keys()) this.kill(id);
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
- log5.debug("shell spawn error", err);
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