agent.libx.js 0.94.22 → 0.94.24

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) {
@@ -2835,6 +2859,8 @@ var AgentOptions = class {
2835
2859
  permissions;
2836
2860
  /** Opt-in syntax guardrail: refuse to persist a syntactically-broken code-file write/edit. Default off. */
2837
2861
  lintOnWrite;
2862
+ /** Optional PDF text extraction for Read on .pdf files (node hosts wire pdftotext); absent => Read explains. */
2863
+ pdfText;
2838
2864
  /** Opt-in: after a write-class tool runs, run `command` over the VFS and append any failure to the tool result.
2839
2865
  * `tools` defaults to ['Write','Edit','MultiEdit','ApplyEdits']. */
2840
2866
  autoTest;
@@ -2885,8 +2911,12 @@ var Agent = class _Agent {
2885
2911
  reprepare() {
2886
2912
  this.prepared = false;
2887
2913
  }
2888
- /** Inject tools into a running agent (e.g. dynamically mounted MCP servers). Takes effect on the next turn. */
2914
+ /** Tools injected via addTools(); kept separate from options.tools so prepare() rebuilds don't drop them. */
2915
+ injectedTools = [];
2916
+ /** Inject tools into a running agent (e.g. dynamically mounted MCP servers). Takes effect on the next turn
2917
+ * and survives prepare() rebuilds (reprepare(), new conversations). */
2889
2918
  addTools(tools) {
2919
+ this.injectedTools.push(...tools);
2890
2920
  this.activeTools.push(...tools);
2891
2921
  }
2892
2922
  /** Remove tools by name from a running agent. Returns the count removed. */
@@ -2894,6 +2924,7 @@ var Agent = class _Agent {
2894
2924
  const s = names instanceof Set ? names : new Set(names);
2895
2925
  const before = this.activeTools.length;
2896
2926
  this.activeTools = this.activeTools.filter((t) => !s.has(t.name));
2927
+ this.injectedTools = this.injectedTools.filter((t) => !s.has(t.name));
2897
2928
  return before - this.activeTools.length;
2898
2929
  }
2899
2930
  constructor(options) {
@@ -2905,6 +2936,7 @@ var Agent = class _Agent {
2905
2936
  this.ctx = makeContext(this.options.fs, this.options.host);
2906
2937
  this.ctx.signal = this.options.signal;
2907
2938
  if (this.options.lintOnWrite) this.ctx.lint = checkSyntax;
2939
+ if (this.options.pdfText) this.ctx.pdfText = this.options.pdfText;
2908
2940
  this.ctx.ai = this.options.ai;
2909
2941
  this.ctx.model = this.options.model;
2910
2942
  this.ctx.parkHuman = (p) => this.park(p);
@@ -2977,7 +3009,7 @@ var Agent = class _Agent {
2977
3009
  const plan = o.planMode ? planMode({ host: o.host }) : void 0;
2978
3010
  if (plan) tools = [...tools, plan.tool];
2979
3011
  this.activeHooks = composeHooks(o.hooks, plan?.hooks, o.permissions?.hooks());
2980
- this.activeTools = tools;
3012
+ this.activeTools = [...tools, ...this.injectedTools];
2981
3013
  this.systemPromptCache = systemPrompt;
2982
3014
  this.prepared = true;
2983
3015
  return systemPrompt;
@@ -3112,7 +3144,15 @@ var Agent = class _Agent {
3112
3144
  } catch (err) {
3113
3145
  if (err?.code === "budget") return kill("budget");
3114
3146
  if (o.signal?.aborted || isAbortError(err)) return kill("aborted");
3115
- log3.error(`chat() failed: ${err?.message ?? err}`, err);
3147
+ const body = err?.body ?? err?.response?.data ?? err?.error;
3148
+ let bodyStr;
3149
+ try {
3150
+ bodyStr = body && typeof body !== "string" ? JSON.stringify(body).slice(0, 2e3) : body;
3151
+ } catch {
3152
+ bodyStr = void 0;
3153
+ }
3154
+ if (bodyStr && err instanceof Error && !err.message.includes(bodyStr)) err.detail = bodyStr;
3155
+ log3.error(`chat() failed: ${err?.message ?? err}${bodyStr ? ` \u2014 ${bodyStr}` : ""}`, err);
3116
3156
  return { text: "", steps, finishReason: "error", messages: this.transcript, usage, usageEstimated, error: err };
3117
3157
  }
3118
3158
  if (o.signal?.aborted) return kill("aborted");
@@ -3343,20 +3383,28 @@ function stubOldToolResults(messages, keep) {
3343
3383
  return { ...x, content: `[${x.name ?? "tool"}${where ? ` ${where}` : ""} output elided \u2014 ${lines} lines; re-run the tool to view]` };
3344
3384
  });
3345
3385
  }
3346
- var hasCallFor = (msgs, id) => msgs.some((m) => m.role === "assistant" && m.tool_calls?.some((tc) => tc.id === id));
3386
+ var callIdSet = (msgs) => {
3387
+ const ids = /* @__PURE__ */ new Set();
3388
+ for (const m of msgs) if (m.role === "assistant") for (const tc of m.tool_calls ?? []) ids.add(tc.id);
3389
+ return ids;
3390
+ };
3347
3391
  function dropOrphanToolResults(messages) {
3348
- const ok = (m) => m.role !== "tool" || hasCallFor(messages, m.tool_call_id);
3392
+ const ids = callIdSet(messages);
3393
+ const ok = (m) => m.role !== "tool" || ids.has(m.tool_call_id ?? "");
3349
3394
  return messages.every(ok) ? messages : messages.filter(ok);
3350
3395
  }
3351
3396
  function fitTokenBudget(messages, maxTokens) {
3352
- if (estimateTokens(messages) <= maxTokens) return messages;
3397
+ const per = messages.map((x) => estimateTokens([x]));
3398
+ let total = per.reduce((a, b) => a + b, 0);
3399
+ if (total <= maxTokens) return messages;
3353
3400
  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];
3401
+ let from = head.length;
3402
+ while (from < messages.length && total > maxTokens) total -= per[from++];
3403
+ const ids = callIdSet(messages.slice(from));
3404
+ while (from < messages.length && messages[from].role === "tool" && !ids.has(messages[from].tool_call_id ?? "")) total -= per[from++];
3405
+ if (total > maxTokens)
3406
+ log3.warn(`context ~${total} tok still over maxContextTokens=${maxTokens} after trimming (system head can't be dropped)`);
3407
+ return [...head, ...messages.slice(from)];
3360
3408
  }
3361
3409
  function compact(m, max, focus) {
3362
3410
  const hasSystem = m[0]?.role === "system";
@@ -3899,11 +3947,11 @@ var Scheduler = class {
3899
3947
  this.jobs.clear();
3900
3948
  }
3901
3949
  };
3902
- function makeScheduleTools(scheduler) {
3950
+ function makeScheduleTools(scheduler, os) {
3903
3951
  return [
3904
3952
  {
3905
3953
  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.',
3954
+ 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
3955
  parameters: {
3908
3956
  type: "object",
3909
3957
  required: ["prompt", "trigger"],
@@ -3918,15 +3966,22 @@ function makeScheduleTools(scheduler) {
3918
3966
  cron: { type: "string" }
3919
3967
  }
3920
3968
  },
3921
- label: { type: "string", description: "Short label for display (optional)." }
3969
+ label: { type: "string", description: "Short label for display (optional)." },
3970
+ backend: { type: "string", enum: ["auto", "session", "os"], description: "Where the job lives (default auto)." }
3922
3971
  }
3923
3972
  },
3924
- async run({ prompt, trigger, label }) {
3973
+ async run({ prompt, trigger, label, backend }) {
3925
3974
  try {
3975
+ if (os && os.route(trigger, backend) === "os") {
3976
+ const id2 = `os-${Date.now().toString(36)}`;
3977
+ const mechanism = os.schedule({ id: id2, prompt, sessionId: os.sessionId, cwd: os.cwd, trigger, label });
3978
+ return `Scheduled ${id2}${label ? ` (${label})` : ""} on the OS scheduler (${mechanism}) \u2014 survives quitting; fires \`agentx --resume ${os.sessionId}\` headless.`;
3979
+ }
3980
+ if (backend === "os") return "Error: no OS scheduler available on this platform \u2014 job not created (use the default in-session backend).";
3926
3981
  const id = scheduler.add({ prompt, trigger, label });
3927
3982
  const job = scheduler.get(id);
3928
3983
  const next = scheduler.nextFire(job);
3929
- return `Scheduled ${id}${label ? ` (${label})` : ""}. Next fire: ${next ? new Date(next).toLocaleString() : "now"}.`;
3984
+ return `Scheduled ${id}${label ? ` (${label})` : ""}. Next fire: ${next ? new Date(next).toLocaleString() : "now"}. (In-session: does not survive quitting.)`;
3930
3985
  } catch (e) {
3931
3986
  return `Error: ${e?.message ?? e}`;
3932
3987
  }
@@ -3934,24 +3989,28 @@ function makeScheduleTools(scheduler) {
3934
3989
  },
3935
3990
  {
3936
3991
  name: "ScheduleList",
3937
- description: "List all scheduled jobs and their next fire time.",
3992
+ description: "List all scheduled jobs (in-session + OS-backed) and their next fire time.",
3938
3993
  parameters: { type: "object", properties: {} },
3939
3994
  async run() {
3995
+ const osJobs = os?.list() ?? [];
3996
+ const osLines = osJobs.map((j) => `${j.id} os ${j.mechanism}${j.label ? " " + j.label : ""}`);
3940
3997
  const jobs = scheduler.list();
3941
- if (!jobs.length) return "(no scheduled jobs)";
3942
- return jobs.map((j) => {
3998
+ if (!jobs.length && !osLines.length) return "(no scheduled jobs)";
3999
+ return [...osLines, ...jobs.map((j) => {
3943
4000
  const next = scheduler.nextFire(j);
3944
4001
  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
4002
  return `${j.id} ${j.status} ${trig} runs:${j.runs} next:${next ? new Date(next).toLocaleTimeString() : "\u2014"}${j.label ? " " + j.label : ""}`;
3946
- }).join("\n");
4003
+ })].join("\n");
3947
4004
  }
3948
4005
  },
3949
4006
  {
3950
4007
  name: "ScheduleCancel",
3951
- description: "Cancel a scheduled job by id.",
4008
+ description: "Cancel a scheduled job by id (in-session or OS-backed).",
3952
4009
  parameters: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
3953
4010
  async run({ id }) {
3954
- return scheduler.cancel(String(id)) ? `Cancelled ${id}.` : `Error: no scheduled job '${id}'. Use ScheduleList to see jobs.`;
4011
+ const key = String(id);
4012
+ if (key.startsWith("os-")) return os?.cancel(key) ? `Cancelled ${key} (OS job removed).` : `Error: no OS job '${key}'.`;
4013
+ return scheduler.cancel(key) ? `Cancelled ${key}.` : `Error: no scheduled job '${key}'. Use ScheduleList to see jobs.`;
3955
4014
  }
3956
4015
  },
3957
4016
  {
@@ -4262,6 +4321,9 @@ var DuplexAgentOptions = class {
4262
4321
  askRelay = false;
4263
4322
  /** Parked questions auto-resolve empty after this long (callers map '' to deny/best-judgment). */
4264
4323
  askTimeoutMs = 12e4;
4324
+ /** Max retained task records: oldest SETTLED tasks (and their activity tails) are evicted past this,
4325
+ * bounding memory over a long-lived session. Running tasks are never evicted. */
4326
+ maxTaskRecords = 50;
4265
4327
  /** Host overrides for QuickLook lookups (keyed by `what`). The engine's defaults go through the
4266
4328
  * (possibly jailed) fs — e.g. `.git/**` is deny-listed, so the CLI supplies 'branch' itself. */
4267
4329
  quickLook;
@@ -4552,6 +4614,11 @@ ${recent}` : brief) + verify;
4552
4614
  };
4553
4615
  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
4616
  this.tasks.set(id, { id, label, status: "running", controller, promise, tail });
4617
+ if (this.tasks.size > this.options.maxTaskRecords)
4618
+ for (const [tid, rec] of this.tasks) {
4619
+ if (this.tasks.size <= this.options.maxTaskRecords) break;
4620
+ if (rec.status !== "running") this.tasks.delete(tid);
4621
+ }
4555
4622
  }
4556
4623
  /** Fresh-context check of a successful Act task: a NEW agent (same model/fs/tools, but NO shared
4557
4624
  * conversation context) re-reads the file state against the brief and fixes any gap. The fix lands