@livx.cc/agentx 0.99.47 → 0.99.49

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,404 @@ 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
+ * NOTE the asymmetry that default hides: for a HOST job silence really is uninformative (the caller holds
1584
+ * the pending dispatch promise — independent proof it is alive), but a NATIVE job has no such proof AND it
1585
+ * suppresses the stall watchdog, i.e. it turns the provider's own silence into permission to keep ignoring
1586
+ * that provider's silence. So Agent passes an explicit per-job `idleMs` (nativeHoldMs) for `native`: the
1587
+ * provider must keep re-proving liveness with `running` events or forfeit the hold. See Agent.nativeHoldMs. */
1588
+ idleMs = { shell: 10 * 6e4, mcp: 10 * 6e4 };
1589
+ /** Absolute ceiling per kind (0/absent = none). */
1590
+ hardCapMs = { mcp: 30 * 6e4 };
1591
+ /** Soft foreground timeout per kind: past it the caller stops waiting and gets a job id instead. */
1592
+ foregroundMs = { mcp: 3e4 };
1593
+ /** How often the reaper runs while reapable jobs exist. */
1594
+ sweepMs = 15e3;
1595
+ /** Grace between the kill signal and a forced kill (producers that escalate read this). */
1596
+ killGraceMs = 5e3;
1597
+ /** Finished jobs kept for inspection; older finished ones are pruned (every delegated tool call is a job). */
1598
+ keepFinished = 200;
1599
+ /** Clock (injectable for tests). Late-bound so a patched Date.now is honoured. */
1600
+ now = () => Date.now();
1601
+ /** Terminal-state notification for jobs with `notify` (the Agent wires this to `inject`). */
1602
+ onExit;
1603
+ };
1604
+ secs = (ms) => `${Math.round(ms / 1e3)}s`;
1605
+ JobRegistry = class {
1606
+ options;
1607
+ jobs = /* @__PURE__ */ new Map();
1608
+ seq = 0;
1609
+ timer;
1610
+ constructor(options) {
1611
+ this.options = { ...new JobRegistryOptions(), ...options };
1612
+ }
1613
+ /** Register externally-driven work (a process, a request, a native tool) and get a handle to feed it. */
1614
+ track(opts = {}) {
1615
+ const o = this.options;
1616
+ const kind = opts.kind ?? "task";
1617
+ const now3 = o.now();
1618
+ let settle;
1619
+ const done = new Promise((r) => {
1620
+ settle = r;
1621
+ });
1622
+ const job = {
1623
+ id: `job-${++this.seq}`,
1624
+ kind,
1625
+ label: opts.label ?? "",
1626
+ status: "running",
1627
+ startedAt: now3,
1628
+ lastActivityAt: now3,
1629
+ bytes: 0,
1630
+ holds: !!opts.holds,
1631
+ buf: "",
1632
+ dropped: 0,
1633
+ idleMs: opts.idleMs ?? o.idleMs[kind] ?? 0,
1634
+ hardCapMs: opts.hardCapMs ?? o.hardCapMs[kind] ?? 0,
1635
+ drain: !!opts.drain,
1636
+ notify: !!opts.notify,
1637
+ idleAfterActivity: !!opts.idleAfterActivity,
1638
+ active: false,
1639
+ cpuTime: opts.cpuTime,
1640
+ onKill: opts.onKill,
1641
+ onExit: opts.onExit,
1642
+ done,
1643
+ settle
1644
+ };
1645
+ this.jobs.set(job.id, job);
1646
+ if (opts.seed) this.append(job, opts.seed);
1647
+ this.arm();
1648
+ return {
1649
+ id: job.id,
1650
+ chunk: (s) => {
1651
+ if (s) {
1652
+ this.append(job, s);
1653
+ job.lastActivityAt = o.now();
1654
+ job.active = true;
1655
+ }
1656
+ },
1657
+ touch: () => {
1658
+ job.lastActivityAt = o.now();
1659
+ job.active = true;
1660
+ },
1661
+ finish: (status, result, reason) => this.end(job, status, result, reason),
1662
+ detach: () => {
1663
+ job.holds = false;
1664
+ job.notify = true;
1665
+ job.drain = true;
1666
+ },
1667
+ get status() {
1668
+ return job.status;
1669
+ }
1670
+ };
1671
+ }
1672
+ /** Start `fn` in the background and return a job id immediately (sandbox/subagent semantics: drained at turn end). */
1673
+ start(fn, opts = {}) {
1674
+ return this.run(fn, { drain: true, ...opts }).handle.id;
1675
+ }
1676
+ /**
1677
+ * Run `fn` in the FOREGROUND with a soft timeout: resolves `{done:true,value}` if it settles in time, else
1678
+ * `{done:false,id}` — the work keeps going as a detached job whose completion is reported via `onExit`.
1679
+ * A rejection before the timeout is rethrown (the caller's normal error path). `softTimeoutMs` ≤ 0 waits forever.
1680
+ */
1681
+ async runForeground(fn, opts = {}) {
1682
+ const { handle, promise } = this.run(fn, opts);
1683
+ const soft = opts.softTimeoutMs ?? this.options.foregroundMs[opts.kind ?? "task"] ?? 0;
1684
+ let t;
1685
+ const late = soft > 0 ? new Promise((r) => {
1686
+ t = setTimeout(() => r("late"), soft);
1687
+ }) : new Promise(() => {
1688
+ });
1689
+ try {
1690
+ const r = await Promise.race([promise.then((v) => ({ v }), (e) => ({ e })), late]);
1691
+ if (r !== "late" && handle.status !== "running") this.jobs.delete(handle.id);
1692
+ if (r === "late") {
1693
+ if (handle.status === "running") {
1694
+ handle.detach();
1695
+ return { done: false, id: handle.id };
1696
+ }
1697
+ const settled = await promise.then((v) => ({ v }), (e) => ({ e }));
1698
+ if ("e" in settled) throw settled.e;
1699
+ return { done: true, value: settled.v };
1700
+ }
1701
+ if ("e" in r) throw r.e;
1702
+ return { done: true, value: r.v };
1703
+ } finally {
1704
+ if (t) clearTimeout(t);
1705
+ }
1706
+ }
1707
+ run(fn, opts) {
1708
+ const ctl = new AbortController();
1709
+ const handle = this.track({ ...opts, onKill: (reason) => {
1710
+ ctl.abort(Object.assign(new Error(reason), { name: "AbortError", code: "job_killed" }));
1711
+ opts.onKill?.(reason);
1712
+ } });
1713
+ let promise;
1714
+ try {
1715
+ promise = fn({ signal: ctl.signal, onChunk: handle.chunk, touch: handle.touch });
1716
+ } catch (e) {
1717
+ promise = Promise.reject(e);
1718
+ }
1719
+ promise.then(
1720
+ (res) => {
1721
+ if (handle.status === "running" && typeof res === "string" && res) handle.chunk(res);
1722
+ handle.finish("done", res);
1723
+ },
1724
+ (e) => {
1725
+ if (handle.status === "running") handle.chunk(`
1726
+ [error] ${e?.message ?? e}`);
1727
+ handle.finish("error", void 0, e?.message ?? String(e));
1728
+ }
1729
+ );
1730
+ return { handle, promise };
1731
+ }
1732
+ append(job, s) {
1733
+ job.buf += s;
1734
+ const over = job.buf.length - this.options.maxBuffer;
1735
+ if (over > 0) {
1736
+ job.buf = job.buf.slice(over);
1737
+ job.dropped += over;
1738
+ }
1739
+ job.bytes = job.buf.length;
1740
+ }
1741
+ end(job, status, result, reason) {
1742
+ if (job.status !== "running") return;
1743
+ job.status = status;
1744
+ job.endedAt = this.options.now();
1745
+ job.result = result;
1746
+ if (reason && status !== "done") job.reason = reason;
1747
+ job.holds = false;
1748
+ job.settle();
1749
+ this.prune();
1750
+ const onExit = job.onExit ?? this.options.onExit;
1751
+ if (job.notify && onExit) {
1752
+ try {
1753
+ onExit({ id: job.id, kind: job.kind, label: job.label, status, reason: job.reason, tail: job.buf.slice(-4e3), result });
1754
+ } catch (e) {
1755
+ log5.warn(`job onExit handler threw for ${job.id}: ${e}`);
1756
+ }
1757
+ }
1758
+ }
1759
+ prune() {
1760
+ const finished = [...this.jobs.values()].filter((j) => j.status !== "running");
1761
+ for (const j of finished.slice(0, Math.max(0, finished.length - this.options.keepFinished))) this.jobs.delete(j.id);
1762
+ }
1763
+ /** Current tail output (null = no such job). */
1764
+ output(id) {
1765
+ return this.jobs.get(id)?.buf ?? null;
1766
+ }
1767
+ /** Incremental read: output from absolute char `offset` (clamped to what the ring still holds). */
1768
+ read(id, offset = 0) {
1769
+ const j = this.jobs.get(id);
1770
+ if (!j) return null;
1771
+ const start = Math.max(0, offset - j.dropped);
1772
+ return { text: j.buf.slice(start), next: j.dropped + j.buf.length, truncated: offset < j.dropped };
1773
+ }
1774
+ get(id) {
1775
+ const j = this.jobs.get(id);
1776
+ return j ? this.info(j) : null;
1777
+ }
1778
+ status(id) {
1779
+ const j = this.jobs.get(id);
1780
+ return j ? { status: j.status, bytes: j.bytes, ...j.reason ? { reason: j.reason } : {} } : null;
1781
+ }
1782
+ list() {
1783
+ return [...this.jobs.values()].map((j) => this.info(j));
1784
+ }
1785
+ info(j) {
1786
+ const { id, kind, label, status, startedAt, lastActivityAt, endedAt, reason, bytes, holds, result } = j;
1787
+ return { id, kind, label, status, startedAt, lastActivityAt, endedAt, reason, bytes, holds, result };
1788
+ }
1789
+ /** Wait (bounded) for a job to settle. Resolves the job's info either way; null for an unknown id. */
1790
+ async wait(id, timeoutMs) {
1791
+ const j = this.jobs.get(id);
1792
+ if (!j) return null;
1793
+ let t;
1794
+ await Promise.race([j.done, new Promise((r) => {
1795
+ t = setTimeout(r, Math.max(0, timeoutMs));
1796
+ })]);
1797
+ if (t) clearTimeout(t);
1798
+ return this.info(j);
1799
+ }
1800
+ /** Stop a job: runs its `onKill` (abort/SIGTERM) and marks it killed with `reason`. */
1801
+ kill(id, reason = "killed by request") {
1802
+ const j = this.jobs.get(id);
1803
+ if (!j) return false;
1804
+ if (j.status === "running") {
1805
+ this.end(j, "killed", void 0, reason);
1806
+ try {
1807
+ j.onKill?.(reason);
1808
+ } catch (e) {
1809
+ log5.warn(`onKill for ${id} threw: ${e}`);
1810
+ }
1811
+ }
1812
+ return true;
1813
+ }
1814
+ /** Kill every running job — or, with `onlyDrainable`, only turn-scoped ones (sandbox/sub-agent/handed-off MCP),
1815
+ * leaving jobs meant to outlive a run (a background dev server) alone. */
1816
+ killAll(reason = "run ended", onlyDrainable = false) {
1817
+ for (const j of this.jobs.values()) if (!onlyDrainable || j.drain) this.kill(j.id, reason);
1818
+ }
1819
+ /** Jobs something is synchronously waiting on (see `JobTrackOptions.holds`) that are still running. */
1820
+ holding() {
1821
+ return [...this.jobs.values()].filter((j) => j.status === "running" && j.holds).map((j) => this.info(j));
1822
+ }
1823
+ /** Await every drainable running job (turn end). Returns the ids that were still running when drain began. */
1824
+ async drain(signal) {
1825
+ const pending = [...this.jobs.values()].filter((j) => j.status === "running" && j.drain);
1826
+ const all = Promise.all(pending.map((j) => j.done));
1827
+ await (signal ? Promise.race([all, new Promise((r) => signal.aborted ? r() : signal.addEventListener("abort", () => r(), { once: true }))]) : all);
1828
+ return pending.map((j) => j.id);
1829
+ }
1830
+ /**
1831
+ * The stuck-job decision, synchronous part: kill every running job past its hard cap, or past its idle
1832
+ * window when it has no CPU probe to argue otherwise. Cheap — the stall watchdog calls it on every tick.
1833
+ * Returns the killed ids. Jobs WITH a probe are left to `sweep()`.
1834
+ */
1835
+ reap() {
1836
+ const now3 = this.options.now();
1837
+ const killed = [];
1838
+ for (const j of this.jobs.values()) {
1839
+ if (j.status !== "running") continue;
1840
+ const verdict = this.verdict(j, now3);
1841
+ if (!verdict || verdict.idle && j.cpuTime) continue;
1842
+ this.stuck(j, verdict.reason, now3);
1843
+ killed.push(j.id);
1844
+ }
1845
+ return killed;
1846
+ }
1847
+ /** Full sweep: sample CPU probes (advancing CPU = activity), then `reap()` including probed jobs. */
1848
+ async sweep() {
1849
+ const now3 = this.options.now();
1850
+ for (const j of [...this.jobs.values()]) {
1851
+ if (j.status !== "running" || !j.cpuTime || !j.idleMs) continue;
1852
+ let cpu;
1853
+ try {
1854
+ cpu = await j.cpuTime();
1855
+ } catch (e) {
1856
+ log5.debug(`cpu probe for ${j.id} failed: ${e}`);
1857
+ }
1858
+ if (cpu != null && j.lastCpu != null && cpu > j.lastCpu) j.lastActivityAt = this.options.now();
1859
+ const prev = j.lastCpu;
1860
+ if (cpu != null) j.lastCpu = cpu;
1861
+ const verdict = this.verdict(j, now3);
1862
+ if (!verdict?.idle || j.status !== "running") continue;
1863
+ if (cpu == null) {
1864
+ log5.debug(`not reaping ${j.id}: ${verdict.reason} but CPU unknown`);
1865
+ continue;
1866
+ }
1867
+ if (prev == null) continue;
1868
+ this.stuck(j, `${verdict.reason}, no CPU progress (${prev}s \u2192 ${cpu}s)`, now3);
1869
+ }
1870
+ return this.reap();
1871
+ }
1872
+ verdict(j, now3) {
1873
+ if (j.hardCapMs && now3 - j.startedAt >= j.hardCapMs) return { reason: `stuck: exceeded hard cap ${secs(j.hardCapMs)}`, idle: false };
1874
+ if (j.idleMs && (!j.idleAfterActivity || j.active) && now3 - j.lastActivityAt >= j.idleMs) return { reason: `stuck: idle ${secs(now3 - j.lastActivityAt)}`, idle: true };
1875
+ return null;
1876
+ }
1877
+ stuck(j, reason, now3) {
1878
+ 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`);
1879
+ if (j.onKill) j.notify = true;
1880
+ this.kill(j.id, reason);
1881
+ }
1882
+ /** Keep a reaper interval alive only while a reapable job is running (unref'd: never holds the process open). */
1883
+ arm() {
1884
+ if (this.timer || !this.options.sweepMs) return;
1885
+ this.timer = setInterval(() => {
1886
+ const live = [...this.jobs.values()].some((j) => j.status === "running" && (j.idleMs || j.hardCapMs));
1887
+ if (!live) {
1888
+ clearInterval(this.timer);
1889
+ this.timer = void 0;
1890
+ return;
1891
+ }
1892
+ void this.sweep().catch((e) => log5.warn(`job sweep failed: ${e}`));
1893
+ }, this.options.sweepMs);
1894
+ this.timer.unref?.();
1895
+ }
1896
+ /** Stop the reaper timer (tests / teardown). */
1897
+ dispose() {
1898
+ if (this.timer) clearInterval(this.timer);
1899
+ this.timer = void 0;
1900
+ }
1901
+ };
1902
+ SandboxJobRegistry = class extends JobRegistry {
1903
+ constructor(maxBuffer) {
1904
+ super(typeof maxBuffer === "number" ? { maxBuffer } : maxBuffer);
1905
+ }
1906
+ };
1907
+ NO_JOB = (id) => `Error: no background job '${id}'. Use JobStatus with no id to list jobs.`;
1908
+ 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)`;
1909
+ }
1910
+ });
1911
+
1514
1912
  // src/NodeDiskFilesystem.ts
1515
1913
  var NodeDiskFilesystem_exports = {};
1516
1914
  __export(NodeDiskFilesystem_exports, {
@@ -1954,7 +2352,10 @@ __export(tools_shell_exports, {
1954
2352
  ShellJobRegistry: () => ShellJobRegistry,
1955
2353
  formatJobExit: () => formatJobExit,
1956
2354
  makeRealShellTool: () => makeRealShellTool,
1957
- makeShellJobTools: () => makeShellJobTools
2355
+ makeShellJobTools: () => makeShellJobTools,
2356
+ parsePsTime: () => parsePsTime,
2357
+ psGroupCpuSeconds: () => psGroupCpuSeconds,
2358
+ sumTreeCpu: () => sumTreeCpu
1958
2359
  });
1959
2360
  function killGroup(proc, signal) {
1960
2361
  if (!proc?.pid) return false;
@@ -1984,10 +2385,39 @@ async function nodeSpawn() {
1984
2385
  return _spawn;
1985
2386
  }
1986
2387
  function formatJobExit(n) {
1987
- return `[background job ${n.id} ${n.status}${n.exitCode != null ? ` exit ${n.exitCode}` : ""}] \`${n.command}\`
2388
+ return `[background job ${n.id} ${n.status}${n.exitCode != null ? ` exit ${n.exitCode}` : ""}${n.reason ? `: ${n.reason}` : ""}] \`${n.command}\`
1988
2389
  ` + (n.tail ? `${n.tail}
1989
2390
  ` : "(no output)\n") + `Read the full output with ShellOutput({id:"${n.id}"}).`;
1990
2391
  }
2392
+ function parsePsTime(t) {
2393
+ const m = t.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/);
2394
+ if (!m) return void 0;
2395
+ return Number(m[1] ?? 0) * 86400 + Number(m[2] ?? 0) * 3600 + Number(m[3]) * 60 + Number(m[4]);
2396
+ }
2397
+ async function psGroupCpuSeconds(pgid) {
2398
+ try {
2399
+ const { execFile } = await import("child_process");
2400
+ 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))));
2401
+ return sumTreeCpu(out, pgid);
2402
+ } catch (e) {
2403
+ log8.debug(`ps cpu probe failed for group ${pgid}: ${e}`);
2404
+ return void 0;
2405
+ }
2406
+ }
2407
+ function sumTreeCpu(psOut, root) {
2408
+ 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) }));
2409
+ const inJob = new Set(rows.filter((r) => r.pgid === root || r.pid === root).map((r) => r.pid));
2410
+ for (let grew = true; grew; ) {
2411
+ grew = false;
2412
+ for (const r of rows) if (!inJob.has(r.pid) && inJob.has(r.ppid)) {
2413
+ inJob.add(r.pid);
2414
+ grew = true;
2415
+ }
2416
+ }
2417
+ let total;
2418
+ for (const r of rows) if (inJob.has(r.pid) && r.sec != null) total = (total ?? 0) + r.sec;
2419
+ return total;
2420
+ }
1991
2421
  function makeRealShellTool(options) {
1992
2422
  const defaultTimeoutMs = options.timeoutMs ?? 12e4;
1993
2423
  const maxTimeoutMs = Math.max(options.maxTimeoutMs ?? 6e5, defaultTimeoutMs);
@@ -2118,7 +2548,7 @@ function makeRealShellTool(options) {
2118
2548
  proc.stderr?.on("data", collect);
2119
2549
  proc.on("error", (err) => {
2120
2550
  if (err?.name === "AbortError" || ctl.signal.aborted) return finish(reasonFor(timedOut, timeoutMs, clean(out)));
2121
- log7.debug("shell spawn error", err);
2551
+ log8.debug("shell spawn error", err);
2122
2552
  finish(`[exit 1] ${err?.message ?? err}${out ? "\n" + clean(out) : ""}`);
2123
2553
  });
2124
2554
  proc.on("close", (code) => {
@@ -2138,7 +2568,7 @@ function makeRealShellTool(options) {
2138
2568
  };
2139
2569
  }
2140
2570
  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.");
2571
+ 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
2572
  return body ? `${head}
2143
2573
  Output so far:
2144
2574
  ${body}` : head;
@@ -2162,7 +2592,7 @@ function makeShellJobTools(registry) {
2162
2592
  const out = registry.output(String(id));
2163
2593
  if (out == null) return NO_JOB2(String(id));
2164
2594
  const st = registry.status(String(id));
2165
- return `[${st.status}${st.exitCode != null ? ` exit ${st.exitCode}` : ""}]
2595
+ return `[${st.status}${st.exitCode != null ? ` exit ${st.exitCode}` : ""}${st.reason ? `: ${st.reason}` : ""}]
2166
2596
  ${clean(out) || "(no output yet)"}`;
2167
2597
  }
2168
2598
  },
@@ -2177,13 +2607,13 @@ ${clean(out) || "(no output yet)"}`;
2177
2607
  return jobs.length ? jobs.map((j) => `${j.id} ${j.status} ${j.command}`).join("\n") : "(no background jobs)";
2178
2608
  }
2179
2609
  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));
2610
+ 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
2611
  }
2182
2612
  },
2183
2613
  {
2184
2614
  ...dropOnRebind,
2185
2615
  name: "ShellKill",
2186
- description: "Stop a running background Shell job by id (SIGTERM).",
2616
+ description: "Stop a running background Shell job by id (SIGTERM to its process group, SIGKILL if it ignores that).",
2187
2617
  parameters: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
2188
2618
  async run({ id }) {
2189
2619
  return registry.kill(String(id)) ? `Killed job ${id}.` : NO_JOB2(String(id));
@@ -2191,7 +2621,7 @@ ${clean(out) || "(no output yet)"}`;
2191
2621
  }
2192
2622
  ];
2193
2623
  }
2194
- var log7, clean, DETACHED, SECRET_ENV_RE, _spawn, ShellJobRegistry, NO_JOB2;
2624
+ var log8, clean, DETACHED, SECRET_ENV_RE, _spawn, ShellJobRegistry, shellStatus, NO_JOB2;
2195
2625
  var init_tools_shell = __esm({
2196
2626
  "src/tools.shell.ts"() {
2197
2627
  "use strict";
@@ -2199,62 +2629,94 @@ var init_tools_shell = __esm({
2199
2629
  init_redact();
2200
2630
  init_logging();
2201
2631
  init_shell_sandbox();
2202
- log7 = forComponent("shell");
2632
+ init_tools_jobs();
2633
+ log8 = forComponent("shell");
2203
2634
  clean = (s) => truncateOutput(redactSecrets(s.replace(/\n+$/, "")));
2204
2635
  DETACHED = { stdio: ["ignore", "pipe", "pipe"], detached: true };
2205
2636
  SECRET_ENV_RE = /(_API_KEY|_TOKEN|_SECRET|_PASSWORD|_PRIVATE_KEY|^AWS_|^GITHUB_TOKEN$|^OPENAI_|^ANTHROPIC_|^GOOGLE_|^GEMINI_|^GROQ_|^NPM_TOKEN$)/i;
2206
2637
  ShellJobRegistry = class {
2207
2638
  constructor(cfg) {
2208
2639
  this.cfg = cfg;
2640
+ this.jobs = cfg.jobs ?? new JobRegistry(cfg.maxBuffer ? { maxBuffer: cfg.maxBuffer } : {});
2209
2641
  if (cfg.killOnExit && typeof process !== "undefined") process.once("exit", () => this.killAll());
2210
2642
  }
2211
2643
  cfg;
2212
- jobs = /* @__PURE__ */ new Map();
2213
- seq = 0;
2644
+ jobs;
2645
+ meta = /* @__PURE__ */ new Map();
2214
2646
  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
- };
2647
+ const h = this.track(command, { idleMs: 0 });
2222
2648
  try {
2223
2649
  const spawn = this.cfg.spawn ?? await nodeSpawn();
2224
2650
  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
- });
2651
+ this.attach(h, spawn(argv.bin, argv.args, { cwd: this.cfg.cwd, env: childEnv(this.cfg), ...DETACHED }));
2244
2652
  } catch (e) {
2245
- job.status = "error";
2246
- job.buf = `failed to spawn: ${e?.message ?? e}`;
2653
+ h.chunk(`failed to spawn: ${e?.message ?? e}`);
2654
+ h.finish("error", void 0, e?.message ?? String(e));
2655
+ }
2656
+ return h.id;
2657
+ }
2658
+ track(command, extra) {
2659
+ const meta = { command };
2660
+ let id = "";
2661
+ const h = this.jobs.track({
2662
+ kind: "shell",
2663
+ label: command,
2664
+ seed: extra.seed,
2665
+ idleMs: extra.idleMs,
2666
+ cpuTime: extra.cpu ? async () => meta.proc?.pid ? (this.cfg.cpuTime ?? psGroupCpuSeconds)(meta.proc.pid) : void 0 : void 0,
2667
+ onKill: () => this.signal(meta),
2668
+ onExit: (n) => this.notifyExit(id, meta, n.status, n.reason),
2669
+ notify: true
2670
+ });
2671
+ id = h.id;
2672
+ this.meta.set(id, meta);
2673
+ return h;
2674
+ }
2675
+ attach(h, proc) {
2676
+ const meta = this.meta.get(h.id);
2677
+ meta.proc = proc;
2678
+ const append = (chunk) => h.chunk(typeof chunk === "string" ? chunk : chunk?.toString?.("utf8") ?? "");
2679
+ proc.stdout?.on("data", append);
2680
+ proc.stderr?.on("data", append);
2681
+ proc.on("error", (err) => {
2682
+ if (h.status === "running") {
2683
+ append(`
2684
+ [error] ${err?.message ?? err}`);
2685
+ h.finish("error", void 0, err?.message ?? String(err));
2686
+ }
2687
+ });
2688
+ proc.on("close", (code) => {
2689
+ if (h.status === "running") {
2690
+ meta.exitCode = code ?? void 0;
2691
+ h.finish("done");
2692
+ }
2693
+ });
2694
+ }
2695
+ /** SIGTERM the whole group (bg children are detached group leaders — /bin/sh alone would orphan a forked
2696
+ * server), then SIGKILL after the grace period if it is still there. Falls back to the pid for fakes. */
2697
+ signal(meta) {
2698
+ const proc = meta.proc;
2699
+ if (!killGroup(proc, "SIGTERM")) {
2700
+ try {
2701
+ proc?.kill("SIGTERM");
2702
+ } catch {
2703
+ }
2247
2704
  }
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);
2705
+ if (!proc?.pid) return;
2706
+ let closed = false;
2707
+ proc.on("close", () => {
2708
+ closed = true;
2709
+ });
2710
+ const t = setTimeout(() => {
2711
+ if (!closed) killGroup(proc, "SIGKILL");
2712
+ }, this.jobs.options.killGraceMs);
2713
+ t.unref?.();
2714
+ }
2715
+ /** 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. */
2716
+ notifyExit(id, meta, status, reason) {
2717
+ if (!this.cfg.onExit || status === "killed" && !reason?.startsWith("stuck")) return;
2256
2718
  try {
2257
- this.cfg.onExit({ id, command: job.command, status: job.status, exitCode: job.exitCode, tail: clean(job.buf).slice(-4e3) });
2719
+ 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
2720
  } catch {
2259
2721
  }
2260
2722
  }
@@ -2273,14 +2735,20 @@ var init_tools_shell = __esm({
2273
2735
  }
2274
2736
  /** Current tail output for a job (null = no such job). */
2275
2737
  output(id) {
2276
- return this.jobs.get(id)?.buf ?? (this.jobs.has(id) ? "" : null);
2738
+ return this.meta.has(id) ? this.jobs.output(id) : null;
2277
2739
  }
2740
+ // null too once the shared registry pruned it
2278
2741
  status(id) {
2279
- const j = this.jobs.get(id);
2280
- return j ? { status: j.status, exitCode: j.exitCode, bytes: j.buf.length } : null;
2742
+ const m = this.meta.get(id);
2743
+ const j = m && this.jobs.get(id);
2744
+ if (!m || !j) {
2745
+ this.meta.delete(id);
2746
+ return null;
2747
+ }
2748
+ return { status: shellStatus(j.status), exitCode: m.exitCode, bytes: j.bytes, ...j.reason ? { reason: j.reason } : {} };
2281
2749
  }
2282
2750
  list() {
2283
- return [...this.jobs].map(([id, j]) => ({ id, command: j.command, status: j.status }));
2751
+ return [...this.meta].filter(([id]) => this.jobs.get(id)).map(([id, m]) => ({ id, command: m.command, status: shellStatus(this.jobs.get(id).status) }));
2284
2752
  }
2285
2753
  /**
2286
2754
  * Take over an ALREADY-RUNNING child as a background job. A foreground command that outruns its
@@ -2288,53 +2756,22 @@ var init_tools_shell = __esm({
2288
2756
  * worse, a launcher that spawned its own detached worker leaves that worker running with nothing
2289
2757
  * tracking it. Adopting hands the model a handle instead: the command keeps going, its completion
2290
2758
  * is reported like any other job, and `seed` carries the output produced before the handoff.
2759
+ * Unlike an explicit background job it IS liveness-checked (idle window + CPU probe): nobody asked for
2760
+ * a long-lived process here, so one that goes silent and CPU-idle is stuck, not serving.
2291
2761
  */
2292
2762
  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;
2763
+ const h = this.track(command, { seed, cpu: true });
2764
+ this.attach(h, proc);
2765
+ return h.id;
2319
2766
  }
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;
2767
+ kill(id, reason = "killed by ShellKill") {
2768
+ return this.meta.has(id) && this.jobs.kill(id, reason);
2333
2769
  }
2334
2770
  killAll() {
2335
- for (const id of this.jobs.keys()) this.kill(id);
2771
+ for (const id of this.meta.keys()) this.jobs.kill(id, "agent exiting");
2336
2772
  }
2337
2773
  };
2774
+ shellStatus = (s) => s === "done" ? "exited" : s === "running" ? "running" : s === "killed" ? "killed" : "error";
2338
2775
  NO_JOB2 = (id) => `Error: no background job '${id}'. Use ShellStatus with no id to list jobs, or start one with Shell({background:true}).`;
2339
2776
  }
2340
2777
  });
@@ -2342,111 +2779,7 @@ var init_tools_shell = __esm({
2342
2779
  // src/Agent.ts
2343
2780
  init_llm();
2344
2781
  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
- }
2782
+ init_tools_jobs();
2450
2783
 
2451
2784
  // src/host.ts
2452
2785
  var askUserQuestionTool = {
@@ -3229,7 +3562,7 @@ function exitWorktree() {
3229
3562
  // src/subagent.ts
3230
3563
  init_NodeDiskFilesystem();
3231
3564
  init_logging();
3232
- var log5 = forComponent("subagent");
3565
+ var log6 = forComponent("subagent");
3233
3566
  async function boundedPool(items, limit, fn) {
3234
3567
  const out = new Array(items.length);
3235
3568
  let next = 0;
@@ -3303,7 +3636,7 @@ function traceFileFor(dir, label, depth) {
3303
3636
  const id = `${Date.now().toString(36)}-${(traceSeq++).toString(36)}`;
3304
3637
  return join3(dir, `${id}-d${depth}-${sanitizeSlug(label) || "sub"}.json`);
3305
3638
  } catch (e) {
3306
- log5.warn(`subagent trace dir unavailable (${dir}) \u2014 running without a trace: ${e instanceof Error ? e.message : String(e)}`);
3639
+ log6.warn(`subagent trace dir unavailable (${dir}) \u2014 running without a trace: ${e instanceof Error ? e.message : String(e)}`);
3307
3640
  return void 0;
3308
3641
  }
3309
3642
  }
@@ -3311,7 +3644,7 @@ function flushTrace(file, meta, messages, status) {
3311
3644
  try {
3312
3645
  writeFileSync(file, JSON.stringify({ ...meta, status, updated: Date.now(), messages }, null, 2));
3313
3646
  } catch (e) {
3314
- log5.warn(`subagent trace flush failed (${file}): ${e instanceof Error ? e.message : String(e)}`);
3647
+ log6.warn(`subagent trace flush failed (${file}): ${e instanceof Error ? e.message : String(e)}`);
3315
3648
  }
3316
3649
  }
3317
3650
  function installTrace(child, file, meta) {
@@ -3352,7 +3685,7 @@ async function runChildTraced(args) {
3352
3685
  if (file) flushTrace(file, meta, child.transcript, wc.timedOut() ? "deadline" : res.finishReason);
3353
3686
  let result = res.finishReason === "stop" ? res.text || `(child '${label}' finished with no summary; finishReason=${res.finishReason})` : abortHint(label, res, file);
3354
3687
  if (wc.timedOut()) {
3355
- log5.warn(`sub-task '${label}' hit its ${deadlineMs}ms wall-clock deadline (steps=${res.steps})`);
3688
+ log6.warn(`sub-task '${label}' hit its ${deadlineMs}ms wall-clock deadline (steps=${res.steps})`);
3356
3689
  result += deadlineNote(deadlineMs);
3357
3690
  }
3358
3691
  await opts.hooks?.onSubagentStop?.(result, { label, agentType });
@@ -3806,7 +4139,7 @@ function reasoningToChatFragment(model, effort) {
3806
4139
  }
3807
4140
 
3808
4141
  // src/Agent.ts
3809
- var log6 = forComponent("Agent");
4142
+ var log7 = forComponent("Agent");
3810
4143
  function envInt(key, fallback) {
3811
4144
  const n = typeof process !== "undefined" ? Number(process.env?.[key]) : NaN;
3812
4145
  return Number.isFinite(n) && n >= 0 ? n : fallback;
@@ -3843,6 +4176,15 @@ var AgentOptions = class {
3843
4176
  * arrives for this long. The between-steps `timeoutMs` can't preempt an in-flight `chat()`, so a
3844
4177
  * provider that goes silent mid-stream would otherwise park the loop forever. 0 = off. */
3845
4178
  stallMs = 6e4;
4179
+ /** IDLE bound on a NATIVE delegated tool's hold over the stall watchdog (cursor's own shell/mcp/task:
4180
+ * `running` → silence → `completed`). Such a hold is the provider's own unverified CLAIM that it is
4181
+ * busy, and it SUSPENDS the very stall detection that would catch that provider going silent — so its
4182
+ * only backstop was `hardCapMs = toolHoldMs`, the TRANSPORT deadline (≥30m, sized for host-tool
4183
+ * round-trips a native call never makes). A wedged native call therefore bought 30m of total silence,
4184
+ * ×2 attempts = the 61-minute zero-output run (blank 2026-09-18). Idle, not absolute: every further
4185
+ * `running` event `touch()`es the job, so a native tool that keeps reporting progress holds
4186
+ * indefinitely — what expires is silence, not work. 0 = off (hard cap only). */
4187
+ nativeHoldMs = 3e5;
3846
4188
  /** Stop if the identical tool-call batch (name+args) repeats this many times in a row. 0 = off. */
3847
4189
  maxRepeats = 3;
3848
4190
  /** Cumulative cap on tool calls dispatched across the run. 0 = unbounded. */
@@ -3958,6 +4300,12 @@ var AgentOptions = class {
3958
4300
  /** Enable `bash({background:true})` + JobOutput/JobStatus/JobKill — sandbox background jobs (overlay-isolated,
3959
4301
  * committed on completion, drained at turn end). Useful when the VFS backend is slow (remote) or for sub-agents. */
3960
4302
  backgroundJobs = false;
4303
+ /** The job registry every long-lived tool call lives in (MCP calls past their soft timeout, delegated host/native
4304
+ * tools, and — when the host shares it with its `ShellJobRegistry` — shell jobs). Its liveness is what the stall
4305
+ * watchdog and the stuck-job reaper decide from. Default: a fresh registry per Agent. Knobs (idle windows, hard
4306
+ * caps, MCP soft timeout) live on `JobRegistryOptions`. Completion notices reach the model via `inject` unless
4307
+ * the host set its own `onExit`. */
4308
+ jobs;
3961
4309
  /** Plan mode: block mutating tools until the agent calls `ExitPlanMode` (host-approved). */
3962
4310
  planMode = false;
3963
4311
  /** Permission policy gating each tool call (allow / ask / deny). */
@@ -4024,17 +4372,18 @@ var Agent = class _Agent {
4024
4372
  /** Per-run ground truth: every tool name actually INVOKED this run (native dispatch + delegated
4025
4373
  * runtime activity). The ledger that makes a fabricated tool claim provable. Reset per run. */
4026
4374
  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();
4375
+ /** Every long-lived tool call is a job here (see AgentOptions.jobs). Delegated-runtime HOST tools (cursor
4376
+ * toolExecutor) and NATIVE tools (cursor's own shell/mcp/task, `running` → `completed`) are tracked as
4377
+ * `holds` jobs: while one runs the provider is not silent — it is waiting on a tool — so the idle-stall
4378
+ * watchdog must not fire. Each is bounded by the transport tool deadline (`hardCapMs = toolHoldMs`), so a
4379
+ * tool that never settles (or a lost `completed` event) is reaped and cannot disable stall protection.
4380
+ * A NATIVE job carries a second, much tighter bound (`idleMs = nativeHoldMs`): the transport deadline is
4381
+ * sized for a host-tool round-trip it never makes, and silence is the only thing it can actually prove. */
4382
+ jobs;
4383
+ /** Native delegated tool activity id → its job (per step attempt). */
4384
+ nativeJobs = /* @__PURE__ */ new Map();
4037
4385
  toolHoldMs = 0;
4386
+ nativeHoldMs = 0;
4038
4387
  /** A tool (host or native) already executed in the current step attempt: a stall retry replays the step
4039
4388
  * from the pre-step transcript, which would re-run it — so such a stall is not retried. */
4040
4389
  toolRanThisAttempt = false;
@@ -4082,6 +4431,7 @@ var Agent = class _Agent {
4082
4431
  addTools(tools) {
4083
4432
  this.injectedTools.push(...tools);
4084
4433
  this.activeTools.push(...tools);
4434
+ if (this.prepared && tools.some(isMcpTool) && !this.activeTools.some((t) => t.name === "JobWait")) this.activeTools.push(...makeJobTools(this.jobs));
4085
4435
  }
4086
4436
  /** Remove tools by name from a running agent. Returns the count removed. Also filters
4087
4437
  * `options.tools` — otherwise a constructor-passed tool resurrects on the next prepare()
@@ -4133,6 +4483,11 @@ var Agent = class _Agent {
4133
4483
  }
4134
4484
  constructor(options) {
4135
4485
  this.options = { ...new AgentOptions(), ...options };
4486
+ this.jobs = this.options.jobs ?? new JobRegistry();
4487
+ this.jobs.options.onExit ??= (n) => {
4488
+ const imgs = n.result?.images;
4489
+ this.inject(imgs?.length ? [{ type: "text", text: formatJobNotice(n) }, ...imgs.map((i) => imagePart(`data:${i.mimeType};base64,${i.data}`))] : formatJobNotice(n));
4490
+ };
4136
4491
  if (this.options.fs) this.buildCtx();
4137
4492
  }
4138
4493
  /** Build the tool context from the resolved fs + options. Idempotent-safe: called once (ctor or ensureFs). */
@@ -4144,7 +4499,8 @@ var Agent = class _Agent {
4144
4499
  this.ctx.ai = this.options.ai;
4145
4500
  this.ctx.model = this.options.model;
4146
4501
  this.ctx.parkHuman = (p) => this.park(p);
4147
- if (this.options.backgroundJobs) this.ctx.jobs = new SandboxJobRegistry();
4502
+ this.ctx.toolJobs = this.jobs;
4503
+ if (this.options.backgroundJobs) this.ctx.jobs = this.jobs;
4148
4504
  }
4149
4505
  /**
4150
4506
  * Resolve the filesystem + build the tool context if the ctor couldn't (no `fs` was passed). The disk
@@ -4160,7 +4516,7 @@ var Agent = class _Agent {
4160
4516
  const disk = new NodeDiskFilesystem2(process.cwd());
4161
4517
  await disk.init();
4162
4518
  this.options.fs = new JailedFilesystem2(disk);
4163
- log6.info(`no fs provided \u2014 defaulting to jailed real disk at ${process.cwd()}`);
4519
+ log7.info(`no fs provided \u2014 defaulting to jailed real disk at ${process.cwd()}`);
4164
4520
  }
4165
4521
  this.buildCtx();
4166
4522
  }
@@ -4230,7 +4586,7 @@ var Agent = class _Agent {
4230
4586
  tools = [...tools, makeTaskTool(taskOpts), makeTaskBatchTool(taskOpts)];
4231
4587
  }
4232
4588
  if (o.checkpoints) tools = [...tools, ...checkpointTools()];
4233
- if (this.ctx.jobs) tools = [...tools, ...makeJobTools(this.ctx.jobs)];
4589
+ if (this.ctx.jobs || [...tools, ...this.injectedTools].some(isMcpTool)) tools = [...tools, ...makeJobTools(this.jobs)];
4234
4590
  const plan = o.planMode ? planMode({ host: o.host }) : void 0;
4235
4591
  if (plan) tools = [...tools, plan.tool];
4236
4592
  this.activeHooks = composeHooks(o.hooks, plan?.hooks, o.permissions?.hooks());
@@ -4346,8 +4702,8 @@ var Agent = class _Agent {
4346
4702
  let closureNudged = false;
4347
4703
  let lastProgressAt = start;
4348
4704
  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();
4705
+ 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` : ""})`);
4706
+ this.jobs.killAll(`run ended: ${finishReason}`, true);
4351
4707
  const claims = this.survivingClaims();
4352
4708
  return { text: lastAssistantText(this.transcript), steps, finishReason, messages: this.transcript, usage, usageEstimated, ...claims.length ? { fabricatedToolClaims: claims } : {} };
4353
4709
  };
@@ -4369,22 +4725,26 @@ var Agent = class _Agent {
4369
4725
  const derivedToolTimeoutMs = Math.max(TRANSPORT_TOOL_TIMEOUT_FLOOR_MS, ...this.activeTools.map(transportHeadroomMs));
4370
4726
  const effectiveToolTimeoutMs = typeof hostToolTimeoutMs === "number" ? Math.max(hostToolTimeoutMs, derivedToolTimeoutMs) : derivedToolTimeoutMs;
4371
4727
  this.toolHoldMs = effectiveToolTimeoutMs;
4728
+ this.nativeHoldMs = o.nativeHoldMs;
4372
4729
  if (isCursorWithTools) {
4373
4730
  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.`);
4731
+ 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
4732
  }
4376
- for (const v of toolTimeoutViolations(this.activeTools, effectiveToolTimeoutMs)) log6.warn(v);
4733
+ for (const v of toolTimeoutViolations(this.activeTools, effectiveToolTimeoutMs)) log7.warn(v);
4377
4734
  }
4378
4735
  const cursorPo = isCursorWithTools ? {
4379
4736
  toolTimeoutMs: effectiveToolTimeoutMs,
4380
4737
  toolExecutor: async (name, args) => {
4381
4738
  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);
4739
+ const job = this.jobs.track({ kind: "host", label: name, holds: true, hardCapMs: effectiveToolTimeoutMs });
4384
4740
  try {
4385
- return await this.dispatch(tc);
4741
+ const r = await this.dispatch(tc);
4742
+ job.finish("done");
4743
+ return r;
4744
+ } catch (e) {
4745
+ job.finish("error", void 0, String(e));
4746
+ throw e;
4386
4747
  } finally {
4387
- this.hostToolsInFlight--;
4388
4748
  this.toolRanThisAttempt = true;
4389
4749
  this.stallPoke?.();
4390
4750
  }
@@ -4398,7 +4758,8 @@ var Agent = class _Agent {
4398
4758
  };
4399
4759
  try {
4400
4760
  for (let attempt = 0; ; attempt++) {
4401
- this.nativeToolsInFlight.clear();
4761
+ for (const [, h] of this.nativeJobs) if (h.status === "running") this.jobs.kill(h.id, "step attempt ended");
4762
+ this.nativeJobs.clear();
4402
4763
  this.toolRanThisAttempt = false;
4403
4764
  try {
4404
4765
  if (useStream) {
@@ -4430,11 +4791,11 @@ var Agent = class _Agent {
4430
4791
  const empty = err?.code === "empty" || err?.code === "cursor_silent";
4431
4792
  const maxAttempts = empty ? Math.max(o.emptyRetries, 0) : 2;
4432
4793
  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)");
4794
+ if (replaysTool) log7.warn("model stall after tool(s) already ran this step \u2014 not retrying (a retry would replay them)");
4434
4795
  const transient = !o.signal?.aborted && !isAbortError(err) && !replaysTool && attempt < maxAttempts && (network || serverSide || stalled || empty);
4435
4796
  if (!transient) throw err;
4436
4797
  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`);
4798
+ log7.warn(`${empty ? "empty model turn" : stalled ? "model stall" : "network drop"} mid-step (${err?.message ?? err}) \u2014 retrying in ${waitMs}ms`);
4438
4799
  o.host?.notify?.({ kind: "retry", message: `${empty ? "model returned nothing" : stalled ? "model stalled" : "connection dropped"} \u2014 retrying step (#${attempt + 1})` });
4439
4800
  await new Promise((r) => setTimeout(r, waitMs));
4440
4801
  }
@@ -4442,12 +4803,12 @@ var Agent = class _Agent {
4442
4803
  } catch (err) {
4443
4804
  if (err?.code === "budget") return kill("budget");
4444
4805
  if (err?.code === "stall") {
4445
- log6.warn(`model stalled out after retries: ${err?.message ?? err}`);
4806
+ log7.warn(`model stalled out after retries: ${err?.message ?? err}`);
4446
4807
  return { ...kill("timeout"), error: err };
4447
4808
  }
4448
4809
  if (o.signal?.aborted || isAbortError(err)) return kill("aborted");
4449
4810
  if (err?.code === "empty" || err?.code === "cursor_silent") {
4450
- log6.warn(`model returned empty/silent turns after retries \u2014 surfacing as failed`);
4811
+ log7.warn(`model returned empty/silent turns after retries \u2014 surfacing as failed`);
4451
4812
  return { text: "", steps, finishReason: "empty", messages: this.transcript, usage, usageEstimated, error: err };
4452
4813
  }
4453
4814
  const body = err?.body ?? err?.response?.data ?? err?.error;
@@ -4459,7 +4820,7 @@ var Agent = class _Agent {
4459
4820
  }
4460
4821
  if (bodyStr && err instanceof Error && !err.message.includes(bodyStr)) err.detail = bodyStr;
4461
4822
  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);
4823
+ log7.error(`chat() failed: ${err?.message ?? err}${bodyStr ? ` \u2014 ${bodyStr}` : ""}`, errLog);
4463
4824
  return { text: "", steps, finishReason: "error", messages: this.transcript, usage, usageEstimated, error: err };
4464
4825
  }
4465
4826
  if (o.signal?.aborted) return kill("aborted");
@@ -4515,21 +4876,21 @@ var Agent = class _Agent {
4515
4876
  if (emptyTurn && o.failEmptyTurns) {
4516
4877
  if (!emptyNudged && canNudge()) {
4517
4878
  emptyNudged = true;
4518
- log6.warn(`empty model turn after tool work (step ${steps}, provider finishReason=${res.finishReason ?? "n/a"}) \u2014 nudging once`);
4879
+ log7.warn(`empty model turn after tool work (step ${steps}, provider finishReason=${res.finishReason ?? "n/a"}) \u2014 nudging once`);
4519
4880
  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
4881
  continue;
4521
4882
  }
4522
- log6.warn(`model returned empty turns after tool work \u2014 surfacing as failed`);
4883
+ log7.warn(`model returned empty turns after tool work \u2014 surfacing as failed`);
4523
4884
  const e = new Error("empty_response: model returned an empty turn (no text, no tools) after a re-prompt");
4524
4885
  e.code = "empty";
4525
4886
  return { text: "", steps, finishReason: "empty", ...res.finishReason ? { providerFinishReason: res.finishReason } : {}, messages: this.transcript, usage, usageEstimated, error: e };
4526
4887
  }
4527
4888
  const fabricated = o.catchFabricatedToolUse && !closureNudged ? this.fabricatedToolNames(contentText(res.content ?? "")) : [];
4528
4889
  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`);
4890
+ 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
4891
  if (fabricated.length && canNudge()) {
4531
4892
  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`);
4893
+ 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
4894
  this.transcript.push({
4534
4895
  role: "user",
4535
4896
  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 +4899,18 @@ var Agent = class _Agent {
4538
4899
  }
4539
4900
  if (o.closeDelegatedTurns && res.endedWithoutClosing && !closureNudged && canNudge()) {
4540
4901
  closureNudged = true;
4541
- log6.verbose("delegated turn went silent after a tool \u2014 nudging a closing answer");
4902
+ log7.verbose("delegated turn went silent after a tool \u2014 nudging a closing answer");
4542
4903
  this.transcript.push({
4543
4904
  role: "user",
4544
4905
  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
4906
  });
4546
4907
  continue;
4547
4908
  }
4548
- log6.verbose(`completed in ${steps} step(s)`);
4549
- await this.ctx.jobs?.drain();
4909
+ log7.verbose(`completed in ${steps} step(s)`);
4910
+ const drained = await this.jobs.drain(o.signal);
4911
+ if (o.signal?.aborted) return kill("aborted");
4912
+ if (drained.length) lastProgressAt = Date.now();
4913
+ if (this.drainInjections()) continue;
4550
4914
  this.releaseStaleImages();
4551
4915
  await this.activeHooks?.onStop?.(res.content ?? "");
4552
4916
  const claims = this.survivingClaims();
@@ -4572,11 +4936,12 @@ var Agent = class _Agent {
4572
4936
  }
4573
4937
  this.transcript.push({ role: "tool", tool_call_id: tc.id, name: tc.function.name, content });
4574
4938
  }
4939
+ lastProgressAt = Date.now();
4575
4940
  if (this.ctx.needsInput) {
4576
4941
  const question = this.ctx.needsInput;
4577
4942
  this.ctx.needsInput = void 0;
4578
- log6.warn(`needs_input: ${question.question} (steps=${steps})`);
4579
- await this.ctx.jobs?.drain();
4943
+ log7.warn(`needs_input: ${question.question} (steps=${steps})`);
4944
+ await this.jobs.drain();
4580
4945
  const text = [
4581
4946
  `OPEN QUESTION (run paused \u2014 no human was reachable): ${question.question}`,
4582
4947
  ...question.options.map((o2) => ` \u2022 ${o2.label}${o2.description ? ` \u2014 ${o2.description}` : ""}`)
@@ -4614,7 +4979,15 @@ var Agent = class _Agent {
4614
4979
  if (!stallMs) return;
4615
4980
  if (timer) clearTimeout(timer);
4616
4981
  timer = setTimeout(() => {
4617
- if (this.toolHoldActive()) return poke();
4982
+ if (this.toolHoldActive()) {
4983
+ const now3 = Date.now();
4984
+ const who = this.jobs.holding().map((j) => `${j.kind} "${j.label}" (${Math.round((now3 - j.startedAt) / 1e3)}s, quiet ${Math.round((now3 - j.lastActivityAt) / 1e3)}s)`).join(", ");
4985
+ log7.warn(`stall watchdog: no stream output for ${stallMs}ms \u2014 held by ${who}`);
4986
+ this.options.host?.notify?.({ kind: "tool_hold", message: `waiting on ${who}` });
4987
+ return poke();
4988
+ }
4989
+ const inFlight = this.jobs.list().filter((j) => j.status === "running");
4990
+ 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
4991
  fired = true;
4619
4992
  ac.abort(stallError());
4620
4993
  }, stallMs);
@@ -4634,12 +5007,11 @@ var Agent = class _Agent {
4634
5007
  reclassify: (err) => stalled() ? stallError() : err
4635
5008
  };
4636
5009
  }
4637
- /** A host or native delegated tool is running within its deadline — the provider is quiet, not stalled. */
5010
+ /** A host or native delegated tool is running and not stuck — the provider is quiet, not stalled. The reap runs
5011
+ * FIRST, so a holder past its deadline is released (logged with evidence) instead of holding forever. */
4638
5012
  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;
5013
+ this.jobs.reap();
5014
+ return this.jobs.holding().length > 0;
4643
5015
  }
4644
5016
  /** A clean-stop turn that carries no assistant text AND no tool calls (native or delegated) — a
4645
5017
  * non-result the model produced by going silent. Callers retry it rather than report "done". */
@@ -4679,10 +5051,16 @@ var Agent = class _Agent {
4679
5051
  if (a.output !== void 0) prev.output = a.output;
4680
5052
  prev.status = a.status;
4681
5053
  }
5054
+ const nj = a.id ? this.nativeJobs.get(a.id) : void 0;
4682
5055
  if (a.status === "running") {
4683
- if (a.id) this.nativeToolsInFlight.set(a.id, Date.now() + this.toolHoldMs);
5056
+ if (nj) nj.touch();
5057
+ else if (a.id) this.nativeJobs.set(a.id, this.jobs.track({ kind: "native", label: a.name, holds: true, idleMs: this.nativeHoldMs, hardCapMs: this.toolHoldMs }));
4684
5058
  } else {
4685
- if (a.id) this.nativeToolsInFlight.delete(a.id);
5059
+ if (nj) {
5060
+ if (typeof a.output === "string") nj.chunk(a.output.slice(-4e3));
5061
+ nj.finish(a.status === "error" ? "error" : "done");
5062
+ this.nativeJobs.delete(a.id);
5063
+ }
4686
5064
  this.toolRanThisAttempt = true;
4687
5065
  }
4688
5066
  sawTool = true;
@@ -4773,13 +5151,13 @@ var Agent = class _Agent {
4773
5151
  const decision = await this.park(Promise.resolve(hooks?.preToolUse?.(call, meta)));
4774
5152
  if (decision?.block) {
4775
5153
  const blocked = `Blocked by hook: ${decision.reason ?? "no reason given"}`;
4776
- log6.debug(`${tc.function.name} -> ${blocked}`);
5154
+ log7.debug(`${tc.function.name} -> ${blocked}`);
4777
5155
  await hooks?.postToolUse?.(call, blocked, meta);
4778
5156
  return blocked;
4779
5157
  }
4780
5158
  this.options.host?.notify?.({ kind: "tool_use", id: tc.id ?? "", name: tc.function.name, input: args });
4781
5159
  if (earlyError) {
4782
- log6.debug(`${tc.function.name} -> ${earlyError}`);
5160
+ log7.debug(`${tc.function.name} -> ${earlyError}`);
4783
5161
  await hooks?.postToolUse?.(call, earlyError, meta);
4784
5162
  this.options.host?.notify?.({ kind: "tool_result", id: tc.id ?? "", output: earlyError, isError: true });
4785
5163
  return earlyError;
@@ -4788,12 +5166,12 @@ var Agent = class _Agent {
4788
5166
  let images;
4789
5167
  let threw = false;
4790
5168
  try {
4791
- log6.debug(`${tc.function.name}(${tc.function.arguments})`);
5169
+ log7.debug(`${tc.function.name}(${tc.function.arguments})`);
4792
5170
  this.ctx.emit = hooks?.onToolOutput ? (chunk) => {
4793
5171
  try {
4794
5172
  hooks.onToolOutput(call, chunk, meta);
4795
5173
  } catch (e) {
4796
- log6.debug(`onToolOutput hook error: ${e}`);
5174
+ log7.debug(`onToolOutput hook error: ${e}`);
4797
5175
  }
4798
5176
  } : void 0;
4799
5177
  const raw = await tool.run(args, this.ctx);
@@ -4805,7 +5183,7 @@ var Agent = class _Agent {
4805
5183
  }
4806
5184
  } catch (e) {
4807
5185
  const msg = e instanceof Error ? e.message : String(e);
4808
- log6.debug(`${tc.function.name} -> error: ${msg}`);
5186
+ log7.debug(`${tc.function.name} -> error: ${msg}`);
4809
5187
  result = `Error: ${msg}`;
4810
5188
  threw = true;
4811
5189
  } finally {
@@ -4897,6 +5275,7 @@ function estimateTokens(m, keepRecentImages = 1) {
4897
5275
  return Math.ceil(sendBytes(m, keepRecentImages) / 4);
4898
5276
  }
4899
5277
  var TRANSPORT_TOOL_TIMEOUT_FLOOR_MS = 18e5;
5278
+ var isMcpTool = (t) => t.name.startsWith("mcp__") || t.name === "McpCall";
4900
5279
  function transportHeadroomMs(t) {
4901
5280
  const d = Number(t.maxDurationMs) || 0;
4902
5281
  if (!d) return 0;
@@ -5072,7 +5451,7 @@ function fitTokenBudget(messages, maxTokens, keepRecentImages = 1) {
5072
5451
  const ids = callIdSet(messages.slice(from));
5073
5452
  while (from < messages.length && messages[from].role === "tool" && !ids.has(messages[from].tool_call_id ?? "")) total -= per[from++];
5074
5453
  if (total > maxTokens)
5075
- log6.warn(`context ~${total} tok still over maxContextTokens=${maxTokens} after trimming (system head can't be dropped)`);
5454
+ log7.warn(`context ~${total} tok still over maxContextTokens=${maxTokens} after trimming (system head can't be dropped)`);
5076
5455
  return [...head, ...messages.slice(from)];
5077
5456
  }
5078
5457
  function compact(m, max, focus) {
@@ -5468,6 +5847,7 @@ var toolCall = (id, name, args) => ({
5468
5847
 
5469
5848
  // src/index.ts
5470
5849
  init_tools();
5850
+ init_tools_jobs();
5471
5851
 
5472
5852
  // src/scheduler.ts
5473
5853
  function parseCronField(field, min, max) {
@@ -5742,7 +6122,7 @@ async function fullAgentOptions(opts = {}) {
5742
6122
  const registry = jobs ?? new ShellJobRegistry2({ cwd, killOnExit: true });
5743
6123
  if (onJobExit) registry.setOnExit((n) => onJobExit(formatJobExit2(n)));
5744
6124
  const shell = [makeRealShellTool2({ cwd, registry }), ...makeShellJobTools2(registry)];
5745
- return { ...rest, tools: [...tools ?? defaultTools(), ...shell] };
6125
+ return { ...rest, jobs: registry.jobs, tools: [...tools ?? defaultTools(), ...shell] };
5746
6126
  }
5747
6127
 
5748
6128
  // src/index.ts
@@ -5754,7 +6134,7 @@ init_tools_web();
5754
6134
  init_tools();
5755
6135
  init_tools_structured();
5756
6136
  init_logging();
5757
- var log8 = forComponent("scratch");
6137
+ var log9 = forComponent("scratch");
5758
6138
  var SCRATCH_DIR = "/scratch";
5759
6139
  function shortArgs(args) {
5760
6140
  try {
@@ -5794,7 +6174,7 @@ var Scratch = class {
5794
6174
  await (this.dirReady ??= mkdirp(this.fs, dir));
5795
6175
  await this.fs.writeFile(path, header + raw);
5796
6176
  } catch (e) {
5797
- log8.debug("scratch write failed; returning raw", e);
6177
+ log9.debug("scratch write failed; returning raw", e);
5798
6178
  return raw;
5799
6179
  }
5800
6180
  const preview = raw.slice(0, previewChars).replace(/\s+/g, " ").trim();
@@ -5824,7 +6204,7 @@ To pull a specific detail, Grep/Read ${path}, or call Ask({ question: "\u2026",
5824
6204
  await (this.dirReady ??= mkdirp(this.fs, dir));
5825
6205
  await this.fs.writeFile(path, header + full);
5826
6206
  } catch (e) {
5827
- log8.debug("scratch spill failed; cropping lossy", e);
6207
+ log9.debug("scratch spill failed; cropping lossy", e);
5828
6208
  return full.slice(0, pageBytes) + `
5829
6209
 
5830
6210
  [output cropped to ${pageBytes} of ${full.length} bytes; full output unavailable (scratch write failed) \u2014 refine your query]`;
@@ -5870,7 +6250,7 @@ Question: ${q}`);
5870
6250
  const answer = (res.text ?? "").trim();
5871
6251
  return answer || "(no answer found in scratch)";
5872
6252
  } catch (e) {
5873
- log8.debug("Ask peek failed", e);
6253
+ log9.debug("Ask peek failed", e);
5874
6254
  return `Error querying scratch: ${e?.message ?? e}`;
5875
6255
  }
5876
6256
  }
@@ -5879,7 +6259,7 @@ Question: ${q}`);
5879
6259
 
5880
6260
  // src/lessons.ts
5881
6261
  init_logging();
5882
- var log9 = forComponent("Lessons");
6262
+ var log10 = forComponent("Lessons");
5883
6263
  var LessonOptionsDefaults = class {
5884
6264
  minRepeats = 2;
5885
6265
  };
@@ -5904,8 +6284,8 @@ function lessonCapture(options) {
5904
6284
  counts.set(lesson.slug, n);
5905
6285
  if (n < o.minRepeats) return;
5906
6286
  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)`);
6287
+ await writeFact(o.fs, o.dir, lesson.slug, lesson.body).catch((e) => log10.warn(`could not persist ${lesson.slug}: ${e?.message ?? e}`));
6288
+ log10.debug(`captured lesson ${lesson.slug} (recurred ${n}\xD7)`);
5909
6289
  }
5910
6290
  };
5911
6291
  }
@@ -5913,7 +6293,7 @@ function lessonCapture(options) {
5913
6293
  // src/reflect.ts
5914
6294
  init_llm();
5915
6295
  init_logging();
5916
- var log10 = forComponent("Reflect");
6296
+ var log11 = forComponent("Reflect");
5917
6297
  async function reflectOnRun(o) {
5918
6298
  const digest = digestRun(o.result.messages, o.maxDigestChars ?? 6e3);
5919
6299
  if (!digest.trim()) return null;
@@ -5929,7 +6309,7 @@ If the run was fine or the issue was purely task-specific (not generalizable), r
5929
6309
  const r = await o.ai.chat({ model: o.model, messages: [{ role: "user", content: prompt }], stream: false });
5930
6310
  text = r?.content ?? "";
5931
6311
  } catch (e) {
5932
- log10.warn(`reflection call failed: ${e?.message ?? e}`);
6312
+ log11.warn(`reflection call failed: ${e?.message ?? e}`);
5933
6313
  return null;
5934
6314
  }
5935
6315
  const m = text.match(/LESSON:\s*(.+)/i);
@@ -5939,10 +6319,10 @@ If the run was fine or the issue was purely task-specific (not generalizable), r
5939
6319
  try {
5940
6320
  await writeFact(o.fs, o.dir, slug2, lesson);
5941
6321
  } catch (e) {
5942
- log10.warn(`could not persist lesson: ${e?.message ?? e}`);
6322
+ log11.warn(`could not persist lesson: ${e?.message ?? e}`);
5943
6323
  return null;
5944
6324
  }
5945
- log10.debug(`reflection persisted ${slug2}`);
6325
+ log11.debug(`reflection persisted ${slug2}`);
5946
6326
  return slug2;
5947
6327
  }
5948
6328
  function digestRun(messages, maxChars) {
@@ -5991,7 +6371,7 @@ function forComponent2(name) {
5991
6371
  }
5992
6372
 
5993
6373
  // node_modules/@bod.ee/voice/src/core/emotion.ts
5994
- var log11 = forComponent2("Emotion");
6374
+ var log12 = forComponent2("Emotion");
5995
6375
  var EMOTIONS = [
5996
6376
  // primary (best results)
5997
6377
  "neutral",
@@ -6145,7 +6525,7 @@ var EmotionStream = class {
6145
6525
  if (tag) {
6146
6526
  this.pending = tag;
6147
6527
  if (this.show) display += m[0];
6148
- } else log11.debug(`dropping unknown emotion tag ${m[0]}`);
6528
+ } else log12.debug(`dropping unknown emotion tag ${m[0]}`);
6149
6529
  last = m.index + m[0].length;
6150
6530
  }
6151
6531
  this.emit(body.slice(last), (s, d, p) => {
@@ -6168,7 +6548,7 @@ var EmotionStream = class {
6168
6548
  };
6169
6549
 
6170
6550
  // node_modules/@bod.ee/voice/src/core/engine.ts
6171
- var log12 = forComponent2("VoiceEngine");
6551
+ var log13 = forComponent2("VoiceEngine");
6172
6552
  var realClock = {
6173
6553
  now: () => performance.now(),
6174
6554
  setTimeout: (fn, ms) => setTimeout(fn, ms),
@@ -6443,7 +6823,7 @@ var VoiceEngine = class _VoiceEngine {
6443
6823
  await Promise.all([this.tts.connect(), this.stt.start()]);
6444
6824
  this.tts.warmup?.();
6445
6825
  this.setState("listening");
6446
- log12.debug(`voice I/O up (${this.stt.usingAec ? "AEC" : "heuristic echo"} capture)`);
6826
+ log13.debug(`voice I/O up (${this.stt.usingAec ? "AEC" : "heuristic echo"} capture)`);
6447
6827
  }
6448
6828
  get usingAec() {
6449
6829
  return this.stt.usingAec;
@@ -6465,7 +6845,7 @@ var VoiceEngine = class _VoiceEngine {
6465
6845
  this.options.onDiag({ t: this.clock.now(), kind, ...fields });
6466
6846
  } catch (e) {
6467
6847
  this.diagOn = false;
6468
- log12.debug(`onDiag threw \u2014 diagnostics disabled: ${e instanceof Error ? e.message : e}`);
6848
+ log13.debug(`onDiag threw \u2014 diagnostics disabled: ${e instanceof Error ? e.message : e}`);
6469
6849
  }
6470
6850
  }
6471
6851
  idleWaiters = [];
@@ -6574,7 +6954,7 @@ var VoiceEngine = class _VoiceEngine {
6574
6954
  if (prose.trim()) this.repliedSinceDispatch = true;
6575
6955
  for (const w of this.words(this.reply)) this.echoWords.add(w);
6576
6956
  this.tts.speak(forSpeech(speech), true);
6577
- if (!this.spokeDeltas && this.turnStartAt) log12.debug(`ttft: ${Math.round(this.clock.now() - this.turnStartAt)}ms`);
6957
+ if (!this.spokeDeltas && this.turnStartAt) log13.debug(`ttft: ${Math.round(this.clock.now() - this.turnStartAt)}ms`);
6578
6958
  this.spokeDeltas = true;
6579
6959
  this.setState("speaking");
6580
6960
  if (this.options.revealMode !== "off" && display) {
@@ -6651,7 +7031,7 @@ var VoiceEngine = class _VoiceEngine {
6651
7031
  this.drainTimer = null;
6652
7032
  this.speaking = false;
6653
7033
  this.stopWordReveal(true);
6654
- if (this.turnStartAt) log12.debug(`turn: ${Math.round(this.clock.now() - this.turnStartAt)}ms (incl. playback)`);
7034
+ if (this.turnStartAt) log13.debug(`turn: ${Math.round(this.clock.now() - this.turnStartAt)}ms (incl. playback)`);
6655
7035
  this.echoUntil = this.clock.now() + 2500;
6656
7036
  if (!this.usingAec) this.stt.reset();
6657
7037
  this.setState("listening");
@@ -6739,7 +7119,7 @@ var VoiceEngine = class _VoiceEngine {
6739
7119
  this.clearAckTimer("interrupt");
6740
7120
  this.adaptiveAckFired = false;
6741
7121
  this.ackProbe = null;
6742
- if (this.uttQueue.length) log12.info(`barge-in dropped ${this.uttQueue.length} queued worker utterance(s)`);
7122
+ if (this.uttQueue.length) log13.info(`barge-in dropped ${this.uttQueue.length} queued worker utterance(s)`);
6743
7123
  const droppedQueued = this.uttQueue.length;
6744
7124
  this.uttQueue = [];
6745
7125
  if (!this.speaking && !this.drainTimer) return;
@@ -6885,7 +7265,7 @@ var VoiceEngine = class _VoiceEngine {
6885
7265
  if (flat && this.lastDispatchFlat && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS && (this.lastDispatchFlat.startsWith(flat) || flat.startsWith(this.lastDispatchFlat))) return;
6886
7266
  this.specSpent = true;
6887
7267
  this.specText = this.specPartial;
6888
- log12.debug(`speculate: "${this.specText.slice(0, 60)}"`);
7268
+ log13.debug(`speculate: "${this.specText.slice(0, 60)}"`);
6889
7269
  this.diag("speculate", { text: this.specText });
6890
7270
  this.options.onSpeculate(this.specText);
6891
7271
  }, this.options.speculativeMs);
@@ -6954,7 +7334,7 @@ var VoiceEngine = class _VoiceEngine {
6954
7334
  };
6955
7335
  this.tts.newContext();
6956
7336
  this.tts.speak(phrase, false);
6957
- log12.debug(`backchannel: "${phrase}"`);
7337
+ log13.debug(`backchannel: "${phrase}"`);
6958
7338
  this.diag("backchannel", { phrase });
6959
7339
  this.options.onBackchannel(phrase);
6960
7340
  }
@@ -6987,7 +7367,7 @@ var VoiceEngine = class _VoiceEngine {
6987
7367
  });
6988
7368
  if (idx < 0) return text;
6989
7369
  toks.splice(idx, 1);
6990
- log12.verbose(`stripped backchannel echo "${this.lastBcPhrase}" from dispatching final`);
7370
+ log13.verbose(`stripped backchannel echo "${this.lastBcPhrase}" from dispatching final`);
6991
7371
  return toks.join(" ");
6992
7372
  }
6993
7373
  /** Merge a resumed utterance into the pending one, deduping any word-overlap. Soniox re-finalizes
@@ -7078,7 +7458,7 @@ var VoiceEngine = class _VoiceEngine {
7078
7458
  this.pendingUtt = this.mergeUtterance(this.pendingUtt, text);
7079
7459
  if (this.pendingTimer) this.clock.clearTimeout(this.pendingTimer);
7080
7460
  if (this.options.incompleteMergeMs && this.looksIncomplete(this.pendingUtt)) {
7081
- log12.verbose(`hold: incomplete utterance "${this.pendingUtt.slice(-40)}"`);
7461
+ log13.verbose(`hold: incomplete utterance "${this.pendingUtt.slice(-40)}"`);
7082
7462
  this.diag("hold", { reason: "incomplete", tail: this.pendingUtt.slice(-40) });
7083
7463
  this.options.onHold();
7084
7464
  if (this.options.holdFiller && !this.speaking) this.speakFiller(this.options.holdFiller);
@@ -7113,13 +7493,13 @@ var VoiceEngine = class _VoiceEngine {
7113
7493
  if (text) {
7114
7494
  const flat = text.toLowerCase().replace(/[^a-z0-9]/g, "");
7115
7495
  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)`);
7496
+ log13.verbose(`dropped duplicate final "${text.slice(0, 40)}" (identical to the just-dispatched utterance, no reply between)`);
7117
7497
  this.diag("utterance_drop", { reason: "dup_final", text: text.slice(0, 60) });
7118
7498
  return;
7119
7499
  }
7120
7500
  const refinal = this.refinalizes(text);
7121
7501
  if (refinal && this.repliedSinceDispatch) {
7122
- log12.verbose(`dropped re-finalization "${text.slice(0, 60)}" (superset of the just-dispatched utterance, reply already flowing)`);
7502
+ log13.verbose(`dropped re-finalization "${text.slice(0, 60)}" (superset of the just-dispatched utterance, reply already flowing)`);
7123
7503
  this.diag("utterance_drop", { reason: "refinalized", text: text.slice(0, 60) });
7124
7504
  this.lastDispatchFlat = flat;
7125
7505
  this.lastDispatchWords = text.trim().split(/\s+/);
@@ -7279,7 +7659,7 @@ var SpokenSplitter = class {
7279
7659
  };
7280
7660
 
7281
7661
  // node_modules/@bod.ee/voice/src/adapters/soniox.ts
7282
- var log13 = forComponent2("SonioxSTT");
7662
+ var log14 = forComponent2("SonioxSTT");
7283
7663
  var now = () => performance.now();
7284
7664
  var SonioxSTTOptions = class {
7285
7665
  auth = "";
@@ -7320,7 +7700,7 @@ var SonioxSTT = class {
7320
7700
  this.onDiag({ t: now(), kind, ...fields });
7321
7701
  } catch (e) {
7322
7702
  this.diagOn = false;
7323
- log13.debug(`onDiag threw \u2014 STT diagnostics disabled: ${e instanceof Error ? e.message : e}`);
7703
+ log14.debug(`onDiag threw \u2014 STT diagnostics disabled: ${e instanceof Error ? e.message : e}`);
7324
7704
  }
7325
7705
  }
7326
7706
  lastChunkAt = 0;
@@ -7362,11 +7742,11 @@ var SonioxSTT = class {
7362
7742
  this.ws.onmessage = (ev) => this.handle(JSON.parse(String(ev.data)));
7363
7743
  this.ws.onclose = (ev) => {
7364
7744
  if (this.stopped) return;
7365
- log13.warn(`soniox ws closed (${ev.code} ${ev.reason || ""}) \u2014 reconnecting`);
7745
+ log14.warn(`soniox ws closed (${ev.code} ${ev.reason || ""}) \u2014 reconnecting`);
7366
7746
  this.diag("stt_ws_closed", { code: ev.code, reason: String(ev.reason || ""), reconnecting: true });
7367
7747
  this.reset();
7368
7748
  this.connectWs().catch((e) => {
7369
- log13.error(`soniox reconnect failed: ${e.message}`);
7749
+ log14.error(`soniox reconnect failed: ${e.message}`);
7370
7750
  this.diag("stt_reconnect_failed", { message: e.message });
7371
7751
  });
7372
7752
  };
@@ -7378,7 +7758,7 @@ var SonioxSTT = class {
7378
7758
  this.endpointTimer = setInterval(() => {
7379
7759
  const combined = (this.finalText + this.partialText).trim();
7380
7760
  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)}"`);
7761
+ if (this.firstTokenAt) log14.debug(`stt: ${Math.round(now() - this.firstTokenAt)}ms first-token\u2192silence-endpoint, "${combined.slice(0, 60)}"`);
7382
7762
  this.reset();
7383
7763
  this.onUtterance(combined, now());
7384
7764
  }, 120);
@@ -7390,7 +7770,7 @@ var SonioxSTT = class {
7390
7770
  if (this.stopped) return;
7391
7771
  const ref = this.lastChunkAt || this.startedChunksAt;
7392
7772
  if (now() - ref > noAudioMs) {
7393
- log13.error(`stt: no mic audio for >${Math.round(noAudioMs / 1e3)}s \u2014 capture device stopped delivering`);
7773
+ log14.error(`stt: no mic audio for >${Math.round(noAudioMs / 1e3)}s \u2014 capture device stopped delivering`);
7394
7774
  this.diag("stt_watchdog_fatal", { noAudioMs });
7395
7775
  this.onFatal("microphone stopped delivering audio (try a different input device, e.g. AirPods, or check System Settings \u2192 Sound \u2192 Input)");
7396
7776
  this.stop();
@@ -7413,7 +7793,7 @@ var SonioxSTT = class {
7413
7793
  handle(m) {
7414
7794
  if (m.error_message) {
7415
7795
  this.diag("stt_error", { message: String(m.error_message), code: m.error_code });
7416
- return log13.error(`soniox: ${m.error_message}`);
7796
+ return log14.error(`soniox: ${m.error_message}`);
7417
7797
  }
7418
7798
  let endpoint = false;
7419
7799
  for (const t of m.tokens ?? []) {
@@ -7430,7 +7810,7 @@ var SonioxSTT = class {
7430
7810
  this.onPartial(combined);
7431
7811
  if (endpoint && this.finalText.trim()) {
7432
7812
  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)}"`);
7813
+ if (this.firstTokenAt) log14.debug(`stt: ${Math.round(now() - this.firstTokenAt)}ms first-token\u2192endpoint, "${utterance.slice(0, 60)}"`);
7434
7814
  this.reset();
7435
7815
  this.onUtterance(utterance, now());
7436
7816
  }
@@ -7452,7 +7832,7 @@ var SonioxSTT = class {
7452
7832
  };
7453
7833
 
7454
7834
  // node_modules/@bod.ee/voice/src/adapters/cartesia.ts
7455
- var log14 = forComponent2("CartesiaTTS");
7835
+ var log15 = forComponent2("CartesiaTTS");
7456
7836
  var now2 = () => performance.now();
7457
7837
  var CartesiaTTSOptions = class {
7458
7838
  auth = "";
@@ -7488,7 +7868,7 @@ var CartesiaTTS = class _CartesiaTTS {
7488
7868
  this.onDiag({ t: now2(), kind, ...fields });
7489
7869
  } catch (e) {
7490
7870
  this.diagOn = false;
7491
- log14.debug(`onDiag threw \u2014 TTS diagnostics disabled: ${e instanceof Error ? e.message : e}`);
7871
+ log15.debug(`onDiag threw \u2014 TTS diagnostics disabled: ${e instanceof Error ? e.message : e}`);
7492
7872
  }
7493
7873
  }
7494
7874
  firstAudioAt = 0;
@@ -7523,11 +7903,11 @@ var CartesiaTTS = class _CartesiaTTS {
7523
7903
  this.ws.onerror = (e) => rej(new Error(`cartesia ws: ${e.message || "connect failed"}`));
7524
7904
  });
7525
7905
  this.ws.onclose = (ev) => {
7526
- log14.warn(`cartesia ws closed (${ev.code} ${ev.reason || ""})`);
7906
+ log15.warn(`cartesia ws closed (${ev.code} ${ev.reason || ""})`);
7527
7907
  this.diag("tts_ws_closed", { code: ev.code, reason: String(ev.reason || ""), reconnecting: !this.closed });
7528
7908
  if (!this.closed) {
7529
7909
  this.connecting = this.doConnect().catch((e) => {
7530
- log14.error(`cartesia reconnect failed: ${e.message}`);
7910
+ log15.error(`cartesia reconnect failed: ${e.message}`);
7531
7911
  this.diag("tts_reconnect_failed", { message: e.message });
7532
7912
  });
7533
7913
  }
@@ -7555,12 +7935,12 @@ var CartesiaTTS = class _CartesiaTTS {
7555
7935
  this.down = true;
7556
7936
  this.downAt = now2();
7557
7937
  this.consecutiveOk = 0;
7558
- log14.warn(`TTS circuit breaker open \u2014 ${this.consecutiveErrors} consecutive errors, switching to text-only`);
7938
+ log15.warn(`TTS circuit breaker open \u2014 ${this.consecutiveErrors} consecutive errors, switching to text-only`);
7559
7939
  this.diag("tts_breaker_open", { errors: this.consecutiveErrors });
7560
7940
  this.onDone();
7561
7941
  this.startProbe();
7562
7942
  } else if (!this.down) {
7563
- (/No valid transcripts/i.test(m.message || "") ? log14.debug : log14.warn)(`cartesia: ${JSON.stringify(m)}`);
7943
+ (/No valid transcripts/i.test(m.message || "") ? log15.debug : log15.warn)(`cartesia: ${JSON.stringify(m)}`);
7564
7944
  }
7565
7945
  }
7566
7946
  };
@@ -7575,7 +7955,7 @@ var CartesiaTTS = class _CartesiaTTS {
7575
7955
  this.stopProbe();
7576
7956
  const downMs = this.downAt ? now2() - this.downAt : 0;
7577
7957
  this.diag("tts_breaker_close", { downMs: Math.round(downMs) });
7578
- (downMs < 2e3 ? log14.debug : log14.info)(`TTS recovered${downMs ? ` (down ${downMs}ms)` : ""}`);
7958
+ (downMs < 2e3 ? log15.debug : log15.info)(`TTS recovered${downMs ? ` (down ${downMs}ms)` : ""}`);
7579
7959
  }
7580
7960
  /** Ensure the WS is open before sending — reconnects if idle-closed. */
7581
7961
  async ensureConnected() {
@@ -7666,7 +8046,7 @@ configureLogging((name) => {
7666
8046
  });
7667
8047
 
7668
8048
  // src/duplex.ts
7669
- var log15 = forComponent("DuplexAgent");
8049
+ var log16 = forComponent("DuplexAgent");
7670
8050
  function describeCall(call) {
7671
8051
  const v = call.args && Object.values(call.args).find((x) => typeof x === "string" && x.trim());
7672
8052
  const hint = v ? ` (${String(v).replace(/\s+/g, " ").trim().slice(0, 48)})` : "";
@@ -7877,7 +8257,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
7877
8257
  const m = this.reflexBuf.match(RESERVED_EVENT_MARKER) ?? this.reflexBuf.match(RESERVED_EVENT_OPENER);
7878
8258
  if (m) {
7879
8259
  this.fabricationCut = true;
7880
- log15.warn(`reflex fabricated a [task \u2026] event in its spoken stream \u2014 cutting it (kept ${m.index} chars)`);
8260
+ log16.warn(`reflex fabricated a [task \u2026] event in its spoken stream \u2014 cutting it (kept ${m.index} chars)`);
7881
8261
  const safe = this.reflexBuf.slice(this.reflexForwarded, m.index);
7882
8262
  if (!safe) return;
7883
8263
  if (safe.trim()) this.spokeThisTurn = true;
@@ -7972,7 +8352,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
7972
8352
  return {
7973
8353
  preToolUse: (call) => {
7974
8354
  if (this.spec?.state === "pending" && !_DuplexAgent.SPEC_SAFE_TOOLS.has(call.name)) {
7975
- log15.verbose(`speculation aborted: reflex called ${call.name} on unconfirmed input`);
8355
+ log16.verbose(`speculation aborted: reflex called ${call.name} on unconfirmed input`);
7976
8356
  this.abortSpeculation();
7977
8357
  return { block: true, reason: "Speculative turn aborted." };
7978
8358
  }
@@ -8010,7 +8390,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8010
8390
  try {
8011
8391
  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
8392
  } catch (e) {
8013
- log15.warn(`ack nudge failed: ${e instanceof Error ? e.message : e}`);
8393
+ log16.warn(`ack nudge failed: ${e instanceof Error ? e.message : e}`);
8014
8394
  } finally {
8015
8395
  this.nudging = false;
8016
8396
  }
@@ -8103,7 +8483,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8103
8483
  try {
8104
8484
  res = await this.voice.send(spec.text);
8105
8485
  } catch (e) {
8106
- log15.warn(`speculative turn failed: ${e instanceof Error ? e.message : e}`);
8486
+ log16.warn(`speculative turn failed: ${e instanceof Error ? e.message : e}`);
8107
8487
  } finally {
8108
8488
  this.voice.options.signal = prevSignal;
8109
8489
  }
@@ -8117,7 +8497,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8117
8497
  if (d === "abort") {
8118
8498
  if (this.voice.transcript.length > base) this.voice.transcript.length = base;
8119
8499
  this.speculativeAbortedCalls++;
8120
- log15.verbose(`speculation aborted (${this.speculativeAbortedCalls} total): "${spec.text.slice(0, 50)}"`);
8500
+ log16.verbose(`speculation aborted (${this.speculativeAbortedCalls} total): "${spec.text.slice(0, 50)}"`);
8121
8501
  if (this.spec === spec) this.spec = void 0;
8122
8502
  return res ?? empty;
8123
8503
  }
@@ -8382,7 +8762,7 @@ Another agent just implemented the above. Independently check the CURRENT state
8382
8762
  this.notify("task_verify", `task ${id}: verifying`, { id });
8383
8763
  const cres = await new Agent(checkerOpts).run(checkBrief);
8384
8764
  if (cres.finishReason !== "stop") {
8385
- log15.warn(`task ${id}: verify inconclusive (${cres.finishReason})`);
8765
+ log16.warn(`task ${id}: verify inconclusive (${cres.finishReason})`);
8386
8766
  this.notify("task_verify", `task ${id}: verify inconclusive (${cres.finishReason})`, { id, finishReason: cres.finishReason });
8387
8767
  }
8388
8768
  const sum = (a = 0, b = 0) => a + b;
@@ -8518,7 +8898,7 @@ Another agent just implemented the above. Independently check the CURRENT state
8518
8898
  rec.status = "done";
8519
8899
  rec.result = res.text;
8520
8900
  const incomplete = res.finishReason !== "stop" && res.finishReason !== "empty";
8521
- log15.verbose(`task ${id} done (${res.steps} steps${incomplete ? `, INCOMPLETE: ${res.finishReason}` : ""})`);
8901
+ log16.verbose(`task ${id} done (${res.steps} steps${incomplete ? `, INCOMPLETE: ${res.finishReason}` : ""})`);
8522
8902
  this.notify("task_done", `task ${id} (${rec.label}) completed`, {
8523
8903
  id,
8524
8904
  text: res.text,
@@ -8560,14 +8940,14 @@ Another agent just implemented the above. Independently check the CURRENT state
8560
8940
  foldIfSuperseded(rec, note) {
8561
8941
  if (!(rec.deliveryParked && this.suppressParkedResume)) return false;
8562
8942
  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`);
8943
+ log16.verbose(`task ${rec.id} settled but superseded (${rec.status}) \u2014 folded into transcript, not voiced`);
8564
8944
  return true;
8565
8945
  }
8566
8946
  failTask(rec, msg) {
8567
8947
  this.dropAsk(rec.id);
8568
8948
  rec.status = "error";
8569
8949
  rec.result = msg;
8570
- log15.warn(`task ${rec.id} failed: ${msg}`);
8950
+ log16.warn(`task ${rec.id} failed: ${msg}`);
8571
8951
  this.notify("task_error", `task ${rec.id} (${rec.label}) failed: ${msg}`);
8572
8952
  if (this.foldIfSuperseded(rec, `The "${rec.label}" task failed: ${msg}`)) return;
8573
8953
  this.queueRevoice(this.integrationPrompt(rec, "error", msg, "error"), true);
@@ -8816,17 +9196,42 @@ async function withDeadline(p, ms) {
8816
9196
  }
8817
9197
  }
8818
9198
  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.`;
9199
+ async function runMcpCall(name, call, ctx, timeoutMs) {
9200
+ const jobs = ctx?.toolJobs;
9201
+ const shape = (raw) => {
9202
+ const r2 = toResult(raw);
9203
+ return r2.images?.length ? r2 : r2.text;
9204
+ };
9205
+ if (!jobs) {
9206
+ const raw = await withDeadline(call({}), timeoutMs);
9207
+ return raw === TIMED_OUT ? mcpTimedOut(name, timeoutMs) : shape(raw);
9208
+ }
9209
+ const r = await jobs.runForeground(async ({ signal, touch, onChunk }) => {
9210
+ const raw = await call({
9211
+ signal,
9212
+ onProgress: (p) => {
9213
+ touch();
9214
+ if (p?.message) onChunk(`[progress${p.progress != null ? ` ${p.progress}${p.total != null ? `/${p.total}` : ""}` : ""}] ${p.message}
9215
+ `);
9216
+ }
9217
+ });
9218
+ const res = toResult(raw);
9219
+ onChunk(res.text + (res.images?.length ? `
9220
+ [${res.images.length} image(s) in the result]` : ""));
9221
+ return res;
9222
+ }, { kind: "mcp", label: name, idleAfterActivity: true });
9223
+ if (r.done) return r.value.images?.length ? r.value : r.value.text;
9224
+ const soft = jobs.options.foregroundMs.mcp ?? 0;
9225
+ 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.`;
9226
+ }
8819
9227
  function mcpToolToAgentTool(spec, callTool, prefix = "mcp__", timeoutMs = MCP_CALL_TIMEOUT_MS) {
8820
9228
  return {
8821
9229
  name: `${prefix}${spec.name}`,
8822
9230
  description: spec.description ?? `MCP tool ${spec.name}`,
8823
9231
  parameters: spec.inputSchema ?? { type: "object", properties: {} },
8824
9232
  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;
9233
+ run(args, ctx) {
9234
+ return runMcpCall(spec.name, (o) => callTool(spec.name, args ?? {}, o), ctx, timeoutMs);
8830
9235
  }
8831
9236
  };
8832
9237
  }
@@ -8867,13 +9272,10 @@ function makeMcpToolSearch(specs, callTool, options = {}) {
8867
9272
  }
8868
9273
  },
8869
9274
  maxDurationMs: callTimeoutMs,
8870
- async run({ name, args }) {
9275
+ async run({ name, args }, ctx) {
8871
9276
  const n = String(name ?? "");
8872
9277
  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;
9278
+ return runMcpCall(n, (o) => callTool(n, args ?? {}, o), ctx, callTimeoutMs);
8877
9279
  }
8878
9280
  };
8879
9281
  return [searchTool, callMcpTool];
@@ -8893,17 +9295,17 @@ function buildMcpCatalog(servers) {
8893
9295
  return { specs, routes };
8894
9296
  }
8895
9297
  function searchOverCatalog(servers, specs, routes, resolve2, options) {
8896
- const tools = specs.length ? makeMcpToolSearch(specs, (name, args) => {
9298
+ const tools = specs.length ? makeMcpToolSearch(specs, (name, args, o) => {
8897
9299
  const r = routes.get(name);
8898
9300
  if (!r) throw new Error(`unknown MCP tool '${name}' \u2014 use ToolSearch to find valid names`);
8899
- return resolve2(r.server, r.rawName, args ?? {});
9301
+ return resolve2(r.server, r.rawName, args ?? {}, o);
8900
9302
  }, options) : [];
8901
9303
  return { tools, serverNames: servers, toolCount: specs.length };
8902
9304
  }
8903
9305
  function makeMcpToolSearchFromMounted(mounted, options) {
8904
9306
  const { specs, routes } = buildMcpCatalog(mounted);
8905
9307
  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);
9308
+ return searchOverCatalog(mounted.map((m) => m.name), specs, routes, (server, rawName, args, o) => byName.get(server).client.callTool(rawName, args, o), options);
8907
9309
  }
8908
9310
  function makeLazyMcpToolSearch(servers, resolve2, options) {
8909
9311
  const { specs, routes } = buildMcpCatalog(servers);
@@ -8988,6 +9390,8 @@ export {
8988
9390
  IndexedDbFilesystem,
8989
9391
  JailOptions,
8990
9392
  JailedFilesystem,
9393
+ JobRegistry,
9394
+ JobRegistryOptions,
8991
9395
  LessonOptionsDefaults,
8992
9396
  MEMORY_PROMPT,
8993
9397
  MODEL_ALIASES,
@@ -9042,6 +9446,7 @@ export {
9042
9446
  expandTemplate,
9043
9447
  findGitRoot,
9044
9448
  forComponent,
9449
+ formatJobNotice,
9045
9450
  fullAgentOptions,
9046
9451
  getOrCreateWorktree,
9047
9452
  getWorktreeSession,