agent.libx.js 0.94.23 → 0.94.25

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.d.ts CHANGED
@@ -1,11 +1,11 @@
1
- import { a as AgentOptions, H as Hooks, h as RunResult, A as Agent } from './Agent-DmsB5hzp.js';
2
- export { C as ChatFragment, D as DEFAULT_MUTATING, b as Decision, P as PermissionOptions, c as PermissionPolicy, d as PermissionRule, e as PreToolUseDecision, R as ReasoningEffort, f as RecordingHooks, g as RecordingLifecycle, T as ToolUse, i as ToolUseMeta, j as composeHooks, p as planMode, r as reasoningToChatFragment } from './Agent-DmsB5hzp.js';
1
+ import { a as AgentOptions, H as Hooks, h as RunResult, A as Agent } from './Agent-BA-rueWn.js';
2
+ export { C as ChatFragment, D as DEFAULT_MUTATING, b as Decision, P as PermissionOptions, c as PermissionPolicy, d as PermissionRule, e as PreToolUseDecision, R as ReasoningEffort, f as RecordingHooks, g as RecordingLifecycle, T as ToolUse, i as ToolUseMeta, j as composeHooks, p as planMode, r as reasoningToChatFragment } from './Agent-BA-rueWn.js';
3
3
  import { IFilesystem, FileMetadata } from '@livx.cc/wcli/core';
4
4
  export { CommandExecutor, FileMetadata, IFilesystem, IndexedDbFilesystem, MemFilesystem, registerHeadlessCommands } from '@livx.cc/wcli/core';
5
5
  import { BodDB } from '@bod.ee/db';
6
- import { A as AgentTool, C as ChatLike, a as ChatOptions, b as ChatResponse, h as ToolCall, H as HostBridge, U as UserQuestion, e as MessageContent } from './tools-9AUK6SG2.js';
7
- export { c as ContentPart, d as HostEvent, M as Message, R as Role, S as SandboxJobRegistry, f as StreamChunk, T as TodoItem, g as Tool, i as ToolContext, j as bashTool, k as contentText, l as defaultTools, m as editTool, n as exitSessionTool, o as imagePart, p as makeContext, q as makeJobTools, r as readTool, t as toWireTools, s as todoWriteTool, u as toolRegistry, v as toolsByName } from './tools-9AUK6SG2.js';
8
- export { M as McpCall, a as McpImage, b as McpRoute, c as McpRouteResolver, d as McpToolResult, e as McpToolSearchOptions, f as McpToolSpec, g as MountedMcpLike, h as buildMcpCatalog, m as makeLazyMcpToolSearch, i as makeMcpToolSearch, j as makeMcpToolSearchFromMounted, k as mcpToolToAgentTool, l as mcpToolsToAgentTools } from './mcp-D00OuccC.js';
6
+ import { A as AgentTool, C as ChatLike, a as ChatOptions, b as ChatResponse, h as ToolCall, H as HostBridge, U as UserQuestion, e as MessageContent } from './tools-DtpN8Agv.js';
7
+ export { c as ContentPart, d as HostEvent, M as Message, R as Role, S as SandboxJobRegistry, f as StreamChunk, T as TodoItem, g as Tool, i as ToolContext, j as bashTool, k as contentText, l as defaultTools, m as editTool, n as exitSessionTool, o as imagePart, p as makeContext, q as makeJobTools, r as readTool, t as toWireTools, s as todoWriteTool, u as toolRegistry, v as toolsByName } from './tools-DtpN8Agv.js';
8
+ export { M as McpCall, a as McpImage, b as McpRoute, c as McpRouteResolver, d as McpToolResult, e as McpToolSearchOptions, f as McpToolSpec, g as MountedMcpLike, h as buildMcpCatalog, m as makeLazyMcpToolSearch, i as makeMcpToolSearch, j as makeMcpToolSearchFromMounted, k as mcpToolToAgentTool, l as mcpToolsToAgentTools } from './mcp-CnzmQ8JE.js';
9
9
  import * as libx_js_src_modules_log from 'libx.js/src/modules/log';
10
10
  export { log } from 'libx.js/src/modules/log';
11
11
 
@@ -25,6 +25,8 @@ declare class NodeDiskFilesystem implements IFilesystem {
25
25
  /** Ensure the root dir exists. */
26
26
  init(): Promise<void>;
27
27
  private real;
28
+ private verified;
29
+ private static VERIFY_TTL_MS;
28
30
  /** Throw if any existing component of `real` is a symlink (escape vector). */
29
31
  private assertNoSymlink;
30
32
  resolvePath(path: string, cwd?: string): string;
@@ -384,7 +386,32 @@ declare class Scheduler {
384
386
  destroy(): void;
385
387
  }
386
388
 
387
- declare function makeScheduleTools(scheduler: Scheduler): AgentTool[];
389
+ /** Narrow seam to an OS-level scheduler backend (jobs that survive quit/reboot). Node hosts wire
390
+ * the CLI's `OsScheduler` (launchd/cron/at); absent => everything stays in-process. Edge-safe:
391
+ * this module only sees the interface. */
392
+ interface OsBackend {
393
+ sessionId: string;
394
+ cwd: string;
395
+ /** 'os' when the trigger should outlive the session (per hint/heuristic). */
396
+ route(trigger: Trigger, backendHint?: string): 'session' | 'os';
397
+ /** Register with the OS. Returns a mechanism description (e.g. 'launchd:…'). Throws on failure. */
398
+ schedule(spec: {
399
+ id: string;
400
+ prompt: string;
401
+ sessionId: string;
402
+ cwd: string;
403
+ trigger: Trigger;
404
+ label?: string;
405
+ }): string;
406
+ cancel(id: string): boolean;
407
+ list(): Array<{
408
+ id: string;
409
+ label?: string;
410
+ mechanism: string;
411
+ trigger: Trigger;
412
+ }>;
413
+ }
414
+ declare function makeScheduleTools(scheduler: Scheduler, os?: OsBackend): AgentTool[];
388
415
 
389
416
  /** Sandbox mode: an in-memory VFS — the real disk is never read or written. Edge/browser/test/dry-run. */
390
417
  declare function sandboxAgentOptions(opts?: Partial<AgentOptions>): Partial<AgentOptions>;
@@ -829,6 +856,9 @@ declare class DuplexAgentOptions {
829
856
  askRelay: boolean;
830
857
  /** Parked questions auto-resolve empty after this long (callers map '' to deny/best-judgment). */
831
858
  askTimeoutMs: number;
859
+ /** Max retained task records: oldest SETTLED tasks (and their activity tails) are evicted past this,
860
+ * bounding memory over a long-lived session. Running tasks are never evicted. */
861
+ maxTaskRecords: number;
832
862
  /** Host overrides for QuickLook lookups (keyed by `what`). The engine's defaults go through the
833
863
  * (possibly jailed) fs — e.g. `.git/**` is deny-listed, so the CLI supplies 'branch' itself. */
834
864
  quickLook?: Record<string, (path?: string) => string | Promise<string>>;
package/dist/index.js CHANGED
@@ -253,12 +253,14 @@ var init_tools_structured = __esm({
253
253
  const ctxN = Math.max(0, Number(context ?? 0));
254
254
  const out = [];
255
255
  const matched = [];
256
+ let skipped = 0;
256
257
  for (const path of files) {
257
258
  ckAbort(ctx.signal);
258
259
  let content;
259
260
  try {
260
261
  content = await ctx.fs.readFile(path);
261
262
  } catch {
263
+ skipped++;
262
264
  continue;
263
265
  }
264
266
  const lines = content.split("\n");
@@ -273,8 +275,10 @@ var init_tools_structured = __esm({
273
275
  }
274
276
  if (fileHit) matched.push(path);
275
277
  }
276
- if (filesOnly) return matched.length ? matched.join("\n") : "(no matches)";
277
- return out.length ? out.join("\n") : "(no matches)";
278
+ const note = skipped ? `
279
+ [skipped ${skipped} unreadable file${skipped === 1 ? "" : "s"}]` : "";
280
+ if (filesOnly) return (matched.length ? matched.join("\n") : "(no matches)") + note;
281
+ return (out.length ? out.join("\n") : "(no matches)") + note;
278
282
  }
279
283
  };
280
284
  SIG_RE = /^\s*(export\b|(?:export\s+)?(?:async\s+)?function\s+\*?\w|(?:export\s+)?(?:abstract\s+)?class\s+\w|(?:export\s+)?interface\s+\w|(?:export\s+)?type\s+\w|(?:export\s+)?enum\s+\w)/;
@@ -963,7 +967,7 @@ var init_tools = __esm({
963
967
  IMG_MIME = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" };
964
968
  readTool = {
965
969
  name: "Read",
966
- description: "Read a file. Text files return 1-indexed numbered lines (with optional `offset`/`limit` and a re-Read pointer for partial reads). Image files (png/jpg/jpeg/gif/webp) return the picture itself so you can SEE it. Always Read a file before Editing it.",
970
+ description: "Read a file. Text files return 1-indexed numbered lines (with optional `offset`/`limit` and a re-Read pointer for partial reads). Image files (png/jpg/jpeg/gif/webp) return the picture itself so you can SEE it. PDFs return their extracted text. Always Read a file before Editing it.",
967
971
  parameters: {
968
972
  type: "object",
969
973
  required: ["path"],
@@ -975,6 +979,12 @@ var init_tools = __esm({
975
979
  },
976
980
  async run({ path, offset, limit }, ctx) {
977
981
  const ext = String(path).toLowerCase().split(".").pop() ?? "";
982
+ if (ext === "pdf") {
983
+ if (!ctx.pdfText) return `[${path} is a PDF \u2014 text extraction isn't available in this environment (install poppler's pdftotext and run on disk).]`;
984
+ if (!await ctx.fs.exists(path)) return `Error: File not found: ${path}`;
985
+ const text = (await ctx.pdfText(ctx.fs.resolvePath(path))).trim();
986
+ return text ? numberLines(text, Math.max(0, offset ?? 0), limit) : `[${path}: no extractable text (scanned/image-only PDF?)]`;
987
+ }
978
988
  if (IMG_MIME[ext]) {
979
989
  const fs = ctx.fs;
980
990
  if (typeof fs.readFileBytes !== "function") {
@@ -1052,7 +1062,7 @@ var NodeDiskFilesystem;
1052
1062
  var init_NodeDiskFilesystem = __esm({
1053
1063
  "src/NodeDiskFilesystem.ts"() {
1054
1064
  "use strict";
1055
- NodeDiskFilesystem = class {
1065
+ NodeDiskFilesystem = class _NodeDiskFilesystem {
1056
1066
  constructor(baseDir, opts = {}) {
1057
1067
  this.baseDir = baseDir;
1058
1068
  this.opts = { denySymlinks: true, ...opts };
@@ -1067,14 +1077,24 @@ var init_NodeDiskFilesystem = __esm({
1067
1077
  real(vpath) {
1068
1078
  return np.join(this.baseDir, "." + vpath);
1069
1079
  }
1080
+ // Verified non-symlink DIRECTORY components, with a short TTL: tree walks (Glob/Grep) hit the same
1081
+ // parents thousands of times; re-lstat'ing each per op is the dominant syscall cost. The 1s window
1082
+ // is an accepted race only against an out-of-band symlink swap mid-walk (this FS can't create
1083
+ // symlinks itself); leaf components are never cached.
1084
+ verified = /* @__PURE__ */ new Map();
1085
+ static VERIFY_TTL_MS = 1e3;
1070
1086
  /** Throw if any existing component of `real` is a symlink (escape vector). */
1071
1087
  async assertNoSymlink(real) {
1072
1088
  if (!this.opts.denySymlinks) return;
1073
1089
  const rel = np.relative(this.baseDir, real);
1074
1090
  if (rel === "" || rel.startsWith("..")) return;
1091
+ const parts = rel.split(np.sep);
1075
1092
  let cur = this.baseDir;
1076
- for (const part of rel.split(np.sep)) {
1077
- cur = np.join(cur, part);
1093
+ const now4 = Date.now();
1094
+ for (let i = 0; i < parts.length; i++) {
1095
+ cur = np.join(cur, parts[i]);
1096
+ const isLeaf = i === parts.length - 1;
1097
+ if (!isLeaf && (this.verified.get(cur) ?? 0) > now4) continue;
1078
1098
  let st;
1079
1099
  try {
1080
1100
  st = await fsp.lstat(cur);
@@ -1082,6 +1102,10 @@ var init_NodeDiskFilesystem = __esm({
1082
1102
  return;
1083
1103
  }
1084
1104
  if (st.isSymbolicLink()) throw new Error("File not found: symlink not permitted");
1105
+ if (!isLeaf) {
1106
+ if (this.verified.size > 1e4) this.verified.clear();
1107
+ this.verified.set(cur, now4 + _NodeDiskFilesystem.VERIFY_TTL_MS);
1108
+ }
1085
1109
  }
1086
1110
  }
1087
1111
  resolvePath(path, cwd) {
@@ -1446,6 +1470,15 @@ __export(tools_shell_exports, {
1446
1470
  makeRealShellTool: () => makeRealShellTool,
1447
1471
  makeShellJobTools: () => makeShellJobTools
1448
1472
  });
1473
+ function killGroup(proc, signal) {
1474
+ if (!proc?.pid) return false;
1475
+ try {
1476
+ process.kill(-proc.pid, signal);
1477
+ return true;
1478
+ } catch {
1479
+ return false;
1480
+ }
1481
+ }
1449
1482
  async function spawnArgvFor(command, cwd, osSandbox) {
1450
1483
  if (!osSandbox) return { bin: "/bin/sh", args: ["-c", command] };
1451
1484
  const opts = osSandbox === true ? {} : osSandbox;
@@ -1468,7 +1501,7 @@ function makeRealShellTool(options) {
1468
1501
  const timeoutMs = options.timeoutMs ?? 12e4;
1469
1502
  return {
1470
1503
  name: "Shell",
1471
- description: "Run a shell command via /bin/sh in the working directory. Executes any installed binary \u2014 ls, cat, grep, git, bun, node, curl, scripts, etc. Returns combined stdout+stderr; non-zero exits are prefixed `[exit N]`. Set `background:true` for long-running processes (servers, watchers) \u2014 returns a job id immediately; poll with ShellOutput, stop with ShellKill.",
1504
+ description: "Run a shell command via /bin/sh in the working directory. Executes any installed binary \u2014 ls, cat, grep, git, bun, node, curl, scripts, etc. Returns combined stdout+stderr; non-zero exits are prefixed `[exit N]`. Runs non-interactively with no terminal (stdin is /dev/null): commands that prompt for input fail fast rather than hang \u2014 for privileged actions use a non-interactive flag (e.g. `sudo -n`), or ask the user to run the command themselves. Set `background:true` for long-running processes (servers, watchers) \u2014 returns a job id immediately; poll with ShellOutput, stop with ShellKill.",
1472
1505
  parameters: {
1473
1506
  type: "object",
1474
1507
  required: ["command"],
@@ -1528,10 +1561,12 @@ function makeRealShellTool(options) {
1528
1561
  };
1529
1562
  let proc;
1530
1563
  try {
1531
- proc = spawn(argv.bin, argv.args, { cwd: options.cwd, env: childEnv(options), signal: ctl.signal });
1564
+ proc = spawn(argv.bin, argv.args, { cwd: options.cwd, env: childEnv(options), signal: ctl.signal, ...DETACHED });
1532
1565
  } catch (e) {
1533
1566
  return finish(`[exit 1] failed to spawn shell: ${e?.message ?? e}`);
1534
1567
  }
1568
+ if (ctl.signal.aborted) killGroup(proc, "SIGKILL");
1569
+ else ctl.signal.addEventListener("abort", () => killGroup(proc, "SIGKILL"), { once: true });
1535
1570
  const collect = (chunk) => {
1536
1571
  const s = typeof chunk === "string" ? chunk : chunk?.toString?.("utf8") ?? "";
1537
1572
  out += s;
@@ -1607,7 +1642,7 @@ ${clean(out) || "(no output yet)"}`;
1607
1642
  }
1608
1643
  ];
1609
1644
  }
1610
- var log4, clean, SECRET_ENV_RE, _spawn, ShellJobRegistry, NO_JOB2;
1645
+ var log4, clean, DETACHED, SECRET_ENV_RE, _spawn, ShellJobRegistry, NO_JOB2;
1611
1646
  var init_tools_shell = __esm({
1612
1647
  "src/tools.shell.ts"() {
1613
1648
  "use strict";
@@ -1617,6 +1652,7 @@ var init_tools_shell = __esm({
1617
1652
  init_shell_sandbox();
1618
1653
  log4 = forComponent("shell");
1619
1654
  clean = (s) => truncateOutput(redactSecrets(s.replace(/\n+$/, "")));
1655
+ DETACHED = { stdio: ["ignore", "pipe", "pipe"], detached: true };
1620
1656
  SECRET_ENV_RE = /(_API_KEY|_TOKEN|_SECRET|_PASSWORD|_PRIVATE_KEY|^AWS_|^GITHUB_TOKEN$|^OPENAI_|^ANTHROPIC_|^GOOGLE_|^GEMINI_|^GROQ_|^NPM_TOKEN$)/i;
1621
1657
  ShellJobRegistry = class {
1622
1658
  constructor(cfg) {
@@ -1637,7 +1673,7 @@ var init_tools_shell = __esm({
1637
1673
  try {
1638
1674
  const spawn = this.cfg.spawn ?? await nodeSpawn();
1639
1675
  const argv = this.cfg.osSandbox ? await spawnArgvFor(command, this.cfg.cwd, this.cfg.osSandbox) : { bin: "/bin/sh", args: ["-c", command] };
1640
- const proc = spawn(argv.bin, argv.args, { cwd: this.cfg.cwd, env: childEnv(this.cfg) });
1676
+ const proc = spawn(argv.bin, argv.args, { cwd: this.cfg.cwd, env: childEnv(this.cfg), ...DETACHED });
1641
1677
  job.proc = proc;
1642
1678
  proc.stdout?.on("data", append);
1643
1679
  proc.stderr?.on("data", append);
@@ -1676,9 +1712,11 @@ var init_tools_shell = __esm({
1676
1712
  const j = this.jobs.get(id);
1677
1713
  if (!j) return false;
1678
1714
  if (j.status === "running") {
1679
- try {
1680
- j.proc?.kill("SIGTERM");
1681
- } catch {
1715
+ if (!killGroup(j.proc, "SIGTERM")) {
1716
+ try {
1717
+ j.proc?.kill("SIGTERM");
1718
+ } catch {
1719
+ }
1682
1720
  }
1683
1721
  j.status = "killed";
1684
1722
  }
@@ -2835,6 +2873,8 @@ var AgentOptions = class {
2835
2873
  permissions;
2836
2874
  /** Opt-in syntax guardrail: refuse to persist a syntactically-broken code-file write/edit. Default off. */
2837
2875
  lintOnWrite;
2876
+ /** Optional PDF text extraction for Read on .pdf files (node hosts wire pdftotext); absent => Read explains. */
2877
+ pdfText;
2838
2878
  /** Opt-in: after a write-class tool runs, run `command` over the VFS and append any failure to the tool result.
2839
2879
  * `tools` defaults to ['Write','Edit','MultiEdit','ApplyEdits']. */
2840
2880
  autoTest;
@@ -2885,8 +2925,12 @@ var Agent = class _Agent {
2885
2925
  reprepare() {
2886
2926
  this.prepared = false;
2887
2927
  }
2888
- /** Inject tools into a running agent (e.g. dynamically mounted MCP servers). Takes effect on the next turn. */
2928
+ /** Tools injected via addTools(); kept separate from options.tools so prepare() rebuilds don't drop them. */
2929
+ injectedTools = [];
2930
+ /** Inject tools into a running agent (e.g. dynamically mounted MCP servers). Takes effect on the next turn
2931
+ * and survives prepare() rebuilds (reprepare(), new conversations). */
2889
2932
  addTools(tools) {
2933
+ this.injectedTools.push(...tools);
2890
2934
  this.activeTools.push(...tools);
2891
2935
  }
2892
2936
  /** Remove tools by name from a running agent. Returns the count removed. */
@@ -2894,6 +2938,7 @@ var Agent = class _Agent {
2894
2938
  const s = names instanceof Set ? names : new Set(names);
2895
2939
  const before = this.activeTools.length;
2896
2940
  this.activeTools = this.activeTools.filter((t) => !s.has(t.name));
2941
+ this.injectedTools = this.injectedTools.filter((t) => !s.has(t.name));
2897
2942
  return before - this.activeTools.length;
2898
2943
  }
2899
2944
  constructor(options) {
@@ -2905,6 +2950,7 @@ var Agent = class _Agent {
2905
2950
  this.ctx = makeContext(this.options.fs, this.options.host);
2906
2951
  this.ctx.signal = this.options.signal;
2907
2952
  if (this.options.lintOnWrite) this.ctx.lint = checkSyntax;
2953
+ if (this.options.pdfText) this.ctx.pdfText = this.options.pdfText;
2908
2954
  this.ctx.ai = this.options.ai;
2909
2955
  this.ctx.model = this.options.model;
2910
2956
  this.ctx.parkHuman = (p) => this.park(p);
@@ -2977,7 +3023,7 @@ var Agent = class _Agent {
2977
3023
  const plan = o.planMode ? planMode({ host: o.host }) : void 0;
2978
3024
  if (plan) tools = [...tools, plan.tool];
2979
3025
  this.activeHooks = composeHooks(o.hooks, plan?.hooks, o.permissions?.hooks());
2980
- this.activeTools = tools;
3026
+ this.activeTools = [...tools, ...this.injectedTools];
2981
3027
  this.systemPromptCache = systemPrompt;
2982
3028
  this.prepared = true;
2983
3029
  return systemPrompt;
@@ -3112,7 +3158,15 @@ var Agent = class _Agent {
3112
3158
  } catch (err) {
3113
3159
  if (err?.code === "budget") return kill("budget");
3114
3160
  if (o.signal?.aborted || isAbortError(err)) return kill("aborted");
3115
- log3.error(`chat() failed: ${err?.message ?? err}`, err);
3161
+ const body = err?.body ?? err?.response?.data ?? err?.error;
3162
+ let bodyStr;
3163
+ try {
3164
+ bodyStr = body && typeof body !== "string" ? JSON.stringify(body).slice(0, 2e3) : body;
3165
+ } catch {
3166
+ bodyStr = void 0;
3167
+ }
3168
+ if (bodyStr && err instanceof Error && !err.message.includes(bodyStr)) err.detail = bodyStr;
3169
+ log3.error(`chat() failed: ${err?.message ?? err}${bodyStr ? ` \u2014 ${bodyStr}` : ""}`, err);
3116
3170
  return { text: "", steps, finishReason: "error", messages: this.transcript, usage, usageEstimated, error: err };
3117
3171
  }
3118
3172
  if (o.signal?.aborted) return kill("aborted");
@@ -3343,20 +3397,28 @@ function stubOldToolResults(messages, keep) {
3343
3397
  return { ...x, content: `[${x.name ?? "tool"}${where ? ` ${where}` : ""} output elided \u2014 ${lines} lines; re-run the tool to view]` };
3344
3398
  });
3345
3399
  }
3346
- var hasCallFor = (msgs, id) => msgs.some((m) => m.role === "assistant" && m.tool_calls?.some((tc) => tc.id === id));
3400
+ var callIdSet = (msgs) => {
3401
+ const ids = /* @__PURE__ */ new Set();
3402
+ for (const m of msgs) if (m.role === "assistant") for (const tc of m.tool_calls ?? []) ids.add(tc.id);
3403
+ return ids;
3404
+ };
3347
3405
  function dropOrphanToolResults(messages) {
3348
- const ok = (m) => m.role !== "tool" || hasCallFor(messages, m.tool_call_id);
3406
+ const ids = callIdSet(messages);
3407
+ const ok = (m) => m.role !== "tool" || ids.has(m.tool_call_id ?? "");
3349
3408
  return messages.every(ok) ? messages : messages.filter(ok);
3350
3409
  }
3351
3410
  function fitTokenBudget(messages, maxTokens) {
3352
- if (estimateTokens(messages) <= maxTokens) return messages;
3411
+ const per = messages.map((x) => estimateTokens([x]));
3412
+ let total = per.reduce((a, b) => a + b, 0);
3413
+ if (total <= maxTokens) return messages;
3353
3414
  const head = messages[0]?.role === "system" ? [messages[0]] : [];
3354
- let body = messages.slice(head.length);
3355
- while (body.length && estimateTokens([...head, ...body]) > maxTokens) body = body.slice(1);
3356
- while (body.length && body[0].role === "tool" && !hasCallFor(body, body[0].tool_call_id)) body = body.slice(1);
3357
- if (estimateTokens([...head, ...body]) > maxTokens)
3358
- log3.warn(`context ~${estimateTokens([...head, ...body])} tok still over maxContextTokens=${maxTokens} after trimming (system head can't be dropped)`);
3359
- return [...head, ...body];
3415
+ let from = head.length;
3416
+ while (from < messages.length && total > maxTokens) total -= per[from++];
3417
+ const ids = callIdSet(messages.slice(from));
3418
+ while (from < messages.length && messages[from].role === "tool" && !ids.has(messages[from].tool_call_id ?? "")) total -= per[from++];
3419
+ if (total > maxTokens)
3420
+ log3.warn(`context ~${total} tok still over maxContextTokens=${maxTokens} after trimming (system head can't be dropped)`);
3421
+ return [...head, ...messages.slice(from)];
3360
3422
  }
3361
3423
  function compact(m, max, focus) {
3362
3424
  const hasSystem = m[0]?.role === "system";
@@ -3899,11 +3961,11 @@ var Scheduler = class {
3899
3961
  this.jobs.clear();
3900
3962
  }
3901
3963
  };
3902
- function makeScheduleTools(scheduler) {
3964
+ function makeScheduleTools(scheduler, os) {
3903
3965
  return [
3904
3966
  {
3905
3967
  name: "ScheduleTask",
3906
- description: 'Schedule a prompt to fire automatically while this session is alive.\nModes:\n \u2022 One-off: {at: <epoch_ms>} \u2014 fires once at that time, then done.\n \u2022 Interval: {everyMs: <ms>} \u2014 fires repeatedly (\u22651s).\n \u2022 Cron: {cron: "min hr dom mon dow"} \u2014 standard 5-field cron.\nReturns the job id. Jobs only fire while the CLI session is running \u2014 they do NOT survive quitting.',
3968
+ description: 'Schedule a prompt to fire automatically.\nModes:\n \u2022 One-off: {at: <epoch_ms>} \u2014 fires once at that time, then done.\n \u2022 Interval: {everyMs: <ms>} \u2014 fires repeatedly (\u22651s).\n \u2022 Cron: {cron: "min hr dom mon dow"} \u2014 standard 5-field cron.\nBackend: "session" fires while this CLI session is alive (default for recurring + near one-offs); "os" registers with the OS scheduler so the job survives quitting \u2014 it headless-resumes this session when it fires (auto-chosen for one-offs \u226530min out when available). Pass backend:"os" explicitly for recurring jobs that must outlive the session.',
3907
3969
  parameters: {
3908
3970
  type: "object",
3909
3971
  required: ["prompt", "trigger"],
@@ -3918,15 +3980,22 @@ function makeScheduleTools(scheduler) {
3918
3980
  cron: { type: "string" }
3919
3981
  }
3920
3982
  },
3921
- label: { type: "string", description: "Short label for display (optional)." }
3983
+ label: { type: "string", description: "Short label for display (optional)." },
3984
+ backend: { type: "string", enum: ["auto", "session", "os"], description: "Where the job lives (default auto)." }
3922
3985
  }
3923
3986
  },
3924
- async run({ prompt, trigger, label }) {
3987
+ async run({ prompt, trigger, label, backend }) {
3925
3988
  try {
3989
+ if (os && os.route(trigger, backend) === "os") {
3990
+ const id2 = `os-${Date.now().toString(36)}`;
3991
+ const mechanism = os.schedule({ id: id2, prompt, sessionId: os.sessionId, cwd: os.cwd, trigger, label });
3992
+ return `Scheduled ${id2}${label ? ` (${label})` : ""} on the OS scheduler (${mechanism}) \u2014 survives quitting; fires \`agentx --resume ${os.sessionId}\` headless.`;
3993
+ }
3994
+ if (backend === "os") return "Error: no OS scheduler available on this platform \u2014 job not created (use the default in-session backend).";
3926
3995
  const id = scheduler.add({ prompt, trigger, label });
3927
3996
  const job = scheduler.get(id);
3928
3997
  const next = scheduler.nextFire(job);
3929
- return `Scheduled ${id}${label ? ` (${label})` : ""}. Next fire: ${next ? new Date(next).toLocaleString() : "now"}.`;
3998
+ return `Scheduled ${id}${label ? ` (${label})` : ""}. Next fire: ${next ? new Date(next).toLocaleString() : "now"}. (In-session: does not survive quitting.)`;
3930
3999
  } catch (e) {
3931
4000
  return `Error: ${e?.message ?? e}`;
3932
4001
  }
@@ -3934,24 +4003,28 @@ function makeScheduleTools(scheduler) {
3934
4003
  },
3935
4004
  {
3936
4005
  name: "ScheduleList",
3937
- description: "List all scheduled jobs and their next fire time.",
4006
+ description: "List all scheduled jobs (in-session + OS-backed) and their next fire time.",
3938
4007
  parameters: { type: "object", properties: {} },
3939
4008
  async run() {
4009
+ const osJobs = os?.list() ?? [];
4010
+ const osLines = osJobs.map((j) => `${j.id} os ${j.mechanism}${j.label ? " " + j.label : ""}`);
3940
4011
  const jobs = scheduler.list();
3941
- if (!jobs.length) return "(no scheduled jobs)";
3942
- return jobs.map((j) => {
4012
+ if (!jobs.length && !osLines.length) return "(no scheduled jobs)";
4013
+ return [...osLines, ...jobs.map((j) => {
3943
4014
  const next = scheduler.nextFire(j);
3944
4015
  const trig = "at" in j.trigger ? `once @ ${new Date(j.trigger.at).toLocaleString()}` : "everyMs" in j.trigger ? `every ${(j.trigger.everyMs / 1e3).toFixed(0)}s` : `cron: ${j.trigger.cron}`;
3945
4016
  return `${j.id} ${j.status} ${trig} runs:${j.runs} next:${next ? new Date(next).toLocaleTimeString() : "\u2014"}${j.label ? " " + j.label : ""}`;
3946
- }).join("\n");
4017
+ })].join("\n");
3947
4018
  }
3948
4019
  },
3949
4020
  {
3950
4021
  name: "ScheduleCancel",
3951
- description: "Cancel a scheduled job by id.",
4022
+ description: "Cancel a scheduled job by id (in-session or OS-backed).",
3952
4023
  parameters: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
3953
4024
  async run({ id }) {
3954
- return scheduler.cancel(String(id)) ? `Cancelled ${id}.` : `Error: no scheduled job '${id}'. Use ScheduleList to see jobs.`;
4025
+ const key = String(id);
4026
+ if (key.startsWith("os-")) return os?.cancel(key) ? `Cancelled ${key} (OS job removed).` : `Error: no OS job '${key}'.`;
4027
+ return scheduler.cancel(key) ? `Cancelled ${key}.` : `Error: no scheduled job '${key}'. Use ScheduleList to see jobs.`;
3955
4028
  }
3956
4029
  },
3957
4030
  {
@@ -4262,6 +4335,9 @@ var DuplexAgentOptions = class {
4262
4335
  askRelay = false;
4263
4336
  /** Parked questions auto-resolve empty after this long (callers map '' to deny/best-judgment). */
4264
4337
  askTimeoutMs = 12e4;
4338
+ /** Max retained task records: oldest SETTLED tasks (and their activity tails) are evicted past this,
4339
+ * bounding memory over a long-lived session. Running tasks are never evicted. */
4340
+ maxTaskRecords = 50;
4265
4341
  /** Host overrides for QuickLook lookups (keyed by `what`). The engine's defaults go through the
4266
4342
  * (possibly jailed) fs — e.g. `.git/**` is deny-listed, so the CLI supplies 'branch' itself. */
4267
4343
  quickLook;
@@ -4552,6 +4628,11 @@ ${recent}` : brief) + verify;
4552
4628
  };
4553
4629
  const promise = new Agent(agentOpts).run(briefText).then((res) => this.maybeVerify(id, briefText, res, tier, agentOpts)).then((res) => this.onWorkerSettled(id, res)).catch((err) => this.onWorkerFailed(id, err));
4554
4630
  this.tasks.set(id, { id, label, status: "running", controller, promise, tail });
4631
+ if (this.tasks.size > this.options.maxTaskRecords)
4632
+ for (const [tid, rec] of this.tasks) {
4633
+ if (this.tasks.size <= this.options.maxTaskRecords) break;
4634
+ if (rec.status !== "running") this.tasks.delete(tid);
4635
+ }
4555
4636
  }
4556
4637
  /** Fresh-context check of a successful Act task: a NEW agent (same model/fs/tools, but NO shared
4557
4638
  * conversation context) re-reads the file state against the brief and fixes any gap. The fix lands