@livx.cc/agentx 0.99.47 → 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/dist/index.js CHANGED
@@ -1511,6 +1511,399 @@ var init_tools = __esm({
1511
1511
  }
1512
1512
  });
1513
1513
 
1514
+ // src/tools.jobs.ts
1515
+ function formatJobNotice(n) {
1516
+ return `[job ${n.id} ${n.status}${n.reason ? `: ${n.reason}` : ""}] ${n.kind}${n.label ? ` \`${n.label}\`` : ""}
1517
+ ` + (n.tail ? `${n.tail.replace(/\n+$/, "")}
1518
+ ` : "(no output)\n") + `Read the full output with JobOutput({id:"${n.id}"}).`;
1519
+ }
1520
+ function makeJobTools(registry, opts = {}) {
1521
+ const maxWaitMs = opts.maxWaitMs ?? 12e4;
1522
+ return [
1523
+ {
1524
+ name: "JobOutput",
1525
+ description: "Read a job's output by id. Without `offset`: the buffered tail. With `offset` (the `next` value from a previous read): only what arrived since.",
1526
+ parameters: { type: "object", required: ["id"], properties: { id: { type: "string" }, offset: { type: "number", description: "return output after this position (from a previous read's `next`)" } } },
1527
+ async run({ id, offset }) {
1528
+ const r = registry.read(String(id), Number(offset) || 0);
1529
+ if (!r) return NO_JOB(String(id));
1530
+ const st = registry.status(String(id));
1531
+ return `[${st.status}${st.reason ? `: ${st.reason}` : ""}] next=${r.next}${r.truncated ? " (older output dropped)" : ""}
1532
+ ${r.text.replace(/\n+$/, "") || "(no new output)"}`;
1533
+ }
1534
+ },
1535
+ {
1536
+ name: "JobStatus",
1537
+ description: "Status of a job (running/done/error/killed, age, last activity). Omit `id` to list all jobs.",
1538
+ parameters: { type: "object", properties: { id: { type: "string" } } },
1539
+ async run({ id }) {
1540
+ const now3 = registry.options.now();
1541
+ if (!id) {
1542
+ const all = registry.list();
1543
+ return all.length ? all.map((j2) => describe(j2, now3)).join("\n") : "(no background jobs)";
1544
+ }
1545
+ const j = registry.get(String(id));
1546
+ return j ? describe(j, now3) : NO_JOB(String(id));
1547
+ }
1548
+ },
1549
+ {
1550
+ name: "JobKill",
1551
+ description: "Stop a running job by id (sandbox jobs are cancelled without committing; processes get SIGTERM; MCP calls are cancelled).",
1552
+ parameters: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
1553
+ async run({ id }) {
1554
+ return registry.kill(String(id), "killed by JobKill") ? `Killed job ${id}.` : NO_JOB(String(id));
1555
+ }
1556
+ },
1557
+ {
1558
+ name: "JobWait",
1559
+ description: `Block until a job finishes or \`timeoutMs\` passes (max ${maxWaitMs}ms), then return its status and output tail. Prefer this over sleeping.`,
1560
+ parameters: { type: "object", required: ["id"], properties: { id: { type: "string" }, timeoutMs: { type: "number" } } },
1561
+ async run({ id, timeoutMs }) {
1562
+ const j = await registry.wait(String(id), Math.min(Number(timeoutMs) || maxWaitMs, maxWaitMs));
1563
+ if (!j) return NO_JOB(String(id));
1564
+ const tail = (registry.output(j.id) ?? "").slice(-4e3).replace(/\n+$/, "");
1565
+ return `${describe(j, registry.options.now())}
1566
+ ${tail || "(no output)"}`;
1567
+ }
1568
+ }
1569
+ ];
1570
+ }
1571
+ var log5, JobRegistryOptions, secs, JobRegistry, SandboxJobRegistry, NO_JOB, describe;
1572
+ var init_tools_jobs = __esm({
1573
+ "src/tools.jobs.ts"() {
1574
+ "use strict";
1575
+ init_logging();
1576
+ log5 = forComponent("jobs");
1577
+ JobRegistryOptions = class {
1578
+ /** Tail buffer cap per job (chars). */
1579
+ maxBuffer = 256 * 1024;
1580
+ /** Idle window per kind: no output/progress/CPU for this long ⇒ stuck. Kinds without an activity channel
1581
+ * (host, native) are absent on purpose — silence is all they can ever show, so it proves nothing; their
1582
+ * bound is the hard cap. Explicit `background:true` shell jobs opt out per job (a quiet server is healthy). */
1583
+ idleMs = { shell: 10 * 6e4, mcp: 10 * 6e4 };
1584
+ /** Absolute ceiling per kind (0/absent = none). */
1585
+ hardCapMs = { mcp: 30 * 6e4 };
1586
+ /** Soft foreground timeout per kind: past it the caller stops waiting and gets a job id instead. */
1587
+ foregroundMs = { mcp: 3e4 };
1588
+ /** How often the reaper runs while reapable jobs exist. */
1589
+ sweepMs = 15e3;
1590
+ /** Grace between the kill signal and a forced kill (producers that escalate read this). */
1591
+ killGraceMs = 5e3;
1592
+ /** Finished jobs kept for inspection; older finished ones are pruned (every delegated tool call is a job). */
1593
+ keepFinished = 200;
1594
+ /** Clock (injectable for tests). Late-bound so a patched Date.now is honoured. */
1595
+ now = () => Date.now();
1596
+ /** Terminal-state notification for jobs with `notify` (the Agent wires this to `inject`). */
1597
+ onExit;
1598
+ };
1599
+ secs = (ms) => `${Math.round(ms / 1e3)}s`;
1600
+ JobRegistry = class {
1601
+ options;
1602
+ jobs = /* @__PURE__ */ new Map();
1603
+ seq = 0;
1604
+ timer;
1605
+ constructor(options) {
1606
+ this.options = { ...new JobRegistryOptions(), ...options };
1607
+ }
1608
+ /** Register externally-driven work (a process, a request, a native tool) and get a handle to feed it. */
1609
+ track(opts = {}) {
1610
+ const o = this.options;
1611
+ const kind = opts.kind ?? "task";
1612
+ const now3 = o.now();
1613
+ let settle;
1614
+ const done = new Promise((r) => {
1615
+ settle = r;
1616
+ });
1617
+ const job = {
1618
+ id: `job-${++this.seq}`,
1619
+ kind,
1620
+ label: opts.label ?? "",
1621
+ status: "running",
1622
+ startedAt: now3,
1623
+ lastActivityAt: now3,
1624
+ bytes: 0,
1625
+ holds: !!opts.holds,
1626
+ buf: "",
1627
+ dropped: 0,
1628
+ idleMs: opts.idleMs ?? o.idleMs[kind] ?? 0,
1629
+ hardCapMs: opts.hardCapMs ?? o.hardCapMs[kind] ?? 0,
1630
+ drain: !!opts.drain,
1631
+ notify: !!opts.notify,
1632
+ idleAfterActivity: !!opts.idleAfterActivity,
1633
+ active: false,
1634
+ cpuTime: opts.cpuTime,
1635
+ onKill: opts.onKill,
1636
+ onExit: opts.onExit,
1637
+ done,
1638
+ settle
1639
+ };
1640
+ this.jobs.set(job.id, job);
1641
+ if (opts.seed) this.append(job, opts.seed);
1642
+ this.arm();
1643
+ return {
1644
+ id: job.id,
1645
+ chunk: (s) => {
1646
+ if (s) {
1647
+ this.append(job, s);
1648
+ job.lastActivityAt = o.now();
1649
+ job.active = true;
1650
+ }
1651
+ },
1652
+ touch: () => {
1653
+ job.lastActivityAt = o.now();
1654
+ job.active = true;
1655
+ },
1656
+ finish: (status, result, reason) => this.end(job, status, result, reason),
1657
+ detach: () => {
1658
+ job.holds = false;
1659
+ job.notify = true;
1660
+ job.drain = true;
1661
+ },
1662
+ get status() {
1663
+ return job.status;
1664
+ }
1665
+ };
1666
+ }
1667
+ /** Start `fn` in the background and return a job id immediately (sandbox/subagent semantics: drained at turn end). */
1668
+ start(fn, opts = {}) {
1669
+ return this.run(fn, { drain: true, ...opts }).handle.id;
1670
+ }
1671
+ /**
1672
+ * Run `fn` in the FOREGROUND with a soft timeout: resolves `{done:true,value}` if it settles in time, else
1673
+ * `{done:false,id}` — the work keeps going as a detached job whose completion is reported via `onExit`.
1674
+ * A rejection before the timeout is rethrown (the caller's normal error path). `softTimeoutMs` ≤ 0 waits forever.
1675
+ */
1676
+ async runForeground(fn, opts = {}) {
1677
+ const { handle, promise } = this.run(fn, opts);
1678
+ const soft = opts.softTimeoutMs ?? this.options.foregroundMs[opts.kind ?? "task"] ?? 0;
1679
+ let t;
1680
+ const late = soft > 0 ? new Promise((r) => {
1681
+ t = setTimeout(() => r("late"), soft);
1682
+ }) : new Promise(() => {
1683
+ });
1684
+ try {
1685
+ const r = await Promise.race([promise.then((v) => ({ v }), (e) => ({ e })), late]);
1686
+ if (r !== "late" && handle.status !== "running") this.jobs.delete(handle.id);
1687
+ if (r === "late") {
1688
+ if (handle.status === "running") {
1689
+ handle.detach();
1690
+ return { done: false, id: handle.id };
1691
+ }
1692
+ const settled = await promise.then((v) => ({ v }), (e) => ({ e }));
1693
+ if ("e" in settled) throw settled.e;
1694
+ return { done: true, value: settled.v };
1695
+ }
1696
+ if ("e" in r) throw r.e;
1697
+ return { done: true, value: r.v };
1698
+ } finally {
1699
+ if (t) clearTimeout(t);
1700
+ }
1701
+ }
1702
+ run(fn, opts) {
1703
+ const ctl = new AbortController();
1704
+ const handle = this.track({ ...opts, onKill: (reason) => {
1705
+ ctl.abort(Object.assign(new Error(reason), { name: "AbortError", code: "job_killed" }));
1706
+ opts.onKill?.(reason);
1707
+ } });
1708
+ let promise;
1709
+ try {
1710
+ promise = fn({ signal: ctl.signal, onChunk: handle.chunk, touch: handle.touch });
1711
+ } catch (e) {
1712
+ promise = Promise.reject(e);
1713
+ }
1714
+ promise.then(
1715
+ (res) => {
1716
+ if (handle.status === "running" && typeof res === "string" && res) handle.chunk(res);
1717
+ handle.finish("done", res);
1718
+ },
1719
+ (e) => {
1720
+ if (handle.status === "running") handle.chunk(`
1721
+ [error] ${e?.message ?? e}`);
1722
+ handle.finish("error", void 0, e?.message ?? String(e));
1723
+ }
1724
+ );
1725
+ return { handle, promise };
1726
+ }
1727
+ append(job, s) {
1728
+ job.buf += s;
1729
+ const over = job.buf.length - this.options.maxBuffer;
1730
+ if (over > 0) {
1731
+ job.buf = job.buf.slice(over);
1732
+ job.dropped += over;
1733
+ }
1734
+ job.bytes = job.buf.length;
1735
+ }
1736
+ end(job, status, result, reason) {
1737
+ if (job.status !== "running") return;
1738
+ job.status = status;
1739
+ job.endedAt = this.options.now();
1740
+ job.result = result;
1741
+ if (reason && status !== "done") job.reason = reason;
1742
+ job.holds = false;
1743
+ job.settle();
1744
+ this.prune();
1745
+ const onExit = job.onExit ?? this.options.onExit;
1746
+ if (job.notify && onExit) {
1747
+ try {
1748
+ onExit({ id: job.id, kind: job.kind, label: job.label, status, reason: job.reason, tail: job.buf.slice(-4e3), result });
1749
+ } catch (e) {
1750
+ log5.warn(`job onExit handler threw for ${job.id}: ${e}`);
1751
+ }
1752
+ }
1753
+ }
1754
+ prune() {
1755
+ const finished = [...this.jobs.values()].filter((j) => j.status !== "running");
1756
+ for (const j of finished.slice(0, Math.max(0, finished.length - this.options.keepFinished))) this.jobs.delete(j.id);
1757
+ }
1758
+ /** Current tail output (null = no such job). */
1759
+ output(id) {
1760
+ return this.jobs.get(id)?.buf ?? null;
1761
+ }
1762
+ /** Incremental read: output from absolute char `offset` (clamped to what the ring still holds). */
1763
+ read(id, offset = 0) {
1764
+ const j = this.jobs.get(id);
1765
+ if (!j) return null;
1766
+ const start = Math.max(0, offset - j.dropped);
1767
+ return { text: j.buf.slice(start), next: j.dropped + j.buf.length, truncated: offset < j.dropped };
1768
+ }
1769
+ get(id) {
1770
+ const j = this.jobs.get(id);
1771
+ return j ? this.info(j) : null;
1772
+ }
1773
+ status(id) {
1774
+ const j = this.jobs.get(id);
1775
+ return j ? { status: j.status, bytes: j.bytes, ...j.reason ? { reason: j.reason } : {} } : null;
1776
+ }
1777
+ list() {
1778
+ return [...this.jobs.values()].map((j) => this.info(j));
1779
+ }
1780
+ info(j) {
1781
+ const { id, kind, label, status, startedAt, lastActivityAt, endedAt, reason, bytes, holds, result } = j;
1782
+ return { id, kind, label, status, startedAt, lastActivityAt, endedAt, reason, bytes, holds, result };
1783
+ }
1784
+ /** Wait (bounded) for a job to settle. Resolves the job's info either way; null for an unknown id. */
1785
+ async wait(id, timeoutMs) {
1786
+ const j = this.jobs.get(id);
1787
+ if (!j) return null;
1788
+ let t;
1789
+ await Promise.race([j.done, new Promise((r) => {
1790
+ t = setTimeout(r, Math.max(0, timeoutMs));
1791
+ })]);
1792
+ if (t) clearTimeout(t);
1793
+ return this.info(j);
1794
+ }
1795
+ /** Stop a job: runs its `onKill` (abort/SIGTERM) and marks it killed with `reason`. */
1796
+ kill(id, reason = "killed by request") {
1797
+ const j = this.jobs.get(id);
1798
+ if (!j) return false;
1799
+ if (j.status === "running") {
1800
+ this.end(j, "killed", void 0, reason);
1801
+ try {
1802
+ j.onKill?.(reason);
1803
+ } catch (e) {
1804
+ log5.warn(`onKill for ${id} threw: ${e}`);
1805
+ }
1806
+ }
1807
+ return true;
1808
+ }
1809
+ /** Kill every running job — or, with `onlyDrainable`, only turn-scoped ones (sandbox/sub-agent/handed-off MCP),
1810
+ * leaving jobs meant to outlive a run (a background dev server) alone. */
1811
+ killAll(reason = "run ended", onlyDrainable = false) {
1812
+ for (const j of this.jobs.values()) if (!onlyDrainable || j.drain) this.kill(j.id, reason);
1813
+ }
1814
+ /** Jobs something is synchronously waiting on (see `JobTrackOptions.holds`) that are still running. */
1815
+ holding() {
1816
+ return [...this.jobs.values()].filter((j) => j.status === "running" && j.holds).map((j) => this.info(j));
1817
+ }
1818
+ /** Await every drainable running job (turn end). Returns the ids that were still running when drain began. */
1819
+ async drain(signal) {
1820
+ const pending = [...this.jobs.values()].filter((j) => j.status === "running" && j.drain);
1821
+ const all = Promise.all(pending.map((j) => j.done));
1822
+ await (signal ? Promise.race([all, new Promise((r) => signal.aborted ? r() : signal.addEventListener("abort", () => r(), { once: true }))]) : all);
1823
+ return pending.map((j) => j.id);
1824
+ }
1825
+ /**
1826
+ * The stuck-job decision, synchronous part: kill every running job past its hard cap, or past its idle
1827
+ * window when it has no CPU probe to argue otherwise. Cheap — the stall watchdog calls it on every tick.
1828
+ * Returns the killed ids. Jobs WITH a probe are left to `sweep()`.
1829
+ */
1830
+ reap() {
1831
+ const now3 = this.options.now();
1832
+ const killed = [];
1833
+ for (const j of this.jobs.values()) {
1834
+ if (j.status !== "running") continue;
1835
+ const verdict = this.verdict(j, now3);
1836
+ if (!verdict || verdict.idle && j.cpuTime) continue;
1837
+ this.stuck(j, verdict.reason, now3);
1838
+ killed.push(j.id);
1839
+ }
1840
+ return killed;
1841
+ }
1842
+ /** Full sweep: sample CPU probes (advancing CPU = activity), then `reap()` including probed jobs. */
1843
+ async sweep() {
1844
+ const now3 = this.options.now();
1845
+ for (const j of [...this.jobs.values()]) {
1846
+ if (j.status !== "running" || !j.cpuTime || !j.idleMs) continue;
1847
+ let cpu;
1848
+ try {
1849
+ cpu = await j.cpuTime();
1850
+ } catch (e) {
1851
+ log5.debug(`cpu probe for ${j.id} failed: ${e}`);
1852
+ }
1853
+ if (cpu != null && j.lastCpu != null && cpu > j.lastCpu) j.lastActivityAt = this.options.now();
1854
+ const prev = j.lastCpu;
1855
+ if (cpu != null) j.lastCpu = cpu;
1856
+ const verdict = this.verdict(j, now3);
1857
+ if (!verdict?.idle || j.status !== "running") continue;
1858
+ if (cpu == null) {
1859
+ log5.debug(`not reaping ${j.id}: ${verdict.reason} but CPU unknown`);
1860
+ continue;
1861
+ }
1862
+ if (prev == null) continue;
1863
+ this.stuck(j, `${verdict.reason}, no CPU progress (${prev}s \u2192 ${cpu}s)`, now3);
1864
+ }
1865
+ return this.reap();
1866
+ }
1867
+ verdict(j, now3) {
1868
+ if (j.hardCapMs && now3 - j.startedAt >= j.hardCapMs) return { reason: `stuck: exceeded hard cap ${secs(j.hardCapMs)}`, idle: false };
1869
+ if (j.idleMs && (!j.idleAfterActivity || j.active) && now3 - j.lastActivityAt >= j.idleMs) return { reason: `stuck: idle ${secs(now3 - j.lastActivityAt)}`, idle: true };
1870
+ return null;
1871
+ }
1872
+ stuck(j, reason, now3) {
1873
+ log5.warn(`reaping ${j.id} (${j.kind} "${j.label}"): ${reason} \u2014 age ${secs(now3 - j.startedAt)}, last activity ${secs(now3 - j.lastActivityAt)} ago, idleMs=${j.idleMs}, hardCapMs=${j.hardCapMs}, ${j.bytes} byte(s) output`);
1874
+ if (j.onKill) j.notify = true;
1875
+ this.kill(j.id, reason);
1876
+ }
1877
+ /** Keep a reaper interval alive only while a reapable job is running (unref'd: never holds the process open). */
1878
+ arm() {
1879
+ if (this.timer || !this.options.sweepMs) return;
1880
+ this.timer = setInterval(() => {
1881
+ const live = [...this.jobs.values()].some((j) => j.status === "running" && (j.idleMs || j.hardCapMs));
1882
+ if (!live) {
1883
+ clearInterval(this.timer);
1884
+ this.timer = void 0;
1885
+ return;
1886
+ }
1887
+ void this.sweep().catch((e) => log5.warn(`job sweep failed: ${e}`));
1888
+ }, this.options.sweepMs);
1889
+ this.timer.unref?.();
1890
+ }
1891
+ /** Stop the reaper timer (tests / teardown). */
1892
+ dispose() {
1893
+ if (this.timer) clearInterval(this.timer);
1894
+ this.timer = void 0;
1895
+ }
1896
+ };
1897
+ SandboxJobRegistry = class extends JobRegistry {
1898
+ constructor(maxBuffer) {
1899
+ super(typeof maxBuffer === "number" ? { maxBuffer } : maxBuffer);
1900
+ }
1901
+ };
1902
+ NO_JOB = (id) => `Error: no background job '${id}'. Use JobStatus with no id to list jobs.`;
1903
+ describe = (j, now3) => `${j.id} ${j.status}${j.reason ? ` (${j.reason})` : ""} ${j.kind}${j.label ? " " + j.label : ""} \xB7 ${secs((j.endedAt ?? now3) - j.startedAt)} old \xB7 last activity ${secs(now3 - j.lastActivityAt)} ago \xB7 ${j.bytes} byte(s)`;
1904
+ }
1905
+ });
1906
+
1514
1907
  // src/NodeDiskFilesystem.ts
1515
1908
  var NodeDiskFilesystem_exports = {};
1516
1909
  __export(NodeDiskFilesystem_exports, {
@@ -1954,7 +2347,10 @@ __export(tools_shell_exports, {
1954
2347
  ShellJobRegistry: () => ShellJobRegistry,
1955
2348
  formatJobExit: () => formatJobExit,
1956
2349
  makeRealShellTool: () => makeRealShellTool,
1957
- makeShellJobTools: () => makeShellJobTools
2350
+ makeShellJobTools: () => makeShellJobTools,
2351
+ parsePsTime: () => parsePsTime,
2352
+ psGroupCpuSeconds: () => psGroupCpuSeconds,
2353
+ sumTreeCpu: () => sumTreeCpu
1958
2354
  });
1959
2355
  function killGroup(proc, signal) {
1960
2356
  if (!proc?.pid) return false;
@@ -1984,10 +2380,39 @@ async function nodeSpawn() {
1984
2380
  return _spawn;
1985
2381
  }
1986
2382
  function formatJobExit(n) {
1987
- return `[background job ${n.id} ${n.status}${n.exitCode != null ? ` exit ${n.exitCode}` : ""}] \`${n.command}\`
2383
+ return `[background job ${n.id} ${n.status}${n.exitCode != null ? ` exit ${n.exitCode}` : ""}${n.reason ? `: ${n.reason}` : ""}] \`${n.command}\`
1988
2384
  ` + (n.tail ? `${n.tail}
1989
2385
  ` : "(no output)\n") + `Read the full output with ShellOutput({id:"${n.id}"}).`;
1990
2386
  }
2387
+ function parsePsTime(t) {
2388
+ const m = t.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/);
2389
+ if (!m) return void 0;
2390
+ return Number(m[1] ?? 0) * 86400 + Number(m[2] ?? 0) * 3600 + Number(m[3]) * 60 + Number(m[4]);
2391
+ }
2392
+ async function psGroupCpuSeconds(pgid) {
2393
+ try {
2394
+ const { execFile } = await import("child_process");
2395
+ 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))));
2396
+ return sumTreeCpu(out, pgid);
2397
+ } catch (e) {
2398
+ log8.debug(`ps cpu probe failed for group ${pgid}: ${e}`);
2399
+ return void 0;
2400
+ }
2401
+ }
2402
+ function sumTreeCpu(psOut, root) {
2403
+ 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) }));
2404
+ const inJob = new Set(rows.filter((r) => r.pgid === root || r.pid === root).map((r) => r.pid));
2405
+ for (let grew = true; grew; ) {
2406
+ grew = false;
2407
+ for (const r of rows) if (!inJob.has(r.pid) && inJob.has(r.ppid)) {
2408
+ inJob.add(r.pid);
2409
+ grew = true;
2410
+ }
2411
+ }
2412
+ let total;
2413
+ for (const r of rows) if (inJob.has(r.pid) && r.sec != null) total = (total ?? 0) + r.sec;
2414
+ return total;
2415
+ }
1991
2416
  function makeRealShellTool(options) {
1992
2417
  const defaultTimeoutMs = options.timeoutMs ?? 12e4;
1993
2418
  const maxTimeoutMs = Math.max(options.maxTimeoutMs ?? 6e5, defaultTimeoutMs);
@@ -2118,7 +2543,7 @@ function makeRealShellTool(options) {
2118
2543
  proc.stderr?.on("data", collect);
2119
2544
  proc.on("error", (err) => {
2120
2545
  if (err?.name === "AbortError" || ctl.signal.aborted) return finish(reasonFor(timedOut, timeoutMs, clean(out)));
2121
- log7.debug("shell spawn error", err);
2546
+ log8.debug("shell spawn error", err);
2122
2547
  finish(`[exit 1] ${err?.message ?? err}${out ? "\n" + clean(out) : ""}`);
2123
2548
  });
2124
2549
  proc.on("close", (code) => {
@@ -2138,7 +2563,7 @@ function makeRealShellTool(options) {
2138
2563
  };
2139
2564
  }
2140
2565
  function handoffFor(timeoutMs, id, body, notifies) {
2141
- 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.");
2566
+ 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.");
2142
2567
  return body ? `${head}
2143
2568
  Output so far:
2144
2569
  ${body}` : head;
@@ -2162,7 +2587,7 @@ function makeShellJobTools(registry) {
2162
2587
  const out = registry.output(String(id));
2163
2588
  if (out == null) return NO_JOB2(String(id));
2164
2589
  const st = registry.status(String(id));
2165
- return `[${st.status}${st.exitCode != null ? ` exit ${st.exitCode}` : ""}]
2590
+ return `[${st.status}${st.exitCode != null ? ` exit ${st.exitCode}` : ""}${st.reason ? `: ${st.reason}` : ""}]
2166
2591
  ${clean(out) || "(no output yet)"}`;
2167
2592
  }
2168
2593
  },
@@ -2177,13 +2602,13 @@ ${clean(out) || "(no output yet)"}`;
2177
2602
  return jobs.length ? jobs.map((j) => `${j.id} ${j.status} ${j.command}`).join("\n") : "(no background jobs)";
2178
2603
  }
2179
2604
  const st = registry.status(String(id));
2180
- return st ? `${st.status}${st.exitCode != null ? ` (exit ${st.exitCode})` : ""} \xB7 ${st.bytes} byte(s) buffered` : NO_JOB2(String(id));
2605
+ return st ? `${st.status}${st.exitCode != null ? ` (exit ${st.exitCode})` : ""}${st.reason ? ` \u2014 ${st.reason}` : ""} \xB7 ${st.bytes} byte(s) buffered` : NO_JOB2(String(id));
2181
2606
  }
2182
2607
  },
2183
2608
  {
2184
2609
  ...dropOnRebind,
2185
2610
  name: "ShellKill",
2186
- description: "Stop a running background Shell job by id (SIGTERM).",
2611
+ description: "Stop a running background Shell job by id (SIGTERM to its process group, SIGKILL if it ignores that).",
2187
2612
  parameters: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
2188
2613
  async run({ id }) {
2189
2614
  return registry.kill(String(id)) ? `Killed job ${id}.` : NO_JOB2(String(id));
@@ -2191,7 +2616,7 @@ ${clean(out) || "(no output yet)"}`;
2191
2616
  }
2192
2617
  ];
2193
2618
  }
2194
- var log7, clean, DETACHED, SECRET_ENV_RE, _spawn, ShellJobRegistry, NO_JOB2;
2619
+ var log8, clean, DETACHED, SECRET_ENV_RE, _spawn, ShellJobRegistry, shellStatus, NO_JOB2;
2195
2620
  var init_tools_shell = __esm({
2196
2621
  "src/tools.shell.ts"() {
2197
2622
  "use strict";
@@ -2199,62 +2624,94 @@ var init_tools_shell = __esm({
2199
2624
  init_redact();
2200
2625
  init_logging();
2201
2626
  init_shell_sandbox();
2202
- log7 = forComponent("shell");
2627
+ init_tools_jobs();
2628
+ log8 = forComponent("shell");
2203
2629
  clean = (s) => truncateOutput(redactSecrets(s.replace(/\n+$/, "")));
2204
2630
  DETACHED = { stdio: ["ignore", "pipe", "pipe"], detached: true };
2205
2631
  SECRET_ENV_RE = /(_API_KEY|_TOKEN|_SECRET|_PASSWORD|_PRIVATE_KEY|^AWS_|^GITHUB_TOKEN$|^OPENAI_|^ANTHROPIC_|^GOOGLE_|^GEMINI_|^GROQ_|^NPM_TOKEN$)/i;
2206
2632
  ShellJobRegistry = class {
2207
2633
  constructor(cfg) {
2208
2634
  this.cfg = cfg;
2635
+ this.jobs = cfg.jobs ?? new JobRegistry(cfg.maxBuffer ? { maxBuffer: cfg.maxBuffer } : {});
2209
2636
  if (cfg.killOnExit && typeof process !== "undefined") process.once("exit", () => this.killAll());
2210
2637
  }
2211
2638
  cfg;
2212
- jobs = /* @__PURE__ */ new Map();
2213
- seq = 0;
2639
+ jobs;
2640
+ meta = /* @__PURE__ */ new Map();
2214
2641
  async start(command) {
2215
- const id = `job-${++this.seq}`;
2216
- const max = this.cfg.maxBuffer ?? 256 * 1024;
2217
- const job = { command, buf: "", status: "running" };
2218
- const append = (chunk) => {
2219
- const s = typeof chunk === "string" ? chunk : chunk?.toString?.("utf8") ?? "";
2220
- job.buf = (job.buf + s).slice(-max);
2221
- };
2642
+ const h = this.track(command, { idleMs: 0 });
2222
2643
  try {
2223
2644
  const spawn = this.cfg.spawn ?? await nodeSpawn();
2224
2645
  const argv = this.cfg.osSandbox ? await spawnArgvFor(command, this.cfg.cwd, this.cfg.osSandbox) : { bin: "/bin/sh", args: ["-c", command] };
2225
- const proc = spawn(argv.bin, argv.args, { cwd: this.cfg.cwd, env: childEnv(this.cfg), ...DETACHED });
2226
- job.proc = proc;
2227
- proc.stdout?.on("data", append);
2228
- proc.stderr?.on("data", append);
2229
- proc.on("error", (err) => {
2230
- if (job.status === "running") {
2231
- job.status = "error";
2232
- append(`
2233
- [error] ${err?.message ?? err}`);
2234
- this.notifyExit(id, job);
2235
- }
2236
- });
2237
- proc.on("close", (code) => {
2238
- if (job.status === "running") {
2239
- job.status = "exited";
2240
- job.exitCode = code ?? void 0;
2241
- this.notifyExit(id, job);
2242
- }
2243
- });
2646
+ this.attach(h, spawn(argv.bin, argv.args, { cwd: this.cfg.cwd, env: childEnv(this.cfg), ...DETACHED }));
2244
2647
  } catch (e) {
2245
- job.status = "error";
2246
- job.buf = `failed to spawn: ${e?.message ?? e}`;
2648
+ h.chunk(`failed to spawn: ${e?.message ?? e}`);
2649
+ h.finish("error", void 0, e?.message ?? String(e));
2650
+ }
2651
+ return h.id;
2652
+ }
2653
+ track(command, extra) {
2654
+ const meta = { command };
2655
+ let id = "";
2656
+ const h = this.jobs.track({
2657
+ kind: "shell",
2658
+ label: command,
2659
+ seed: extra.seed,
2660
+ idleMs: extra.idleMs,
2661
+ cpuTime: extra.cpu ? async () => meta.proc?.pid ? (this.cfg.cpuTime ?? psGroupCpuSeconds)(meta.proc.pid) : void 0 : void 0,
2662
+ onKill: () => this.signal(meta),
2663
+ onExit: (n) => this.notifyExit(id, meta, n.status, n.reason),
2664
+ notify: true
2665
+ });
2666
+ id = h.id;
2667
+ this.meta.set(id, meta);
2668
+ return h;
2669
+ }
2670
+ attach(h, proc) {
2671
+ const meta = this.meta.get(h.id);
2672
+ meta.proc = proc;
2673
+ const append = (chunk) => h.chunk(typeof chunk === "string" ? chunk : chunk?.toString?.("utf8") ?? "");
2674
+ proc.stdout?.on("data", append);
2675
+ proc.stderr?.on("data", append);
2676
+ proc.on("error", (err) => {
2677
+ if (h.status === "running") {
2678
+ append(`
2679
+ [error] ${err?.message ?? err}`);
2680
+ h.finish("error", void 0, err?.message ?? String(err));
2681
+ }
2682
+ });
2683
+ proc.on("close", (code) => {
2684
+ if (h.status === "running") {
2685
+ meta.exitCode = code ?? void 0;
2686
+ h.finish("done");
2687
+ }
2688
+ });
2689
+ }
2690
+ /** SIGTERM the whole group (bg children are detached group leaders — /bin/sh alone would orphan a forked
2691
+ * server), then SIGKILL after the grace period if it is still there. Falls back to the pid for fakes. */
2692
+ signal(meta) {
2693
+ const proc = meta.proc;
2694
+ if (!killGroup(proc, "SIGTERM")) {
2695
+ try {
2696
+ proc?.kill("SIGTERM");
2697
+ } catch {
2698
+ }
2247
2699
  }
2248
- this.jobs.set(id, job);
2249
- return id;
2250
- }
2251
- /** Fire `onExit` at most once per job, with the tail so the model can act without a second round-trip. */
2252
- notified = /* @__PURE__ */ new Set();
2253
- notifyExit(id, job) {
2254
- if (this.notified.has(id) || !this.cfg.onExit) return;
2255
- this.notified.add(id);
2700
+ if (!proc?.pid) return;
2701
+ let closed = false;
2702
+ proc.on("close", () => {
2703
+ closed = true;
2704
+ });
2705
+ const t = setTimeout(() => {
2706
+ if (!closed) killGroup(proc, "SIGKILL");
2707
+ }, this.jobs.options.killGraceMs);
2708
+ t.unref?.();
2709
+ }
2710
+ /** 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. */
2711
+ notifyExit(id, meta, status, reason) {
2712
+ if (!this.cfg.onExit || status === "killed" && !reason?.startsWith("stuck")) return;
2256
2713
  try {
2257
- this.cfg.onExit({ id, command: job.command, status: job.status, exitCode: job.exitCode, tail: clean(job.buf).slice(-4e3) });
2714
+ 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 } : {} });
2258
2715
  } catch {
2259
2716
  }
2260
2717
  }
@@ -2273,14 +2730,20 @@ var init_tools_shell = __esm({
2273
2730
  }
2274
2731
  /** Current tail output for a job (null = no such job). */
2275
2732
  output(id) {
2276
- return this.jobs.get(id)?.buf ?? (this.jobs.has(id) ? "" : null);
2733
+ return this.meta.has(id) ? this.jobs.output(id) : null;
2277
2734
  }
2735
+ // null too once the shared registry pruned it
2278
2736
  status(id) {
2279
- const j = this.jobs.get(id);
2280
- return j ? { status: j.status, exitCode: j.exitCode, bytes: j.buf.length } : null;
2737
+ const m = this.meta.get(id);
2738
+ const j = m && this.jobs.get(id);
2739
+ if (!m || !j) {
2740
+ this.meta.delete(id);
2741
+ return null;
2742
+ }
2743
+ return { status: shellStatus(j.status), exitCode: m.exitCode, bytes: j.bytes, ...j.reason ? { reason: j.reason } : {} };
2281
2744
  }
2282
2745
  list() {
2283
- return [...this.jobs].map(([id, j]) => ({ id, command: j.command, status: j.status }));
2746
+ return [...this.meta].filter(([id]) => this.jobs.get(id)).map(([id, m]) => ({ id, command: m.command, status: shellStatus(this.jobs.get(id).status) }));
2284
2747
  }
2285
2748
  /**
2286
2749
  * Take over an ALREADY-RUNNING child as a background job. A foreground command that outruns its
@@ -2288,53 +2751,22 @@ var init_tools_shell = __esm({
2288
2751
  * worse, a launcher that spawned its own detached worker leaves that worker running with nothing
2289
2752
  * tracking it. Adopting hands the model a handle instead: the command keeps going, its completion
2290
2753
  * is reported like any other job, and `seed` carries the output produced before the handoff.
2754
+ * Unlike an explicit background job it IS liveness-checked (idle window + CPU probe): nobody asked for
2755
+ * a long-lived process here, so one that goes silent and CPU-idle is stuck, not serving.
2291
2756
  */
2292
2757
  adopt(command, proc, seed = "") {
2293
- const id = `job-${++this.seq}`;
2294
- const max = this.cfg.maxBuffer ?? 256 * 1024;
2295
- const job = { command, buf: seed.slice(-max), status: "running", proc };
2296
- const append = (chunk) => {
2297
- const s = typeof chunk === "string" ? chunk : chunk?.toString?.("utf8") ?? "";
2298
- job.buf = (job.buf + s).slice(-max);
2299
- };
2300
- proc.stdout?.on("data", append);
2301
- proc.stderr?.on("data", append);
2302
- proc.on("error", (err) => {
2303
- if (job.status === "running") {
2304
- job.status = "error";
2305
- append(`
2306
- [error] ${err?.message ?? err}`);
2307
- this.notifyExit(id, job);
2308
- }
2309
- });
2310
- proc.on("close", (code) => {
2311
- if (job.status === "running") {
2312
- job.status = "exited";
2313
- job.exitCode = code ?? void 0;
2314
- this.notifyExit(id, job);
2315
- }
2316
- });
2317
- this.jobs.set(id, job);
2318
- return id;
2758
+ const h = this.track(command, { seed, cpu: true });
2759
+ this.attach(h, proc);
2760
+ return h.id;
2319
2761
  }
2320
- kill(id) {
2321
- const j = this.jobs.get(id);
2322
- if (!j) return false;
2323
- if (j.status === "running") {
2324
- if (!killGroup(j.proc, "SIGTERM")) {
2325
- try {
2326
- j.proc?.kill("SIGTERM");
2327
- } catch {
2328
- }
2329
- }
2330
- j.status = "killed";
2331
- }
2332
- return true;
2762
+ kill(id, reason = "killed by ShellKill") {
2763
+ return this.meta.has(id) && this.jobs.kill(id, reason);
2333
2764
  }
2334
2765
  killAll() {
2335
- for (const id of this.jobs.keys()) this.kill(id);
2766
+ for (const id of this.meta.keys()) this.jobs.kill(id, "agent exiting");
2336
2767
  }
2337
2768
  };
2769
+ shellStatus = (s) => s === "done" ? "exited" : s === "running" ? "running" : s === "killed" ? "killed" : "error";
2338
2770
  NO_JOB2 = (id) => `Error: no background job '${id}'. Use ShellStatus with no id to list jobs, or start one with Shell({background:true}).`;
2339
2771
  }
2340
2772
  });
@@ -2342,111 +2774,7 @@ var init_tools_shell = __esm({
2342
2774
  // src/Agent.ts
2343
2775
  init_llm();
2344
2776
  init_tools();
2345
-
2346
- // src/tools.jobs.ts
2347
- var SandboxJobRegistry = class {
2348
- constructor(maxBuffer = 256 * 1024) {
2349
- this.maxBuffer = maxBuffer;
2350
- }
2351
- maxBuffer;
2352
- jobs = /* @__PURE__ */ new Map();
2353
- seq = 0;
2354
- /** Start `fn` in the background and return a job id immediately (does not await completion). */
2355
- start(fn, opts = {}) {
2356
- const id = `job-${++this.seq}`;
2357
- const ctl = new AbortController();
2358
- const onChunk = (s) => {
2359
- job.buf = (job.buf + s).slice(-this.maxBuffer);
2360
- };
2361
- const job = { kind: opts.kind ?? "task", label: opts.label ?? "", status: "running", buf: "", ctl, promise: Promise.resolve() };
2362
- job.promise = fn({ signal: ctl.signal, onChunk }).then(
2363
- (res) => {
2364
- if (job.status === "running") {
2365
- job.status = "done";
2366
- if (res) onChunk(res);
2367
- }
2368
- },
2369
- (e) => {
2370
- if (job.status === "running") {
2371
- job.status = "error";
2372
- onChunk(`
2373
- [error] ${e?.message ?? e}`);
2374
- }
2375
- }
2376
- );
2377
- this.jobs.set(id, job);
2378
- return id;
2379
- }
2380
- /** Tail output for a job (null = no such job, '' = job exists but no output yet). */
2381
- output(id) {
2382
- return this.jobs.get(id)?.buf ?? (this.jobs.has(id) ? "" : null);
2383
- }
2384
- status(id) {
2385
- const j = this.jobs.get(id);
2386
- return j ? { status: j.status, bytes: j.buf.length } : null;
2387
- }
2388
- list() {
2389
- return [...this.jobs].map(([id, j]) => ({ id, kind: j.kind, label: j.label, status: j.status }));
2390
- }
2391
- /** Signal cancel (the producer decides what abort means — e.g. skip the overlay commit). */
2392
- kill(id) {
2393
- const j = this.jobs.get(id);
2394
- if (!j) return false;
2395
- if (j.status === "running") {
2396
- j.ctl.abort();
2397
- j.status = "killed";
2398
- }
2399
- return true;
2400
- }
2401
- killAll() {
2402
- for (const id of this.jobs.keys()) this.kill(id);
2403
- }
2404
- /** Await every still-running job. Called at turn end so a job's committed writes aren't lost from view
2405
- * just because the model didn't poll it. Returns the ids that were still running when drain began. */
2406
- async drain() {
2407
- const pending = [...this.jobs].filter(([, j]) => j.status === "running").map(([id]) => id);
2408
- await Promise.all([...this.jobs.values()].map((j) => j.promise));
2409
- return pending;
2410
- }
2411
- };
2412
- var NO_JOB = (id) => `Error: no background job '${id}'. Use JobStatus with no id to list jobs, or start one with bash({background:true}).`;
2413
- function makeJobTools(registry) {
2414
- return [
2415
- {
2416
- name: "JobOutput",
2417
- description: "Read the accumulated output (tail) of a background job by id (from bash({background:true})).",
2418
- parameters: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
2419
- async run({ id }) {
2420
- const out = registry.output(String(id));
2421
- if (out == null) return NO_JOB(String(id));
2422
- const st = registry.status(String(id));
2423
- return `[${st.status}]
2424
- ${out.replace(/\n+$/, "") || "(no output yet)"}`;
2425
- }
2426
- },
2427
- {
2428
- name: "JobStatus",
2429
- description: "Status of a background job (running/done/killed/error). Omit `id` to list all jobs.",
2430
- parameters: { type: "object", properties: { id: { type: "string" } } },
2431
- async run({ id }) {
2432
- if (!id) {
2433
- const jobs = registry.list();
2434
- return jobs.length ? jobs.map((j) => `${j.id} ${j.status} ${j.kind}${j.label ? " " + j.label : ""}`).join("\n") : "(no background jobs)";
2435
- }
2436
- const st = registry.status(String(id));
2437
- return st ? `${st.status} \xB7 ${st.bytes} byte(s) buffered` : NO_JOB(String(id));
2438
- }
2439
- },
2440
- {
2441
- name: "JobKill",
2442
- description: "Stop a running background job by id (the job is cancelled; its overlay writes are NOT committed).",
2443
- parameters: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
2444
- async run({ id }) {
2445
- return registry.kill(String(id)) ? `Killed job ${id}.` : NO_JOB(String(id));
2446
- }
2447
- }
2448
- ];
2449
- }
2777
+ init_tools_jobs();
2450
2778
 
2451
2779
  // src/host.ts
2452
2780
  var askUserQuestionTool = {
@@ -3229,7 +3557,7 @@ function exitWorktree() {
3229
3557
  // src/subagent.ts
3230
3558
  init_NodeDiskFilesystem();
3231
3559
  init_logging();
3232
- var log5 = forComponent("subagent");
3560
+ var log6 = forComponent("subagent");
3233
3561
  async function boundedPool(items, limit, fn) {
3234
3562
  const out = new Array(items.length);
3235
3563
  let next = 0;
@@ -3303,7 +3631,7 @@ function traceFileFor(dir, label, depth) {
3303
3631
  const id = `${Date.now().toString(36)}-${(traceSeq++).toString(36)}`;
3304
3632
  return join3(dir, `${id}-d${depth}-${sanitizeSlug(label) || "sub"}.json`);
3305
3633
  } catch (e) {
3306
- log5.warn(`subagent trace dir unavailable (${dir}) \u2014 running without a trace: ${e instanceof Error ? e.message : String(e)}`);
3634
+ log6.warn(`subagent trace dir unavailable (${dir}) \u2014 running without a trace: ${e instanceof Error ? e.message : String(e)}`);
3307
3635
  return void 0;
3308
3636
  }
3309
3637
  }
@@ -3311,7 +3639,7 @@ function flushTrace(file, meta, messages, status) {
3311
3639
  try {
3312
3640
  writeFileSync(file, JSON.stringify({ ...meta, status, updated: Date.now(), messages }, null, 2));
3313
3641
  } catch (e) {
3314
- log5.warn(`subagent trace flush failed (${file}): ${e instanceof Error ? e.message : String(e)}`);
3642
+ log6.warn(`subagent trace flush failed (${file}): ${e instanceof Error ? e.message : String(e)}`);
3315
3643
  }
3316
3644
  }
3317
3645
  function installTrace(child, file, meta) {
@@ -3352,7 +3680,7 @@ async function runChildTraced(args) {
3352
3680
  if (file) flushTrace(file, meta, child.transcript, wc.timedOut() ? "deadline" : res.finishReason);
3353
3681
  let result = res.finishReason === "stop" ? res.text || `(child '${label}' finished with no summary; finishReason=${res.finishReason})` : abortHint(label, res, file);
3354
3682
  if (wc.timedOut()) {
3355
- log5.warn(`sub-task '${label}' hit its ${deadlineMs}ms wall-clock deadline (steps=${res.steps})`);
3683
+ log6.warn(`sub-task '${label}' hit its ${deadlineMs}ms wall-clock deadline (steps=${res.steps})`);
3356
3684
  result += deadlineNote(deadlineMs);
3357
3685
  }
3358
3686
  await opts.hooks?.onSubagentStop?.(result, { label, agentType });
@@ -3806,7 +4134,7 @@ function reasoningToChatFragment(model, effort) {
3806
4134
  }
3807
4135
 
3808
4136
  // src/Agent.ts
3809
- var log6 = forComponent("Agent");
4137
+ var log7 = forComponent("Agent");
3810
4138
  function envInt(key, fallback) {
3811
4139
  const n = typeof process !== "undefined" ? Number(process.env?.[key]) : NaN;
3812
4140
  return Number.isFinite(n) && n >= 0 ? n : fallback;
@@ -3958,6 +4286,12 @@ var AgentOptions = class {
3958
4286
  /** Enable `bash({background:true})` + JobOutput/JobStatus/JobKill — sandbox background jobs (overlay-isolated,
3959
4287
  * committed on completion, drained at turn end). Useful when the VFS backend is slow (remote) or for sub-agents. */
3960
4288
  backgroundJobs = false;
4289
+ /** The job registry every long-lived tool call lives in (MCP calls past their soft timeout, delegated host/native
4290
+ * tools, and — when the host shares it with its `ShellJobRegistry` — shell jobs). Its liveness is what the stall
4291
+ * watchdog and the stuck-job reaper decide from. Default: a fresh registry per Agent. Knobs (idle windows, hard
4292
+ * caps, MCP soft timeout) live on `JobRegistryOptions`. Completion notices reach the model via `inject` unless
4293
+ * the host set its own `onExit`. */
4294
+ jobs;
3961
4295
  /** Plan mode: block mutating tools until the agent calls `ExitPlanMode` (host-approved). */
3962
4296
  planMode = false;
3963
4297
  /** Permission policy gating each tool call (allow / ask / deny). */
@@ -4024,16 +4358,14 @@ var Agent = class _Agent {
4024
4358
  /** Per-run ground truth: every tool name actually INVOKED this run (native dispatch + delegated
4025
4359
  * runtime activity). The ledger that makes a fabricated tool claim provable. Reset per run. */
4026
4360
  invokedTools = /* @__PURE__ */ new Set();
4027
- /** Delegated-runtime HOST tools currently executing (cursor toolExecutor). While > 0 the provider is
4028
- * not silent — it is waiting on US — so the idle-stall watchdog must not fire (see armStallWatchdog). */
4029
- hostToolsInFlight = 0;
4030
- /** The hold is bounded by the transport tool deadline: a host tool that never settles (the helper already gave
4031
- * up on it) must not disable stall protection for the rest of the request — or this instance. */
4032
- hostToolHoldUntil = 0;
4033
- /** Delegated-runtime NATIVE tools (cursor's own shell/mcp/task) seen `running` but not yet `completed`/`error`,
4034
- * id → hold-until. The runtime streams nothing while one runs, so it holds the watchdog like a host tool —
4035
- * bounded by the same tool deadline (`toolHoldMs`) so a lost `completed` event can't disable it. */
4036
- nativeToolsInFlight = /* @__PURE__ */ new Map();
4361
+ /** Every long-lived tool call is a job here (see AgentOptions.jobs). Delegated-runtime HOST tools (cursor
4362
+ * toolExecutor) and NATIVE tools (cursor's own shell/mcp/task, `running` → `completed`) are tracked as
4363
+ * `holds` jobs: while one runs the provider is not silent — it is waiting on a tool — so the idle-stall
4364
+ * watchdog must not fire. Each is bounded by the transport tool deadline (`hardCapMs = toolHoldMs`), so a
4365
+ * tool that never settles (or a lost `completed` event) is reaped and cannot disable stall protection. */
4366
+ jobs;
4367
+ /** Native delegated tool activity id → its job (per step attempt). */
4368
+ nativeJobs = /* @__PURE__ */ new Map();
4037
4369
  toolHoldMs = 0;
4038
4370
  /** A tool (host or native) already executed in the current step attempt: a stall retry replays the step
4039
4371
  * from the pre-step transcript, which would re-run it — so such a stall is not retried. */
@@ -4082,6 +4414,7 @@ var Agent = class _Agent {
4082
4414
  addTools(tools) {
4083
4415
  this.injectedTools.push(...tools);
4084
4416
  this.activeTools.push(...tools);
4417
+ if (this.prepared && tools.some(isMcpTool) && !this.activeTools.some((t) => t.name === "JobWait")) this.activeTools.push(...makeJobTools(this.jobs));
4085
4418
  }
4086
4419
  /** Remove tools by name from a running agent. Returns the count removed. Also filters
4087
4420
  * `options.tools` — otherwise a constructor-passed tool resurrects on the next prepare()
@@ -4133,6 +4466,11 @@ var Agent = class _Agent {
4133
4466
  }
4134
4467
  constructor(options) {
4135
4468
  this.options = { ...new AgentOptions(), ...options };
4469
+ this.jobs = this.options.jobs ?? new JobRegistry();
4470
+ this.jobs.options.onExit ??= (n) => {
4471
+ const imgs = n.result?.images;
4472
+ this.inject(imgs?.length ? [{ type: "text", text: formatJobNotice(n) }, ...imgs.map((i) => imagePart(`data:${i.mimeType};base64,${i.data}`))] : formatJobNotice(n));
4473
+ };
4136
4474
  if (this.options.fs) this.buildCtx();
4137
4475
  }
4138
4476
  /** Build the tool context from the resolved fs + options. Idempotent-safe: called once (ctor or ensureFs). */
@@ -4144,7 +4482,8 @@ var Agent = class _Agent {
4144
4482
  this.ctx.ai = this.options.ai;
4145
4483
  this.ctx.model = this.options.model;
4146
4484
  this.ctx.parkHuman = (p) => this.park(p);
4147
- if (this.options.backgroundJobs) this.ctx.jobs = new SandboxJobRegistry();
4485
+ this.ctx.toolJobs = this.jobs;
4486
+ if (this.options.backgroundJobs) this.ctx.jobs = this.jobs;
4148
4487
  }
4149
4488
  /**
4150
4489
  * Resolve the filesystem + build the tool context if the ctor couldn't (no `fs` was passed). The disk
@@ -4160,7 +4499,7 @@ var Agent = class _Agent {
4160
4499
  const disk = new NodeDiskFilesystem2(process.cwd());
4161
4500
  await disk.init();
4162
4501
  this.options.fs = new JailedFilesystem2(disk);
4163
- log6.info(`no fs provided \u2014 defaulting to jailed real disk at ${process.cwd()}`);
4502
+ log7.info(`no fs provided \u2014 defaulting to jailed real disk at ${process.cwd()}`);
4164
4503
  }
4165
4504
  this.buildCtx();
4166
4505
  }
@@ -4230,7 +4569,7 @@ var Agent = class _Agent {
4230
4569
  tools = [...tools, makeTaskTool(taskOpts), makeTaskBatchTool(taskOpts)];
4231
4570
  }
4232
4571
  if (o.checkpoints) tools = [...tools, ...checkpointTools()];
4233
- if (this.ctx.jobs) tools = [...tools, ...makeJobTools(this.ctx.jobs)];
4572
+ if (this.ctx.jobs || [...tools, ...this.injectedTools].some(isMcpTool)) tools = [...tools, ...makeJobTools(this.jobs)];
4234
4573
  const plan = o.planMode ? planMode({ host: o.host }) : void 0;
4235
4574
  if (plan) tools = [...tools, plan.tool];
4236
4575
  this.activeHooks = composeHooks(o.hooks, plan?.hooks, o.permissions?.hooks());
@@ -4346,8 +4685,8 @@ var Agent = class _Agent {
4346
4685
  let closureNudged = false;
4347
4686
  let lastProgressAt = start;
4348
4687
  const kill = (finishReason) => {
4349
- log6.warn(`kill-switch: ${finishReason} (steps=${steps}, tokens=${usage.totalTokens}, budgetTokens=${Math.round(usage.totalTokens - 0.9 * usage.cacheReadTokens)}, ms=${Date.now() - start - this.parkedMs}${finishReason === "timeout" ? `, idle=${Date.now() - lastProgressAt - this.parkedMs}ms` : ""}${this.parkedMs ? ` +${this.parkedMs} parked` : ""})`);
4350
- this.ctx.jobs?.killAll();
4688
+ log7.warn(`kill-switch: ${finishReason} (steps=${steps}, tokens=${usage.totalTokens}, budgetTokens=${Math.round(usage.totalTokens - 0.9 * usage.cacheReadTokens)}, ms=${Date.now() - start - this.parkedMs}${finishReason === "timeout" ? `, idle=${Date.now() - lastProgressAt - this.parkedMs}ms` : ""}${this.parkedMs ? ` +${this.parkedMs} parked` : ""})`);
4689
+ this.jobs.killAll(`run ended: ${finishReason}`, true);
4351
4690
  const claims = this.survivingClaims();
4352
4691
  return { text: lastAssistantText(this.transcript), steps, finishReason, messages: this.transcript, usage, usageEstimated, ...claims.length ? { fabricatedToolClaims: claims } : {} };
4353
4692
  };
@@ -4371,20 +4710,23 @@ var Agent = class _Agent {
4371
4710
  this.toolHoldMs = effectiveToolTimeoutMs;
4372
4711
  if (isCursorWithTools) {
4373
4712
  if (typeof hostToolTimeoutMs === "number" && effectiveToolTimeoutMs !== hostToolTimeoutMs) {
4374
- log6.warn(`providerOptions.toolTimeoutMs=${hostToolTimeoutMs}ms is below what the registered tools need \u2014 RAISED to ${effectiveToolTimeoutMs}ms. ${toolTimeoutViolations(this.activeTools, hostToolTimeoutMs).length} tool(s) could outlive it, and the transport would have destroyed their results. To lower it, lower the tools' own maxDurationMs (e.g. the Shell tool's maxTimeoutMs) instead.`);
4713
+ log7.warn(`providerOptions.toolTimeoutMs=${hostToolTimeoutMs}ms is below what the registered tools need \u2014 RAISED to ${effectiveToolTimeoutMs}ms. ${toolTimeoutViolations(this.activeTools, hostToolTimeoutMs).length} tool(s) could outlive it, and the transport would have destroyed their results. To lower it, lower the tools' own maxDurationMs (e.g. the Shell tool's maxTimeoutMs) instead.`);
4375
4714
  }
4376
- for (const v of toolTimeoutViolations(this.activeTools, effectiveToolTimeoutMs)) log6.warn(v);
4715
+ for (const v of toolTimeoutViolations(this.activeTools, effectiveToolTimeoutMs)) log7.warn(v);
4377
4716
  }
4378
4717
  const cursorPo = isCursorWithTools ? {
4379
4718
  toolTimeoutMs: effectiveToolTimeoutMs,
4380
4719
  toolExecutor: async (name, args) => {
4381
4720
  const tc = { id: `cursor-${Date.now()}`, type: "function", function: { name, arguments: JSON.stringify(args) } };
4382
- this.hostToolsInFlight++;
4383
- this.hostToolHoldUntil = Math.max(this.hostToolHoldUntil, Date.now() + effectiveToolTimeoutMs);
4721
+ const job = this.jobs.track({ kind: "host", label: name, holds: true, hardCapMs: effectiveToolTimeoutMs });
4384
4722
  try {
4385
- return await this.dispatch(tc);
4723
+ const r = await this.dispatch(tc);
4724
+ job.finish("done");
4725
+ return r;
4726
+ } catch (e) {
4727
+ job.finish("error", void 0, String(e));
4728
+ throw e;
4386
4729
  } finally {
4387
- this.hostToolsInFlight--;
4388
4730
  this.toolRanThisAttempt = true;
4389
4731
  this.stallPoke?.();
4390
4732
  }
@@ -4398,7 +4740,8 @@ var Agent = class _Agent {
4398
4740
  };
4399
4741
  try {
4400
4742
  for (let attempt = 0; ; attempt++) {
4401
- this.nativeToolsInFlight.clear();
4743
+ for (const [, h] of this.nativeJobs) if (h.status === "running") this.jobs.kill(h.id, "step attempt ended");
4744
+ this.nativeJobs.clear();
4402
4745
  this.toolRanThisAttempt = false;
4403
4746
  try {
4404
4747
  if (useStream) {
@@ -4430,11 +4773,11 @@ var Agent = class _Agent {
4430
4773
  const empty = err?.code === "empty" || err?.code === "cursor_silent";
4431
4774
  const maxAttempts = empty ? Math.max(o.emptyRetries, 0) : 2;
4432
4775
  const replaysTool = stalled && this.toolRanThisAttempt;
4433
- if (replaysTool) log6.warn("model stall after tool(s) already ran this step \u2014 not retrying (a retry would replay them)");
4776
+ if (replaysTool) log7.warn("model stall after tool(s) already ran this step \u2014 not retrying (a retry would replay them)");
4434
4777
  const transient = !o.signal?.aborted && !isAbortError(err) && !replaysTool && attempt < maxAttempts && (network || serverSide || stalled || empty);
4435
4778
  if (!transient) throw err;
4436
4779
  const waitMs = (empty ? o.emptyRetryBaseMs : 1e3) * (attempt + 1);
4437
- log6.warn(`${empty ? "empty model turn" : stalled ? "model stall" : "network drop"} mid-step (${err?.message ?? err}) \u2014 retrying in ${waitMs}ms`);
4780
+ log7.warn(`${empty ? "empty model turn" : stalled ? "model stall" : "network drop"} mid-step (${err?.message ?? err}) \u2014 retrying in ${waitMs}ms`);
4438
4781
  o.host?.notify?.({ kind: "retry", message: `${empty ? "model returned nothing" : stalled ? "model stalled" : "connection dropped"} \u2014 retrying step (#${attempt + 1})` });
4439
4782
  await new Promise((r) => setTimeout(r, waitMs));
4440
4783
  }
@@ -4442,12 +4785,12 @@ var Agent = class _Agent {
4442
4785
  } catch (err) {
4443
4786
  if (err?.code === "budget") return kill("budget");
4444
4787
  if (err?.code === "stall") {
4445
- log6.warn(`model stalled out after retries: ${err?.message ?? err}`);
4788
+ log7.warn(`model stalled out after retries: ${err?.message ?? err}`);
4446
4789
  return { ...kill("timeout"), error: err };
4447
4790
  }
4448
4791
  if (o.signal?.aborted || isAbortError(err)) return kill("aborted");
4449
4792
  if (err?.code === "empty" || err?.code === "cursor_silent") {
4450
- log6.warn(`model returned empty/silent turns after retries \u2014 surfacing as failed`);
4793
+ log7.warn(`model returned empty/silent turns after retries \u2014 surfacing as failed`);
4451
4794
  return { text: "", steps, finishReason: "empty", messages: this.transcript, usage, usageEstimated, error: err };
4452
4795
  }
4453
4796
  const body = err?.body ?? err?.response?.data ?? err?.error;
@@ -4459,7 +4802,7 @@ var Agent = class _Agent {
4459
4802
  }
4460
4803
  if (bodyStr && err instanceof Error && !err.message.includes(bodyStr)) err.detail = bodyStr;
4461
4804
  const errLog = err instanceof Error ? { message: err.message, ...err.detail ? { detail: err.detail } : {}, stack: err.stack } : err;
4462
- log6.error(`chat() failed: ${err?.message ?? err}${bodyStr ? ` \u2014 ${bodyStr}` : ""}`, errLog);
4805
+ log7.error(`chat() failed: ${err?.message ?? err}${bodyStr ? ` \u2014 ${bodyStr}` : ""}`, errLog);
4463
4806
  return { text: "", steps, finishReason: "error", messages: this.transcript, usage, usageEstimated, error: err };
4464
4807
  }
4465
4808
  if (o.signal?.aborted) return kill("aborted");
@@ -4515,21 +4858,21 @@ var Agent = class _Agent {
4515
4858
  if (emptyTurn && o.failEmptyTurns) {
4516
4859
  if (!emptyNudged && canNudge()) {
4517
4860
  emptyNudged = true;
4518
- log6.warn(`empty model turn after tool work (step ${steps}, provider finishReason=${res.finishReason ?? "n/a"}) \u2014 nudging once`);
4861
+ log7.warn(`empty model turn after tool work (step ${steps}, provider finishReason=${res.finishReason ?? "n/a"}) \u2014 nudging once`);
4519
4862
  this.transcript.push({ role: "user", content: "Continue \u2014 you returned nothing. Answer the user now using the tool results above, or keep working if you are not done." });
4520
4863
  continue;
4521
4864
  }
4522
- log6.warn(`model returned empty turns after tool work \u2014 surfacing as failed`);
4865
+ log7.warn(`model returned empty turns after tool work \u2014 surfacing as failed`);
4523
4866
  const e = new Error("empty_response: model returned an empty turn (no text, no tools) after a re-prompt");
4524
4867
  e.code = "empty";
4525
4868
  return { text: "", steps, finishReason: "empty", ...res.finishReason ? { providerFinishReason: res.finishReason } : {}, messages: this.transcript, usage, usageEstimated, error: e };
4526
4869
  }
4527
4870
  const fabricated = o.catchFabricatedToolUse && !closureNudged ? this.fabricatedToolNames(contentText(res.content ?? "")) : [];
4528
4871
  for (const n of fabricated) this.fabricatedClaims.add(n);
4529
- if (fabricated.length && !canNudge()) log6.warn(`fabricated tool use: text names ${fabricated.join(", ")} but no step/budget left to demand the call \u2014 finishing with the existing answer`);
4872
+ if (fabricated.length && !canNudge()) log7.warn(`fabricated tool use: text names ${fabricated.join(", ")} but no step/budget left to demand the call \u2014 finishing with the existing answer`);
4530
4873
  if (fabricated.length && canNudge()) {
4531
4874
  closureNudged = true;
4532
- log6.warn(`fabricated tool use: text names ${fabricated.join(", ")} but the run invoked ${this.invokedTools.size ? [...this.invokedTools].join(", ") : "nothing"} \u2014 demanding the real call`);
4875
+ log7.warn(`fabricated tool use: text names ${fabricated.join(", ")} but the run invoked ${this.invokedTools.size ? [...this.invokedTools].join(", ") : "nothing"} \u2014 demanding the real call`);
4533
4876
  this.transcript.push({
4534
4877
  role: "user",
4535
4878
  content: `You referred to ${fabricated.map((n) => `\`${n}\``).join(", ")} in your reply, but you never actually called ${fabricated.length > 1 ? "those tools" : "that tool"} \u2014 no tool call was made, so any result you reported for ${fabricated.length > 1 ? "them" : "it"} was invented. Either call ${fabricated.length > 1 ? "them" : "it"} for real now, or answer without ${fabricated.length > 1 ? "them" : "it"} and do NOT claim any outcome \u2014 including a failure or a timeout \u2014 for a call you did not make.`
@@ -4538,15 +4881,18 @@ var Agent = class _Agent {
4538
4881
  }
4539
4882
  if (o.closeDelegatedTurns && res.endedWithoutClosing && !closureNudged && canNudge()) {
4540
4883
  closureNudged = true;
4541
- log6.verbose("delegated turn went silent after a tool \u2014 nudging a closing answer");
4884
+ log7.verbose("delegated turn went silent after a tool \u2014 nudging a closing answer");
4542
4885
  this.transcript.push({
4543
4886
  role: "user",
4544
4887
  content: "You ran tools but did not reply. Answer the user now using the tool results above; if a tool failed, say so plainly. Do NOT re-run those tools, do NOT narrate what you did, and do NOT ask for permission to continue."
4545
4888
  });
4546
4889
  continue;
4547
4890
  }
4548
- log6.verbose(`completed in ${steps} step(s)`);
4549
- await this.ctx.jobs?.drain();
4891
+ log7.verbose(`completed in ${steps} step(s)`);
4892
+ const drained = await this.jobs.drain(o.signal);
4893
+ if (o.signal?.aborted) return kill("aborted");
4894
+ if (drained.length) lastProgressAt = Date.now();
4895
+ if (this.drainInjections()) continue;
4550
4896
  this.releaseStaleImages();
4551
4897
  await this.activeHooks?.onStop?.(res.content ?? "");
4552
4898
  const claims = this.survivingClaims();
@@ -4572,11 +4918,12 @@ var Agent = class _Agent {
4572
4918
  }
4573
4919
  this.transcript.push({ role: "tool", tool_call_id: tc.id, name: tc.function.name, content });
4574
4920
  }
4921
+ lastProgressAt = Date.now();
4575
4922
  if (this.ctx.needsInput) {
4576
4923
  const question = this.ctx.needsInput;
4577
4924
  this.ctx.needsInput = void 0;
4578
- log6.warn(`needs_input: ${question.question} (steps=${steps})`);
4579
- await this.ctx.jobs?.drain();
4925
+ log7.warn(`needs_input: ${question.question} (steps=${steps})`);
4926
+ await this.jobs.drain();
4580
4927
  const text = [
4581
4928
  `OPEN QUESTION (run paused \u2014 no human was reachable): ${question.question}`,
4582
4929
  ...question.options.map((o2) => ` \u2022 ${o2.label}${o2.description ? ` \u2014 ${o2.description}` : ""}`)
@@ -4615,6 +4962,8 @@ var Agent = class _Agent {
4615
4962
  if (timer) clearTimeout(timer);
4616
4963
  timer = setTimeout(() => {
4617
4964
  if (this.toolHoldActive()) return poke();
4965
+ const inFlight = this.jobs.list().filter((j) => j.status === "running");
4966
+ log7.warn(`stall watchdog: no stream output for ${stallMs}ms and no tool holding the stream \u2014 aborting the request` + (inFlight.length ? ` (${inFlight.length} detached job(s) running, not awaited by the stream: ${inFlight.map((j) => `${j.id}/${j.kind}`).join(", ")})` : ""));
4618
4967
  fired = true;
4619
4968
  ac.abort(stallError());
4620
4969
  }, stallMs);
@@ -4634,12 +4983,11 @@ var Agent = class _Agent {
4634
4983
  reclassify: (err) => stalled() ? stallError() : err
4635
4984
  };
4636
4985
  }
4637
- /** A host or native delegated tool is running within its deadline — the provider is quiet, not stalled. */
4986
+ /** A host or native delegated tool is running and not stuck — the provider is quiet, not stalled. The reap runs
4987
+ * FIRST, so a holder past its deadline is released (logged with evidence) instead of holding forever. */
4638
4988
  toolHoldActive() {
4639
- const now3 = Date.now();
4640
- if (this.hostToolsInFlight > 0 && now3 < this.hostToolHoldUntil) return true;
4641
- for (const until of this.nativeToolsInFlight.values()) if (now3 < until) return true;
4642
- return false;
4989
+ this.jobs.reap();
4990
+ return this.jobs.holding().length > 0;
4643
4991
  }
4644
4992
  /** A clean-stop turn that carries no assistant text AND no tool calls (native or delegated) — a
4645
4993
  * non-result the model produced by going silent. Callers retry it rather than report "done". */
@@ -4679,10 +5027,16 @@ var Agent = class _Agent {
4679
5027
  if (a.output !== void 0) prev.output = a.output;
4680
5028
  prev.status = a.status;
4681
5029
  }
5030
+ const nj = a.id ? this.nativeJobs.get(a.id) : void 0;
4682
5031
  if (a.status === "running") {
4683
- if (a.id) this.nativeToolsInFlight.set(a.id, Date.now() + this.toolHoldMs);
5032
+ if (nj) nj.touch();
5033
+ else if (a.id) this.nativeJobs.set(a.id, this.jobs.track({ kind: "native", label: a.name, holds: true, hardCapMs: this.toolHoldMs }));
4684
5034
  } else {
4685
- if (a.id) this.nativeToolsInFlight.delete(a.id);
5035
+ if (nj) {
5036
+ if (typeof a.output === "string") nj.chunk(a.output.slice(-4e3));
5037
+ nj.finish(a.status === "error" ? "error" : "done");
5038
+ this.nativeJobs.delete(a.id);
5039
+ }
4686
5040
  this.toolRanThisAttempt = true;
4687
5041
  }
4688
5042
  sawTool = true;
@@ -4773,13 +5127,13 @@ var Agent = class _Agent {
4773
5127
  const decision = await this.park(Promise.resolve(hooks?.preToolUse?.(call, meta)));
4774
5128
  if (decision?.block) {
4775
5129
  const blocked = `Blocked by hook: ${decision.reason ?? "no reason given"}`;
4776
- log6.debug(`${tc.function.name} -> ${blocked}`);
5130
+ log7.debug(`${tc.function.name} -> ${blocked}`);
4777
5131
  await hooks?.postToolUse?.(call, blocked, meta);
4778
5132
  return blocked;
4779
5133
  }
4780
5134
  this.options.host?.notify?.({ kind: "tool_use", id: tc.id ?? "", name: tc.function.name, input: args });
4781
5135
  if (earlyError) {
4782
- log6.debug(`${tc.function.name} -> ${earlyError}`);
5136
+ log7.debug(`${tc.function.name} -> ${earlyError}`);
4783
5137
  await hooks?.postToolUse?.(call, earlyError, meta);
4784
5138
  this.options.host?.notify?.({ kind: "tool_result", id: tc.id ?? "", output: earlyError, isError: true });
4785
5139
  return earlyError;
@@ -4788,12 +5142,12 @@ var Agent = class _Agent {
4788
5142
  let images;
4789
5143
  let threw = false;
4790
5144
  try {
4791
- log6.debug(`${tc.function.name}(${tc.function.arguments})`);
5145
+ log7.debug(`${tc.function.name}(${tc.function.arguments})`);
4792
5146
  this.ctx.emit = hooks?.onToolOutput ? (chunk) => {
4793
5147
  try {
4794
5148
  hooks.onToolOutput(call, chunk, meta);
4795
5149
  } catch (e) {
4796
- log6.debug(`onToolOutput hook error: ${e}`);
5150
+ log7.debug(`onToolOutput hook error: ${e}`);
4797
5151
  }
4798
5152
  } : void 0;
4799
5153
  const raw = await tool.run(args, this.ctx);
@@ -4805,7 +5159,7 @@ var Agent = class _Agent {
4805
5159
  }
4806
5160
  } catch (e) {
4807
5161
  const msg = e instanceof Error ? e.message : String(e);
4808
- log6.debug(`${tc.function.name} -> error: ${msg}`);
5162
+ log7.debug(`${tc.function.name} -> error: ${msg}`);
4809
5163
  result = `Error: ${msg}`;
4810
5164
  threw = true;
4811
5165
  } finally {
@@ -4897,6 +5251,7 @@ function estimateTokens(m, keepRecentImages = 1) {
4897
5251
  return Math.ceil(sendBytes(m, keepRecentImages) / 4);
4898
5252
  }
4899
5253
  var TRANSPORT_TOOL_TIMEOUT_FLOOR_MS = 18e5;
5254
+ var isMcpTool = (t) => t.name.startsWith("mcp__") || t.name === "McpCall";
4900
5255
  function transportHeadroomMs(t) {
4901
5256
  const d = Number(t.maxDurationMs) || 0;
4902
5257
  if (!d) return 0;
@@ -5072,7 +5427,7 @@ function fitTokenBudget(messages, maxTokens, keepRecentImages = 1) {
5072
5427
  const ids = callIdSet(messages.slice(from));
5073
5428
  while (from < messages.length && messages[from].role === "tool" && !ids.has(messages[from].tool_call_id ?? "")) total -= per[from++];
5074
5429
  if (total > maxTokens)
5075
- log6.warn(`context ~${total} tok still over maxContextTokens=${maxTokens} after trimming (system head can't be dropped)`);
5430
+ log7.warn(`context ~${total} tok still over maxContextTokens=${maxTokens} after trimming (system head can't be dropped)`);
5076
5431
  return [...head, ...messages.slice(from)];
5077
5432
  }
5078
5433
  function compact(m, max, focus) {
@@ -5468,6 +5823,7 @@ var toolCall = (id, name, args) => ({
5468
5823
 
5469
5824
  // src/index.ts
5470
5825
  init_tools();
5826
+ init_tools_jobs();
5471
5827
 
5472
5828
  // src/scheduler.ts
5473
5829
  function parseCronField(field, min, max) {
@@ -5742,7 +6098,7 @@ async function fullAgentOptions(opts = {}) {
5742
6098
  const registry = jobs ?? new ShellJobRegistry2({ cwd, killOnExit: true });
5743
6099
  if (onJobExit) registry.setOnExit((n) => onJobExit(formatJobExit2(n)));
5744
6100
  const shell = [makeRealShellTool2({ cwd, registry }), ...makeShellJobTools2(registry)];
5745
- return { ...rest, tools: [...tools ?? defaultTools(), ...shell] };
6101
+ return { ...rest, jobs: registry.jobs, tools: [...tools ?? defaultTools(), ...shell] };
5746
6102
  }
5747
6103
 
5748
6104
  // src/index.ts
@@ -5754,7 +6110,7 @@ init_tools_web();
5754
6110
  init_tools();
5755
6111
  init_tools_structured();
5756
6112
  init_logging();
5757
- var log8 = forComponent("scratch");
6113
+ var log9 = forComponent("scratch");
5758
6114
  var SCRATCH_DIR = "/scratch";
5759
6115
  function shortArgs(args) {
5760
6116
  try {
@@ -5794,7 +6150,7 @@ var Scratch = class {
5794
6150
  await (this.dirReady ??= mkdirp(this.fs, dir));
5795
6151
  await this.fs.writeFile(path, header + raw);
5796
6152
  } catch (e) {
5797
- log8.debug("scratch write failed; returning raw", e);
6153
+ log9.debug("scratch write failed; returning raw", e);
5798
6154
  return raw;
5799
6155
  }
5800
6156
  const preview = raw.slice(0, previewChars).replace(/\s+/g, " ").trim();
@@ -5824,7 +6180,7 @@ To pull a specific detail, Grep/Read ${path}, or call Ask({ question: "\u2026",
5824
6180
  await (this.dirReady ??= mkdirp(this.fs, dir));
5825
6181
  await this.fs.writeFile(path, header + full);
5826
6182
  } catch (e) {
5827
- log8.debug("scratch spill failed; cropping lossy", e);
6183
+ log9.debug("scratch spill failed; cropping lossy", e);
5828
6184
  return full.slice(0, pageBytes) + `
5829
6185
 
5830
6186
  [output cropped to ${pageBytes} of ${full.length} bytes; full output unavailable (scratch write failed) \u2014 refine your query]`;
@@ -5870,7 +6226,7 @@ Question: ${q}`);
5870
6226
  const answer = (res.text ?? "").trim();
5871
6227
  return answer || "(no answer found in scratch)";
5872
6228
  } catch (e) {
5873
- log8.debug("Ask peek failed", e);
6229
+ log9.debug("Ask peek failed", e);
5874
6230
  return `Error querying scratch: ${e?.message ?? e}`;
5875
6231
  }
5876
6232
  }
@@ -5879,7 +6235,7 @@ Question: ${q}`);
5879
6235
 
5880
6236
  // src/lessons.ts
5881
6237
  init_logging();
5882
- var log9 = forComponent("Lessons");
6238
+ var log10 = forComponent("Lessons");
5883
6239
  var LessonOptionsDefaults = class {
5884
6240
  minRepeats = 2;
5885
6241
  };
@@ -5904,8 +6260,8 @@ function lessonCapture(options) {
5904
6260
  counts.set(lesson.slug, n);
5905
6261
  if (n < o.minRepeats) return;
5906
6262
  written.add(lesson.slug);
5907
- await writeFact(o.fs, o.dir, lesson.slug, lesson.body).catch((e) => log9.warn(`could not persist ${lesson.slug}: ${e?.message ?? e}`));
5908
- log9.debug(`captured lesson ${lesson.slug} (recurred ${n}\xD7)`);
6263
+ await writeFact(o.fs, o.dir, lesson.slug, lesson.body).catch((e) => log10.warn(`could not persist ${lesson.slug}: ${e?.message ?? e}`));
6264
+ log10.debug(`captured lesson ${lesson.slug} (recurred ${n}\xD7)`);
5909
6265
  }
5910
6266
  };
5911
6267
  }
@@ -5913,7 +6269,7 @@ function lessonCapture(options) {
5913
6269
  // src/reflect.ts
5914
6270
  init_llm();
5915
6271
  init_logging();
5916
- var log10 = forComponent("Reflect");
6272
+ var log11 = forComponent("Reflect");
5917
6273
  async function reflectOnRun(o) {
5918
6274
  const digest = digestRun(o.result.messages, o.maxDigestChars ?? 6e3);
5919
6275
  if (!digest.trim()) return null;
@@ -5929,7 +6285,7 @@ If the run was fine or the issue was purely task-specific (not generalizable), r
5929
6285
  const r = await o.ai.chat({ model: o.model, messages: [{ role: "user", content: prompt }], stream: false });
5930
6286
  text = r?.content ?? "";
5931
6287
  } catch (e) {
5932
- log10.warn(`reflection call failed: ${e?.message ?? e}`);
6288
+ log11.warn(`reflection call failed: ${e?.message ?? e}`);
5933
6289
  return null;
5934
6290
  }
5935
6291
  const m = text.match(/LESSON:\s*(.+)/i);
@@ -5939,10 +6295,10 @@ If the run was fine or the issue was purely task-specific (not generalizable), r
5939
6295
  try {
5940
6296
  await writeFact(o.fs, o.dir, slug2, lesson);
5941
6297
  } catch (e) {
5942
- log10.warn(`could not persist lesson: ${e?.message ?? e}`);
6298
+ log11.warn(`could not persist lesson: ${e?.message ?? e}`);
5943
6299
  return null;
5944
6300
  }
5945
- log10.debug(`reflection persisted ${slug2}`);
6301
+ log11.debug(`reflection persisted ${slug2}`);
5946
6302
  return slug2;
5947
6303
  }
5948
6304
  function digestRun(messages, maxChars) {
@@ -5991,7 +6347,7 @@ function forComponent2(name) {
5991
6347
  }
5992
6348
 
5993
6349
  // node_modules/@bod.ee/voice/src/core/emotion.ts
5994
- var log11 = forComponent2("Emotion");
6350
+ var log12 = forComponent2("Emotion");
5995
6351
  var EMOTIONS = [
5996
6352
  // primary (best results)
5997
6353
  "neutral",
@@ -6145,7 +6501,7 @@ var EmotionStream = class {
6145
6501
  if (tag) {
6146
6502
  this.pending = tag;
6147
6503
  if (this.show) display += m[0];
6148
- } else log11.debug(`dropping unknown emotion tag ${m[0]}`);
6504
+ } else log12.debug(`dropping unknown emotion tag ${m[0]}`);
6149
6505
  last = m.index + m[0].length;
6150
6506
  }
6151
6507
  this.emit(body.slice(last), (s, d, p) => {
@@ -6168,7 +6524,7 @@ var EmotionStream = class {
6168
6524
  };
6169
6525
 
6170
6526
  // node_modules/@bod.ee/voice/src/core/engine.ts
6171
- var log12 = forComponent2("VoiceEngine");
6527
+ var log13 = forComponent2("VoiceEngine");
6172
6528
  var realClock = {
6173
6529
  now: () => performance.now(),
6174
6530
  setTimeout: (fn, ms) => setTimeout(fn, ms),
@@ -6443,7 +6799,7 @@ var VoiceEngine = class _VoiceEngine {
6443
6799
  await Promise.all([this.tts.connect(), this.stt.start()]);
6444
6800
  this.tts.warmup?.();
6445
6801
  this.setState("listening");
6446
- log12.debug(`voice I/O up (${this.stt.usingAec ? "AEC" : "heuristic echo"} capture)`);
6802
+ log13.debug(`voice I/O up (${this.stt.usingAec ? "AEC" : "heuristic echo"} capture)`);
6447
6803
  }
6448
6804
  get usingAec() {
6449
6805
  return this.stt.usingAec;
@@ -6465,7 +6821,7 @@ var VoiceEngine = class _VoiceEngine {
6465
6821
  this.options.onDiag({ t: this.clock.now(), kind, ...fields });
6466
6822
  } catch (e) {
6467
6823
  this.diagOn = false;
6468
- log12.debug(`onDiag threw \u2014 diagnostics disabled: ${e instanceof Error ? e.message : e}`);
6824
+ log13.debug(`onDiag threw \u2014 diagnostics disabled: ${e instanceof Error ? e.message : e}`);
6469
6825
  }
6470
6826
  }
6471
6827
  idleWaiters = [];
@@ -6574,7 +6930,7 @@ var VoiceEngine = class _VoiceEngine {
6574
6930
  if (prose.trim()) this.repliedSinceDispatch = true;
6575
6931
  for (const w of this.words(this.reply)) this.echoWords.add(w);
6576
6932
  this.tts.speak(forSpeech(speech), true);
6577
- if (!this.spokeDeltas && this.turnStartAt) log12.debug(`ttft: ${Math.round(this.clock.now() - this.turnStartAt)}ms`);
6933
+ if (!this.spokeDeltas && this.turnStartAt) log13.debug(`ttft: ${Math.round(this.clock.now() - this.turnStartAt)}ms`);
6578
6934
  this.spokeDeltas = true;
6579
6935
  this.setState("speaking");
6580
6936
  if (this.options.revealMode !== "off" && display) {
@@ -6651,7 +7007,7 @@ var VoiceEngine = class _VoiceEngine {
6651
7007
  this.drainTimer = null;
6652
7008
  this.speaking = false;
6653
7009
  this.stopWordReveal(true);
6654
- if (this.turnStartAt) log12.debug(`turn: ${Math.round(this.clock.now() - this.turnStartAt)}ms (incl. playback)`);
7010
+ if (this.turnStartAt) log13.debug(`turn: ${Math.round(this.clock.now() - this.turnStartAt)}ms (incl. playback)`);
6655
7011
  this.echoUntil = this.clock.now() + 2500;
6656
7012
  if (!this.usingAec) this.stt.reset();
6657
7013
  this.setState("listening");
@@ -6739,7 +7095,7 @@ var VoiceEngine = class _VoiceEngine {
6739
7095
  this.clearAckTimer("interrupt");
6740
7096
  this.adaptiveAckFired = false;
6741
7097
  this.ackProbe = null;
6742
- if (this.uttQueue.length) log12.info(`barge-in dropped ${this.uttQueue.length} queued worker utterance(s)`);
7098
+ if (this.uttQueue.length) log13.info(`barge-in dropped ${this.uttQueue.length} queued worker utterance(s)`);
6743
7099
  const droppedQueued = this.uttQueue.length;
6744
7100
  this.uttQueue = [];
6745
7101
  if (!this.speaking && !this.drainTimer) return;
@@ -6885,7 +7241,7 @@ var VoiceEngine = class _VoiceEngine {
6885
7241
  if (flat && this.lastDispatchFlat && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS && (this.lastDispatchFlat.startsWith(flat) || flat.startsWith(this.lastDispatchFlat))) return;
6886
7242
  this.specSpent = true;
6887
7243
  this.specText = this.specPartial;
6888
- log12.debug(`speculate: "${this.specText.slice(0, 60)}"`);
7244
+ log13.debug(`speculate: "${this.specText.slice(0, 60)}"`);
6889
7245
  this.diag("speculate", { text: this.specText });
6890
7246
  this.options.onSpeculate(this.specText);
6891
7247
  }, this.options.speculativeMs);
@@ -6954,7 +7310,7 @@ var VoiceEngine = class _VoiceEngine {
6954
7310
  };
6955
7311
  this.tts.newContext();
6956
7312
  this.tts.speak(phrase, false);
6957
- log12.debug(`backchannel: "${phrase}"`);
7313
+ log13.debug(`backchannel: "${phrase}"`);
6958
7314
  this.diag("backchannel", { phrase });
6959
7315
  this.options.onBackchannel(phrase);
6960
7316
  }
@@ -6987,7 +7343,7 @@ var VoiceEngine = class _VoiceEngine {
6987
7343
  });
6988
7344
  if (idx < 0) return text;
6989
7345
  toks.splice(idx, 1);
6990
- log12.verbose(`stripped backchannel echo "${this.lastBcPhrase}" from dispatching final`);
7346
+ log13.verbose(`stripped backchannel echo "${this.lastBcPhrase}" from dispatching final`);
6991
7347
  return toks.join(" ");
6992
7348
  }
6993
7349
  /** Merge a resumed utterance into the pending one, deduping any word-overlap. Soniox re-finalizes
@@ -7078,7 +7434,7 @@ var VoiceEngine = class _VoiceEngine {
7078
7434
  this.pendingUtt = this.mergeUtterance(this.pendingUtt, text);
7079
7435
  if (this.pendingTimer) this.clock.clearTimeout(this.pendingTimer);
7080
7436
  if (this.options.incompleteMergeMs && this.looksIncomplete(this.pendingUtt)) {
7081
- log12.verbose(`hold: incomplete utterance "${this.pendingUtt.slice(-40)}"`);
7437
+ log13.verbose(`hold: incomplete utterance "${this.pendingUtt.slice(-40)}"`);
7082
7438
  this.diag("hold", { reason: "incomplete", tail: this.pendingUtt.slice(-40) });
7083
7439
  this.options.onHold();
7084
7440
  if (this.options.holdFiller && !this.speaking) this.speakFiller(this.options.holdFiller);
@@ -7113,13 +7469,13 @@ var VoiceEngine = class _VoiceEngine {
7113
7469
  if (text) {
7114
7470
  const flat = text.toLowerCase().replace(/[^a-z0-9]/g, "");
7115
7471
  if (flat && flat === this.lastDispatchFlat && !this.repliedSinceDispatch && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS) {
7116
- log12.verbose(`dropped duplicate final "${text.slice(0, 40)}" (identical to the just-dispatched utterance, no reply between)`);
7472
+ log13.verbose(`dropped duplicate final "${text.slice(0, 40)}" (identical to the just-dispatched utterance, no reply between)`);
7117
7473
  this.diag("utterance_drop", { reason: "dup_final", text: text.slice(0, 60) });
7118
7474
  return;
7119
7475
  }
7120
7476
  const refinal = this.refinalizes(text);
7121
7477
  if (refinal && this.repliedSinceDispatch) {
7122
- log12.verbose(`dropped re-finalization "${text.slice(0, 60)}" (superset of the just-dispatched utterance, reply already flowing)`);
7478
+ log13.verbose(`dropped re-finalization "${text.slice(0, 60)}" (superset of the just-dispatched utterance, reply already flowing)`);
7123
7479
  this.diag("utterance_drop", { reason: "refinalized", text: text.slice(0, 60) });
7124
7480
  this.lastDispatchFlat = flat;
7125
7481
  this.lastDispatchWords = text.trim().split(/\s+/);
@@ -7279,7 +7635,7 @@ var SpokenSplitter = class {
7279
7635
  };
7280
7636
 
7281
7637
  // node_modules/@bod.ee/voice/src/adapters/soniox.ts
7282
- var log13 = forComponent2("SonioxSTT");
7638
+ var log14 = forComponent2("SonioxSTT");
7283
7639
  var now = () => performance.now();
7284
7640
  var SonioxSTTOptions = class {
7285
7641
  auth = "";
@@ -7320,7 +7676,7 @@ var SonioxSTT = class {
7320
7676
  this.onDiag({ t: now(), kind, ...fields });
7321
7677
  } catch (e) {
7322
7678
  this.diagOn = false;
7323
- log13.debug(`onDiag threw \u2014 STT diagnostics disabled: ${e instanceof Error ? e.message : e}`);
7679
+ log14.debug(`onDiag threw \u2014 STT diagnostics disabled: ${e instanceof Error ? e.message : e}`);
7324
7680
  }
7325
7681
  }
7326
7682
  lastChunkAt = 0;
@@ -7362,11 +7718,11 @@ var SonioxSTT = class {
7362
7718
  this.ws.onmessage = (ev) => this.handle(JSON.parse(String(ev.data)));
7363
7719
  this.ws.onclose = (ev) => {
7364
7720
  if (this.stopped) return;
7365
- log13.warn(`soniox ws closed (${ev.code} ${ev.reason || ""}) \u2014 reconnecting`);
7721
+ log14.warn(`soniox ws closed (${ev.code} ${ev.reason || ""}) \u2014 reconnecting`);
7366
7722
  this.diag("stt_ws_closed", { code: ev.code, reason: String(ev.reason || ""), reconnecting: true });
7367
7723
  this.reset();
7368
7724
  this.connectWs().catch((e) => {
7369
- log13.error(`soniox reconnect failed: ${e.message}`);
7725
+ log14.error(`soniox reconnect failed: ${e.message}`);
7370
7726
  this.diag("stt_reconnect_failed", { message: e.message });
7371
7727
  });
7372
7728
  };
@@ -7378,7 +7734,7 @@ var SonioxSTT = class {
7378
7734
  this.endpointTimer = setInterval(() => {
7379
7735
  const combined = (this.finalText + this.partialText).trim();
7380
7736
  if (!combined || now() - this.lastChangeAt < this.options.silenceEndpointMs) return;
7381
- if (this.firstTokenAt) log13.debug(`stt: ${Math.round(now() - this.firstTokenAt)}ms first-token\u2192silence-endpoint, "${combined.slice(0, 60)}"`);
7737
+ if (this.firstTokenAt) log14.debug(`stt: ${Math.round(now() - this.firstTokenAt)}ms first-token\u2192silence-endpoint, "${combined.slice(0, 60)}"`);
7382
7738
  this.reset();
7383
7739
  this.onUtterance(combined, now());
7384
7740
  }, 120);
@@ -7390,7 +7746,7 @@ var SonioxSTT = class {
7390
7746
  if (this.stopped) return;
7391
7747
  const ref = this.lastChunkAt || this.startedChunksAt;
7392
7748
  if (now() - ref > noAudioMs) {
7393
- log13.error(`stt: no mic audio for >${Math.round(noAudioMs / 1e3)}s \u2014 capture device stopped delivering`);
7749
+ log14.error(`stt: no mic audio for >${Math.round(noAudioMs / 1e3)}s \u2014 capture device stopped delivering`);
7394
7750
  this.diag("stt_watchdog_fatal", { noAudioMs });
7395
7751
  this.onFatal("microphone stopped delivering audio (try a different input device, e.g. AirPods, or check System Settings \u2192 Sound \u2192 Input)");
7396
7752
  this.stop();
@@ -7413,7 +7769,7 @@ var SonioxSTT = class {
7413
7769
  handle(m) {
7414
7770
  if (m.error_message) {
7415
7771
  this.diag("stt_error", { message: String(m.error_message), code: m.error_code });
7416
- return log13.error(`soniox: ${m.error_message}`);
7772
+ return log14.error(`soniox: ${m.error_message}`);
7417
7773
  }
7418
7774
  let endpoint = false;
7419
7775
  for (const t of m.tokens ?? []) {
@@ -7430,7 +7786,7 @@ var SonioxSTT = class {
7430
7786
  this.onPartial(combined);
7431
7787
  if (endpoint && this.finalText.trim()) {
7432
7788
  const utterance = this.finalText.trim();
7433
- if (this.firstTokenAt) log13.debug(`stt: ${Math.round(now() - this.firstTokenAt)}ms first-token\u2192endpoint, "${utterance.slice(0, 60)}"`);
7789
+ if (this.firstTokenAt) log14.debug(`stt: ${Math.round(now() - this.firstTokenAt)}ms first-token\u2192endpoint, "${utterance.slice(0, 60)}"`);
7434
7790
  this.reset();
7435
7791
  this.onUtterance(utterance, now());
7436
7792
  }
@@ -7452,7 +7808,7 @@ var SonioxSTT = class {
7452
7808
  };
7453
7809
 
7454
7810
  // node_modules/@bod.ee/voice/src/adapters/cartesia.ts
7455
- var log14 = forComponent2("CartesiaTTS");
7811
+ var log15 = forComponent2("CartesiaTTS");
7456
7812
  var now2 = () => performance.now();
7457
7813
  var CartesiaTTSOptions = class {
7458
7814
  auth = "";
@@ -7488,7 +7844,7 @@ var CartesiaTTS = class _CartesiaTTS {
7488
7844
  this.onDiag({ t: now2(), kind, ...fields });
7489
7845
  } catch (e) {
7490
7846
  this.diagOn = false;
7491
- log14.debug(`onDiag threw \u2014 TTS diagnostics disabled: ${e instanceof Error ? e.message : e}`);
7847
+ log15.debug(`onDiag threw \u2014 TTS diagnostics disabled: ${e instanceof Error ? e.message : e}`);
7492
7848
  }
7493
7849
  }
7494
7850
  firstAudioAt = 0;
@@ -7523,11 +7879,11 @@ var CartesiaTTS = class _CartesiaTTS {
7523
7879
  this.ws.onerror = (e) => rej(new Error(`cartesia ws: ${e.message || "connect failed"}`));
7524
7880
  });
7525
7881
  this.ws.onclose = (ev) => {
7526
- log14.warn(`cartesia ws closed (${ev.code} ${ev.reason || ""})`);
7882
+ log15.warn(`cartesia ws closed (${ev.code} ${ev.reason || ""})`);
7527
7883
  this.diag("tts_ws_closed", { code: ev.code, reason: String(ev.reason || ""), reconnecting: !this.closed });
7528
7884
  if (!this.closed) {
7529
7885
  this.connecting = this.doConnect().catch((e) => {
7530
- log14.error(`cartesia reconnect failed: ${e.message}`);
7886
+ log15.error(`cartesia reconnect failed: ${e.message}`);
7531
7887
  this.diag("tts_reconnect_failed", { message: e.message });
7532
7888
  });
7533
7889
  }
@@ -7555,12 +7911,12 @@ var CartesiaTTS = class _CartesiaTTS {
7555
7911
  this.down = true;
7556
7912
  this.downAt = now2();
7557
7913
  this.consecutiveOk = 0;
7558
- log14.warn(`TTS circuit breaker open \u2014 ${this.consecutiveErrors} consecutive errors, switching to text-only`);
7914
+ log15.warn(`TTS circuit breaker open \u2014 ${this.consecutiveErrors} consecutive errors, switching to text-only`);
7559
7915
  this.diag("tts_breaker_open", { errors: this.consecutiveErrors });
7560
7916
  this.onDone();
7561
7917
  this.startProbe();
7562
7918
  } else if (!this.down) {
7563
- (/No valid transcripts/i.test(m.message || "") ? log14.debug : log14.warn)(`cartesia: ${JSON.stringify(m)}`);
7919
+ (/No valid transcripts/i.test(m.message || "") ? log15.debug : log15.warn)(`cartesia: ${JSON.stringify(m)}`);
7564
7920
  }
7565
7921
  }
7566
7922
  };
@@ -7575,7 +7931,7 @@ var CartesiaTTS = class _CartesiaTTS {
7575
7931
  this.stopProbe();
7576
7932
  const downMs = this.downAt ? now2() - this.downAt : 0;
7577
7933
  this.diag("tts_breaker_close", { downMs: Math.round(downMs) });
7578
- (downMs < 2e3 ? log14.debug : log14.info)(`TTS recovered${downMs ? ` (down ${downMs}ms)` : ""}`);
7934
+ (downMs < 2e3 ? log15.debug : log15.info)(`TTS recovered${downMs ? ` (down ${downMs}ms)` : ""}`);
7579
7935
  }
7580
7936
  /** Ensure the WS is open before sending — reconnects if idle-closed. */
7581
7937
  async ensureConnected() {
@@ -7666,7 +8022,7 @@ configureLogging((name) => {
7666
8022
  });
7667
8023
 
7668
8024
  // src/duplex.ts
7669
- var log15 = forComponent("DuplexAgent");
8025
+ var log16 = forComponent("DuplexAgent");
7670
8026
  function describeCall(call) {
7671
8027
  const v = call.args && Object.values(call.args).find((x) => typeof x === "string" && x.trim());
7672
8028
  const hint = v ? ` (${String(v).replace(/\s+/g, " ").trim().slice(0, 48)})` : "";
@@ -7877,7 +8233,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
7877
8233
  const m = this.reflexBuf.match(RESERVED_EVENT_MARKER) ?? this.reflexBuf.match(RESERVED_EVENT_OPENER);
7878
8234
  if (m) {
7879
8235
  this.fabricationCut = true;
7880
- log15.warn(`reflex fabricated a [task \u2026] event in its spoken stream \u2014 cutting it (kept ${m.index} chars)`);
8236
+ log16.warn(`reflex fabricated a [task \u2026] event in its spoken stream \u2014 cutting it (kept ${m.index} chars)`);
7881
8237
  const safe = this.reflexBuf.slice(this.reflexForwarded, m.index);
7882
8238
  if (!safe) return;
7883
8239
  if (safe.trim()) this.spokeThisTurn = true;
@@ -7972,7 +8328,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
7972
8328
  return {
7973
8329
  preToolUse: (call) => {
7974
8330
  if (this.spec?.state === "pending" && !_DuplexAgent.SPEC_SAFE_TOOLS.has(call.name)) {
7975
- log15.verbose(`speculation aborted: reflex called ${call.name} on unconfirmed input`);
8331
+ log16.verbose(`speculation aborted: reflex called ${call.name} on unconfirmed input`);
7976
8332
  this.abortSpeculation();
7977
8333
  return { block: true, reason: "Speculative turn aborted." };
7978
8334
  }
@@ -8010,7 +8366,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8010
8366
  try {
8011
8367
  await this.voice.send(fallback ? "[reminder] You said nothing to the user this turn. Tell them, in ONE short spoken sentence, what just happened \u2014 no tools." : dispatched ? "[reminder] You dispatched a task but said nothing to the user. Say ONE short spoken acknowledgement now \u2014 no tools." : "[reminder] You said nothing to the user this turn. Give your ONE short spoken reply now \u2014 no tools.");
8012
8368
  } catch (e) {
8013
- log15.warn(`ack nudge failed: ${e instanceof Error ? e.message : e}`);
8369
+ log16.warn(`ack nudge failed: ${e instanceof Error ? e.message : e}`);
8014
8370
  } finally {
8015
8371
  this.nudging = false;
8016
8372
  }
@@ -8103,7 +8459,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8103
8459
  try {
8104
8460
  res = await this.voice.send(spec.text);
8105
8461
  } catch (e) {
8106
- log15.warn(`speculative turn failed: ${e instanceof Error ? e.message : e}`);
8462
+ log16.warn(`speculative turn failed: ${e instanceof Error ? e.message : e}`);
8107
8463
  } finally {
8108
8464
  this.voice.options.signal = prevSignal;
8109
8465
  }
@@ -8117,7 +8473,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8117
8473
  if (d === "abort") {
8118
8474
  if (this.voice.transcript.length > base) this.voice.transcript.length = base;
8119
8475
  this.speculativeAbortedCalls++;
8120
- log15.verbose(`speculation aborted (${this.speculativeAbortedCalls} total): "${spec.text.slice(0, 50)}"`);
8476
+ log16.verbose(`speculation aborted (${this.speculativeAbortedCalls} total): "${spec.text.slice(0, 50)}"`);
8121
8477
  if (this.spec === spec) this.spec = void 0;
8122
8478
  return res ?? empty;
8123
8479
  }
@@ -8382,7 +8738,7 @@ Another agent just implemented the above. Independently check the CURRENT state
8382
8738
  this.notify("task_verify", `task ${id}: verifying`, { id });
8383
8739
  const cres = await new Agent(checkerOpts).run(checkBrief);
8384
8740
  if (cres.finishReason !== "stop") {
8385
- log15.warn(`task ${id}: verify inconclusive (${cres.finishReason})`);
8741
+ log16.warn(`task ${id}: verify inconclusive (${cres.finishReason})`);
8386
8742
  this.notify("task_verify", `task ${id}: verify inconclusive (${cres.finishReason})`, { id, finishReason: cres.finishReason });
8387
8743
  }
8388
8744
  const sum = (a = 0, b = 0) => a + b;
@@ -8518,7 +8874,7 @@ Another agent just implemented the above. Independently check the CURRENT state
8518
8874
  rec.status = "done";
8519
8875
  rec.result = res.text;
8520
8876
  const incomplete = res.finishReason !== "stop" && res.finishReason !== "empty";
8521
- log15.verbose(`task ${id} done (${res.steps} steps${incomplete ? `, INCOMPLETE: ${res.finishReason}` : ""})`);
8877
+ log16.verbose(`task ${id} done (${res.steps} steps${incomplete ? `, INCOMPLETE: ${res.finishReason}` : ""})`);
8522
8878
  this.notify("task_done", `task ${id} (${rec.label}) completed`, {
8523
8879
  id,
8524
8880
  text: res.text,
@@ -8560,14 +8916,14 @@ Another agent just implemented the above. Independently check the CURRENT state
8560
8916
  foldIfSuperseded(rec, note) {
8561
8917
  if (!(rec.deliveryParked && this.suppressParkedResume)) return false;
8562
8918
  if (note.trim()) this.voice.transcript.push({ role: "assistant", content: note });
8563
- log15.verbose(`task ${rec.id} settled but superseded (${rec.status}) \u2014 folded into transcript, not voiced`);
8919
+ log16.verbose(`task ${rec.id} settled but superseded (${rec.status}) \u2014 folded into transcript, not voiced`);
8564
8920
  return true;
8565
8921
  }
8566
8922
  failTask(rec, msg) {
8567
8923
  this.dropAsk(rec.id);
8568
8924
  rec.status = "error";
8569
8925
  rec.result = msg;
8570
- log15.warn(`task ${rec.id} failed: ${msg}`);
8926
+ log16.warn(`task ${rec.id} failed: ${msg}`);
8571
8927
  this.notify("task_error", `task ${rec.id} (${rec.label}) failed: ${msg}`);
8572
8928
  if (this.foldIfSuperseded(rec, `The "${rec.label}" task failed: ${msg}`)) return;
8573
8929
  this.queueRevoice(this.integrationPrompt(rec, "error", msg, "error"), true);
@@ -8816,17 +9172,42 @@ async function withDeadline(p, ms) {
8816
9172
  }
8817
9173
  }
8818
9174
  var mcpTimedOut = (name, ms = MCP_CALL_TIMEOUT_MS) => `[${name}] no response after ${ms / 1e3}s \u2014 the MCP server never answered. The call's outcome is UNKNOWN: do not assume it ran or that it failed. Verify the state (or pick a bounded/narrower call) before retrying.`;
9175
+ async function runMcpCall(name, call, ctx, timeoutMs) {
9176
+ const jobs = ctx?.toolJobs;
9177
+ const shape = (raw) => {
9178
+ const r2 = toResult(raw);
9179
+ return r2.images?.length ? r2 : r2.text;
9180
+ };
9181
+ if (!jobs) {
9182
+ const raw = await withDeadline(call({}), timeoutMs);
9183
+ return raw === TIMED_OUT ? mcpTimedOut(name, timeoutMs) : shape(raw);
9184
+ }
9185
+ const r = await jobs.runForeground(async ({ signal, touch, onChunk }) => {
9186
+ const raw = await call({
9187
+ signal,
9188
+ onProgress: (p) => {
9189
+ touch();
9190
+ if (p?.message) onChunk(`[progress${p.progress != null ? ` ${p.progress}${p.total != null ? `/${p.total}` : ""}` : ""}] ${p.message}
9191
+ `);
9192
+ }
9193
+ });
9194
+ const res = toResult(raw);
9195
+ onChunk(res.text + (res.images?.length ? `
9196
+ [${res.images.length} image(s) in the result]` : ""));
9197
+ return res;
9198
+ }, { kind: "mcp", label: name, idleAfterActivity: true });
9199
+ if (r.done) return r.value.images?.length ? r.value : r.value.text;
9200
+ const soft = jobs.options.foregroundMs.mcp ?? 0;
9201
+ return `[still running] MCP call ${name} is still working after ${soft >= 1e3 ? `${Math.round(soft / 1e3)}s` : `${soft}ms`}, so it was handed to background job ${r.id} \u2014 NOT cancelled, the server keeps going. Its result will be delivered to you when it finishes, so continue with other work; to block on it use JobWait({id:"${r.id}"}) instead of sleeping. JobStatus/JobOutput show its progress; JobKill({id:"${r.id}"}) cancels it. Do NOT re-issue the same call \u2014 that would run it twice.`;
9202
+ }
8819
9203
  function mcpToolToAgentTool(spec, callTool, prefix = "mcp__", timeoutMs = MCP_CALL_TIMEOUT_MS) {
8820
9204
  return {
8821
9205
  name: `${prefix}${spec.name}`,
8822
9206
  description: spec.description ?? `MCP tool ${spec.name}`,
8823
9207
  parameters: spec.inputSchema ?? { type: "object", properties: {} },
8824
9208
  maxDurationMs: timeoutMs,
8825
- async run(args, _ctx) {
8826
- const raw = await withDeadline(callTool(spec.name, args ?? {}), timeoutMs);
8827
- if (raw === TIMED_OUT) return mcpTimedOut(spec.name, timeoutMs);
8828
- const r = toResult(raw);
8829
- return r.images?.length ? r : r.text;
9209
+ run(args, ctx) {
9210
+ return runMcpCall(spec.name, (o) => callTool(spec.name, args ?? {}, o), ctx, timeoutMs);
8830
9211
  }
8831
9212
  };
8832
9213
  }
@@ -8867,13 +9248,10 @@ function makeMcpToolSearch(specs, callTool, options = {}) {
8867
9248
  }
8868
9249
  },
8869
9250
  maxDurationMs: callTimeoutMs,
8870
- async run({ name, args }) {
9251
+ async run({ name, args }, ctx) {
8871
9252
  const n = String(name ?? "");
8872
9253
  if (!byName.has(n)) return `Error: unknown MCP tool '${n}'. Use ToolSearch to find valid names.`;
8873
- const raw = await withDeadline(callTool(n, args ?? {}), callTimeoutMs);
8874
- if (raw === TIMED_OUT) return mcpTimedOut(n, callTimeoutMs);
8875
- const r = toResult(raw);
8876
- return r.images?.length ? r : r.text;
9254
+ return runMcpCall(n, (o) => callTool(n, args ?? {}, o), ctx, callTimeoutMs);
8877
9255
  }
8878
9256
  };
8879
9257
  return [searchTool, callMcpTool];
@@ -8893,17 +9271,17 @@ function buildMcpCatalog(servers) {
8893
9271
  return { specs, routes };
8894
9272
  }
8895
9273
  function searchOverCatalog(servers, specs, routes, resolve2, options) {
8896
- const tools = specs.length ? makeMcpToolSearch(specs, (name, args) => {
9274
+ const tools = specs.length ? makeMcpToolSearch(specs, (name, args, o) => {
8897
9275
  const r = routes.get(name);
8898
9276
  if (!r) throw new Error(`unknown MCP tool '${name}' \u2014 use ToolSearch to find valid names`);
8899
- return resolve2(r.server, r.rawName, args ?? {});
9277
+ return resolve2(r.server, r.rawName, args ?? {}, o);
8900
9278
  }, options) : [];
8901
9279
  return { tools, serverNames: servers, toolCount: specs.length };
8902
9280
  }
8903
9281
  function makeMcpToolSearchFromMounted(mounted, options) {
8904
9282
  const { specs, routes } = buildMcpCatalog(mounted);
8905
9283
  const byName = new Map(mounted.map((m) => [m.name, m]));
8906
- return searchOverCatalog(mounted.map((m) => m.name), specs, routes, (server, rawName, args) => byName.get(server).client.callTool(rawName, args), options);
9284
+ return searchOverCatalog(mounted.map((m) => m.name), specs, routes, (server, rawName, args, o) => byName.get(server).client.callTool(rawName, args, o), options);
8907
9285
  }
8908
9286
  function makeLazyMcpToolSearch(servers, resolve2, options) {
8909
9287
  const { specs, routes } = buildMcpCatalog(servers);
@@ -8988,6 +9366,8 @@ export {
8988
9366
  IndexedDbFilesystem,
8989
9367
  JailOptions,
8990
9368
  JailedFilesystem,
9369
+ JobRegistry,
9370
+ JobRegistryOptions,
8991
9371
  LessonOptionsDefaults,
8992
9372
  MEMORY_PROMPT,
8993
9373
  MODEL_ALIASES,
@@ -9042,6 +9422,7 @@ export {
9042
9422
  expandTemplate,
9043
9423
  findGitRoot,
9044
9424
  forComponent,
9425
+ formatJobNotice,
9045
9426
  fullAgentOptions,
9046
9427
  getOrCreateWorktree,
9047
9428
  getWorktreeSession,