agent.libx.js 0.93.29 → 0.93.31

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/cli.js CHANGED
@@ -26,7 +26,10 @@ var init_redact = __esm({
26
26
  });
27
27
 
28
28
  // src/tools.structured.ts
29
- async function walkFiles(fs, dir, out = []) {
29
+ function ckAbort(signal) {
30
+ if (signal?.aborted) throw new Error("aborted");
31
+ }
32
+ async function walkFiles(fs, dir, signal, out = []) {
30
33
  let entries;
31
34
  try {
32
35
  entries = await fs.readDir(dir);
@@ -34,8 +37,9 @@ async function walkFiles(fs, dir, out = []) {
34
37
  return out;
35
38
  }
36
39
  for (const name of entries.sort()) {
40
+ ckAbort(signal);
37
41
  const p = dir === "/" ? `/${name}` : `${dir}/${name}`;
38
- if (await fs.isDirectory(p)) await walkFiles(fs, p, out);
42
+ if (await fs.isDirectory(p)) await walkFiles(fs, p, signal, out);
39
43
  else out.push(p);
40
44
  }
41
45
  return out;
@@ -92,13 +96,14 @@ function signaturesOf(content, cap = 40) {
92
96
  }
93
97
  return out;
94
98
  }
95
- async function repoIndex(fs, glob, mode = "code") {
99
+ async function repoIndex(fs, glob, mode = "code", signal) {
96
100
  const scope = glob ? anchoredGlob(fs, String(glob)) : null;
97
101
  const filter = mode === "code" ? isCode : mode === "docs" ? isDoc : (p) => isCode(p) || isDoc(p);
98
- const files = (await walkFiles(fs, fsCwd(fs))).filter((p) => scope ? scope.test(p) : filter(p));
102
+ const files = (await walkFiles(fs, fsCwd(fs), signal)).filter((p) => scope ? scope.test(p) : filter(p));
99
103
  const blocks = [];
100
104
  let shown = 0;
101
105
  for (const path of files) {
106
+ ckAbort(signal);
102
107
  let content;
103
108
  try {
104
109
  content = await fs.readFile(path);
@@ -218,7 +223,7 @@ var init_tools_structured = __esm({
218
223
  const include = pats.filter((p) => !p.startsWith("!")).map((p) => anchoredGlob(ctx.fs, p));
219
224
  const exclude = pats.filter((p) => p.startsWith("!")).map((p) => anchoredGlob(ctx.fs, p.slice(1)));
220
225
  const includes = include.length ? include : [anchoredGlob(ctx.fs, "**")];
221
- const hits = (await walkFiles(ctx.fs, fsCwd(ctx.fs))).filter(
226
+ const hits = (await walkFiles(ctx.fs, fsCwd(ctx.fs), ctx.signal)).filter(
222
227
  (p) => includes.some((re) => re.test(p)) && !exclude.some((re) => re.test(p))
223
228
  );
224
229
  return hits.length ? hits.join("\n") : "(no matches)";
@@ -245,11 +250,12 @@ var init_tools_structured = __esm({
245
250
  throw new Error(`invalid regex: ${String(e)}`);
246
251
  }
247
252
  const scope = glob ? anchoredGlob(ctx.fs, String(glob)) : null;
248
- const files = (await walkFiles(ctx.fs, fsCwd(ctx.fs))).filter((p) => !scope || scope.test(p));
253
+ const files = (await walkFiles(ctx.fs, fsCwd(ctx.fs), ctx.signal)).filter((p) => !scope || scope.test(p));
249
254
  const ctxN = Math.max(0, Number(context ?? 0));
250
255
  const out = [];
251
256
  const matched = [];
252
257
  for (const path of files) {
258
+ ckAbort(ctx.signal);
253
259
  let content;
254
260
  try {
255
261
  content = await ctx.fs.readFile(path);
@@ -285,7 +291,7 @@ var init_tools_structured = __esm({
285
291
  scope: { type: "string", enum: ["code", "docs", "all"], description: 'what to map: "code" (default), "docs", or "all"' }
286
292
  }
287
293
  },
288
- run: ({ glob, scope }, ctx) => repoIndex(ctx.fs, glob, scope || "code")
294
+ run: ({ glob, scope }, ctx) => repoIndex(ctx.fs, glob, scope || "code", ctx.signal)
289
295
  };
290
296
  writeTool = {
291
297
  name: "Write",
@@ -1671,7 +1677,7 @@ close access f`;
1671
1677
 
1672
1678
  // cli/cli.ts
1673
1679
  import { join as join9, resolve as resolve3, basename as basename2, extname, dirname as dirname4 } from "path";
1674
- import { AIClient, listModels, listProviders, getProviderFromModel, getModelInfo, resolveModel, isModelSupported } from "ai.libx.js";
1680
+ import { AIClient, listModels, listProviders, getProviderFromModel, getModelInfo, resolveModel, isModelSupported, disposeCursorSessions } from "ai.libx.js";
1675
1681
 
1676
1682
  // src/llm.ts
1677
1683
  function contentText(content) {
@@ -2002,9 +2008,9 @@ async function loadCommands(fs, dir, opts = {}) {
2002
2008
  properties: { name: { type: "string" }, args: { type: "string", description: "arguments to fill the template" } }
2003
2009
  },
2004
2010
  async run({ name, args }, ctx) {
2005
- const slug = String(name ?? "").replace(/^\//, "");
2006
- const c = commands.find((x) => x.name === slug);
2007
- if (!c) return `Error: no command named '${slug}'. Available: ${commands.map((x) => x.name).join(", ")}`;
2011
+ const slug2 = String(name ?? "").replace(/^\//, "");
2012
+ const c = commands.find((x) => x.name === slug2);
2013
+ if (!c) return `Error: no command named '${slug2}'. Available: ${commands.map((x) => x.name).join(", ")}`;
2008
2014
  return expandCommand(ctx.fs, c, String(args ?? ""));
2009
2015
  }
2010
2016
  };
@@ -2039,9 +2045,9 @@ async function loadMemory(fs, dir, opts = {}) {
2039
2045
  const lines = md.split("\n");
2040
2046
  if (!header) header = lines.filter((l) => !/^\s*-\s*\[.+\]\(.+\.md\)/.test(l)).join("\n").trim();
2041
2047
  for (const l of lines.filter((l2) => /^\s*-\s*\[.+\]\(.+\.md\)/.test(l2))) {
2042
- const slug = l.match(/\]\(([^)]+)\.md\)/)?.[1];
2043
- if (slug && !seenSlugs.has(slug)) {
2044
- seenSlugs.add(slug);
2048
+ const slug2 = l.match(/\]\(([^)]+)\.md\)/)?.[1];
2049
+ if (slug2 && !seenSlugs.has(slug2)) {
2050
+ seenSlugs.add(slug2);
2045
2051
  allPointers.push(l);
2046
2052
  }
2047
2053
  }
@@ -2057,20 +2063,20 @@ function slugify(s, fallback = "note") {
2057
2063
  const base = String(s ?? "").trim().toLowerCase().replace(/\.md$/i, "").replace(/[^\w\s-]/g, "").replace(/[\s_]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 48);
2058
2064
  return base || fallback;
2059
2065
  }
2060
- async function writeFact(fs, dir, slug, body, opts) {
2066
+ async function writeFact(fs, dir, slug2, body, opts) {
2061
2067
  await mkdirp(fs, dir);
2062
2068
  const content = opts?.type ? `---
2063
2069
  type: ${opts.type}
2064
2070
  ---
2065
2071
 
2066
2072
  ${body}` : body;
2067
- await fs.writeFile(`${dir}/${slug}.md`, content.endsWith("\n") ? content : content + "\n");
2073
+ await fs.writeFile(`${dir}/${slug2}.md`, content.endsWith("\n") ? content : content + "\n");
2068
2074
  const indexPath = `${dir}/MEMORY.md`;
2069
2075
  const idx = await fs.exists(indexPath) ? await fs.readFile(indexPath) : "# Memory Index\n";
2070
2076
  const summary = opts?.description || body.split("\n")[0].slice(0, 80);
2071
- const line = `- [${slug}](${slug}.md) \u2014 ${summary}`;
2077
+ const line = `- [${slug2}](${slug2}.md) \u2014 ${summary}`;
2072
2078
  const lines = idx.split("\n");
2073
- const at = lines.findIndex((l) => l.includes(`(${slug}.md)`));
2079
+ const at = lines.findIndex((l) => l.includes(`(${slug2}.md)`));
2074
2080
  if (at >= 0) {
2075
2081
  if (lines[at] !== line) {
2076
2082
  lines[at] = line;
@@ -2121,8 +2127,8 @@ async function listSlugs(fs, dir, prefix = "") {
2121
2127
  }
2122
2128
  return out;
2123
2129
  }
2124
- async function loadFact(fs, dir, slug) {
2125
- const path = `${dir}/${slug}.md`;
2130
+ async function loadFact(fs, dir, slug2) {
2131
+ const path = `${dir}/${slug2}.md`;
2126
2132
  try {
2127
2133
  const raw = await fs.readFile(path);
2128
2134
  const { body } = splitFrontmatter(raw);
@@ -2131,9 +2137,9 @@ async function loadFact(fs, dir, slug) {
2131
2137
  return null;
2132
2138
  }
2133
2139
  }
2134
- async function loadFactMulti(fs, dirs, slug) {
2140
+ async function loadFactMulti(fs, dirs, slug2) {
2135
2141
  for (const d of dirs) {
2136
- const r = await loadFact(fs, d, slug);
2142
+ const r = await loadFact(fs, d, slug2);
2137
2143
  if (r != null) return r;
2138
2144
  }
2139
2145
  return null;
@@ -2159,9 +2165,9 @@ function recallTool(fs, dirs) {
2159
2165
  pattern: { type: "string", description: 'glob pattern to match against slugs (e.g. "auth*", "*database*")' }
2160
2166
  }
2161
2167
  },
2162
- async run({ slug, slugs, pattern }, ctx) {
2168
+ async run({ slug: slug2, slugs, pattern }, ctx) {
2163
2169
  let targets = [];
2164
- if (slug) targets = [cleanSlug(slug)];
2170
+ if (slug2) targets = [cleanSlug(slug2)];
2165
2171
  else if (Array.isArray(slugs)) targets = slugs.map(cleanSlug).filter(Boolean);
2166
2172
  else if (pattern) {
2167
2173
  const escaped = String(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&");
@@ -2212,19 +2218,19 @@ function memorySearchTool(fs, dirs) {
2212
2218
  matcher = (l) => l.toLowerCase().includes(lower);
2213
2219
  }
2214
2220
  const loaded = [];
2215
- for (const slug of slugs) {
2216
- const body = await loadFactMulti(fs, dirs, slug);
2217
- if (body) loaded.push({ slug, body });
2221
+ for (const slug2 of slugs) {
2222
+ const body = await loadFactMulti(fs, dirs, slug2);
2223
+ if (body) loaded.push({ slug: slug2, body });
2218
2224
  }
2219
2225
  const idf = idfWeights(loaded.map((l) => l.body));
2220
2226
  const qTokens = tokenize(q2);
2221
2227
  const hits = [];
2222
- for (const { slug, body } of loaded) {
2228
+ for (const { slug: slug2, body } of loaded) {
2223
2229
  const lines = body.split("\n");
2224
2230
  const matchLine = lines.find(matcher);
2225
2231
  if (!matchLine && !relevanceScore(body, qTokens, idf)) continue;
2226
2232
  const snippet = matchLine?.trim().slice(0, 120) || lines.find((l) => l.trim())?.trim().slice(0, 120) || "";
2227
- hits.push({ slug, snippet, score: relevanceScore(body, qTokens, idf) });
2233
+ hits.push({ slug: slug2, snippet, score: relevanceScore(body, qTokens, idf) });
2228
2234
  }
2229
2235
  if (!hits.length) return "(no matches)";
2230
2236
  hits.sort((a, b) => b.score - a.score);
@@ -2248,11 +2254,11 @@ function rememberTool(fs, dir, memOpts = {}) {
2248
2254
  description: { type: "string", description: "one-line summary for the memory index (\u226480 chars)" }
2249
2255
  }
2250
2256
  },
2251
- async run({ fact, slug, type, description }, ctx) {
2257
+ async run({ fact, slug: slug2, type, description }, ctx) {
2252
2258
  const body = String(fact ?? "").trim();
2253
2259
  if (!body) return `Error: nothing to remember (empty fact).`;
2254
2260
  if (++writes > maxWrites) return `Rate limit: too many memories this session (${maxWrites}). Only persist genuinely durable facts.`;
2255
- const name = slugify(slug || body.split("\n")[0]);
2261
+ const name = slugify(slug2 || body.split("\n")[0]);
2256
2262
  const isGlobal = (type === "user" || type === "feedback") && memOpts.userDir;
2257
2263
  const targetDir = isGlobal ? memOpts.userDir : dir;
2258
2264
  await writeFact(fs, targetDir, name, body, { type, description });
@@ -2686,6 +2692,14 @@ var AgentOptions = class {
2686
2692
  /** Token-aware backstop (~4 chars/token estimate). After note-taking, drop oldest messages from the
2687
2693
  * sent context until the estimate is under this ceiling (pairing-safe). 0 = off. */
2688
2694
  maxContextTokens = 0;
2695
+ /** Pagination ceiling for a SINGLE tool result (bytes). A result over this is cropped to page 1 with
2696
+ * a marker telling the model it was cropped (refine the query, or page further). Guards against one
2697
+ * Grep/Read/MCP call blowing the whole context window. 0 = off. Default 60k (~15k tokens). */
2698
+ maxToolResultBytes = 6e4;
2699
+ /** Hook to handle an oversized tool result instead of the default lossy crop: receives the FULL output
2700
+ * and returns the (cropped) string to put in context — e.g. spill to scratch and return a recoverable,
2701
+ * paginated stub. Called only when a result exceeds `maxToolResultBytes`. */
2702
+ capToolResult;
2689
2703
  /** VFS dir(s) of skills (`<dir>/<id>/SKILL.md`). If set: inject a catalog + add the `Skill` tool. Multiple dirs are merged (first wins on name collisions). */
2690
2704
  skillsDir;
2691
2705
  /** VFS dir(s) of slash-command templates (`<dir>/<name>.md`). If set: inject a catalog + add the `SlashCommand` tool. Multiple dirs are merged (first wins). */
@@ -3008,6 +3022,7 @@ var Agent = class _Agent {
3008
3022
  toolCallsTotal += toolCalls.length;
3009
3023
  if (o.maxToolCalls && toolCallsTotal > o.maxToolCalls) return kill("max_tool_calls");
3010
3024
  for (const tc of toolCalls) {
3025
+ if (o.signal?.aborted) return kill("aborted");
3011
3026
  const raw = await this.dispatch(tc);
3012
3027
  let content;
3013
3028
  if (typeof raw === "string") {
@@ -3100,6 +3115,11 @@ var Agent = class _Agent {
3100
3115
  this.ctx.emit = void 0;
3101
3116
  }
3102
3117
  if (!threw) result = await this.maybeAutoTest(tc.function.name, result);
3118
+ const cap = this.options.maxToolResultBytes ?? 0;
3119
+ if (!threw && cap > 0 && result.length > cap) {
3120
+ const info = { tool: tc.function.name, args };
3121
+ result = this.options.capToolResult ? await this.options.capToolResult(result, info) : cropResult(result, cap);
3122
+ }
3103
3123
  await hooks?.postToolUse?.(call, result, meta);
3104
3124
  this.options.host?.notify?.({ kind: "tool_result", id: tc.id ?? "", output: result, isError: threw });
3105
3125
  if (images?.length) {
@@ -3153,6 +3173,15 @@ function estimateTokens(m) {
3153
3173
  for (const x of m) chars += contentText(x.content).length + (x.tool_calls ? JSON.stringify(x.tool_calls).length : 0);
3154
3174
  return Math.ceil(chars / 4);
3155
3175
  }
3176
+ function cropResult(result, cap) {
3177
+ const head = result.slice(0, cap);
3178
+ const nl = head.lastIndexOf("\n");
3179
+ const page = nl > cap * 0.5 ? head.slice(0, nl) : head;
3180
+ const omitted = result.length - page.length;
3181
+ return `${page}
3182
+
3183
+ [output cropped \u2014 showing ${page.length} of ${result.length} bytes; ${omitted} omitted. This is page 1. Refine your query/command to narrow it, or call the tool again with a tighter scope to see more.]`;
3184
+ }
3156
3185
  function stubOldToolResults(messages, keep) {
3157
3186
  const meta = /* @__PURE__ */ new Map();
3158
3187
  for (const msg of messages)
@@ -3473,6 +3502,127 @@ init_tools();
3473
3502
  init_tools_structured();
3474
3503
  init_logging();
3475
3504
  var log4 = forComponent("scratch");
3505
+ var SCRATCH_DIR = "/scratch";
3506
+ function shortArgs(args) {
3507
+ try {
3508
+ const s = JSON.stringify(args ?? {});
3509
+ return s.length > 50 ? s.slice(0, 47) + "\u2026" : s;
3510
+ } catch {
3511
+ return "";
3512
+ }
3513
+ }
3514
+ var slug = (s) => s.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase().slice(0, 32) || "out";
3515
+ var Scratch = class {
3516
+ constructor(fs, options = {}) {
3517
+ this.fs = fs;
3518
+ this.options = { threshold: 1500, dir: SCRATCH_DIR, previewChars: 320, ...options };
3519
+ }
3520
+ fs;
3521
+ options;
3522
+ seq = 0;
3523
+ dirReady;
3524
+ /** Number of captures so far. */
3525
+ get count() {
3526
+ return this.seq;
3527
+ }
3528
+ /** Wrap a tool: oversized STRING results spill to a scratch file; everything else passes through. */
3529
+ capture(tool) {
3530
+ const { threshold, dir, previewChars } = this.options;
3531
+ return {
3532
+ ...tool,
3533
+ run: async (args, ctx) => {
3534
+ const raw = await tool.run(args, ctx);
3535
+ if (typeof raw !== "string" || raw.length <= threshold) return raw;
3536
+ const id = "a" + ++this.seq;
3537
+ const path = `${dir}/${id}-${slug(tool.name)}.txt`;
3538
+ const header = `# ${tool.name}(${shortArgs(args)}) \u2014 ${raw.length} bytes
3539
+ `;
3540
+ try {
3541
+ await (this.dirReady ??= mkdirp(this.fs, dir));
3542
+ await this.fs.writeFile(path, header + raw);
3543
+ } catch (e) {
3544
+ log4.debug("scratch write failed; returning raw", e);
3545
+ return raw;
3546
+ }
3547
+ const preview = raw.slice(0, previewChars).replace(/\s+/g, " ").trim();
3548
+ return `[scratch ${path} \xB7 ${tool.name} \xB7 ${raw.length} bytes \u2014 full output saved out of context to keep it clean]
3549
+ preview: ${preview}\u2026
3550
+ To pull a specific detail, Grep/Read ${path}, or call Ask({ question: "\u2026", over: "${path}" }). Do NOT guess at what the preview cuts off.`;
3551
+ }
3552
+ };
3553
+ }
3554
+ /** Wrap many tools at once. */
3555
+ captureAll(tools) {
3556
+ return tools.map((t) => this.capture(t));
3557
+ }
3558
+ /**
3559
+ * Spill an oversized tool result to a scratch file and return PAGE 1 + a recoverable, paginated stub.
3560
+ * Drop-in for `Agent.capToolResult`: the agent sees usable content immediately and knows how to get
3561
+ * the rest (refine the query, Read the file in pages with offset/limit, or Ask to extract specifics).
3562
+ * Lossless — unlike a plain crop, the full output stays available on the scratch FS.
3563
+ */
3564
+ async spill(full, info, pageBytes = 8e3) {
3565
+ const { dir } = this.options;
3566
+ const id = "a" + ++this.seq;
3567
+ const path = `${dir}/${id}-${slug(info.tool)}.txt`;
3568
+ const header = `# ${info.tool}(${shortArgs(info.args)}) \u2014 ${full.length} bytes
3569
+ `;
3570
+ try {
3571
+ await (this.dirReady ??= mkdirp(this.fs, dir));
3572
+ await this.fs.writeFile(path, header + full);
3573
+ } catch (e) {
3574
+ log4.debug("scratch spill failed; cropping lossy", e);
3575
+ return full.slice(0, pageBytes) + `
3576
+
3577
+ [output cropped to ${pageBytes} of ${full.length} bytes; full output unavailable (scratch write failed) \u2014 refine your query]`;
3578
+ }
3579
+ const head = full.slice(0, pageBytes);
3580
+ const nl = head.lastIndexOf("\n");
3581
+ const page = nl > pageBytes * 0.5 ? head.slice(0, nl) : head;
3582
+ return `${page}
3583
+
3584
+ [output cropped \u2014 page 1 (${page.length} of ${full.length} bytes). Full output saved to ${path}. To see more: refine your query/command to narrow it, or Read ${path} with offset/limit to page through it, or Ask({ question: "\u2026", over: "${path}" }) to extract specifics.]`;
3585
+ }
3586
+ };
3587
+ var ASK_PROMPT = "You are a retrieval-extraction step with Read, Grep and Glob over a scratch filesystem holding raw outputs from earlier tools. Find the information that answers the question and return it concisely, quoting values/facts verbatim. Do NOT add analysis or anything not grounded in the files. If the answer is not present, say so plainly.";
3588
+ function makeAskTool(o) {
3589
+ const dir = o.dir ?? SCRATCH_DIR;
3590
+ return {
3591
+ name: "Ask",
3592
+ description: "Answer a question by peeking into the scratch files \u2014 large earlier outputs (web search, big reads, subagent reports) kept out of your context. Pass `over` with a scratch path for a targeted lookup, or omit it to search the scratch dir. Returns only the extracted answer; the full data never enters your context.",
3593
+ parameters: {
3594
+ type: "object",
3595
+ required: ["question"],
3596
+ properties: {
3597
+ question: { type: "string", description: "what you need from the scratch files" },
3598
+ over: { type: "string", description: 'scratch path to read, e.g. "/scratch/a3-websearch.txt"; omit to search' }
3599
+ }
3600
+ },
3601
+ async run({ question, over }) {
3602
+ const q2 = String(question ?? "").trim();
3603
+ if (!q2) return "Error: empty question";
3604
+ const child = new Agent({
3605
+ ai: o.ai,
3606
+ model: o.model,
3607
+ fs: o.fs,
3608
+ tools: toolsByName(["Read", "Grep", "Glob"]),
3609
+ maxSteps: o.maxSteps ?? 6,
3610
+ systemPrompt: ASK_PROMPT
3611
+ });
3612
+ const hint = over ? `Start by reading: ${over}.` : `Grep/Glob ${dir} to find the relevant file(s) first.`;
3613
+ try {
3614
+ const res = await child.run(`${hint}
3615
+
3616
+ Question: ${q2}`);
3617
+ const answer = (res.text ?? "").trim();
3618
+ return answer || "(no answer found in scratch)";
3619
+ } catch (e) {
3620
+ log4.debug("Ask peek failed", e);
3621
+ return `Error querying scratch: ${e?.message ?? e}`;
3622
+ }
3623
+ }
3624
+ };
3625
+ }
3476
3626
 
3477
3627
  // src/lessons.ts
3478
3628
  init_logging();
@@ -3531,15 +3681,15 @@ If the run was fine or the issue was purely task-specific (not generalizable), r
3531
3681
  const m = text.match(/LESSON:\s*(.+)/i);
3532
3682
  const lesson = m?.[1]?.trim().slice(0, 200) ?? "";
3533
3683
  if (!lesson || /^none\b/i.test(lesson)) return null;
3534
- const slug = ("lesson-" + slugify(lesson)).slice(0, 56);
3684
+ const slug2 = ("lesson-" + slugify(lesson)).slice(0, 56);
3535
3685
  try {
3536
- await writeFact(o.fs, o.dir, slug, lesson);
3686
+ await writeFact(o.fs, o.dir, slug2, lesson);
3537
3687
  } catch (e) {
3538
3688
  log6.warn(`could not persist lesson: ${e?.message ?? e}`);
3539
3689
  return null;
3540
3690
  }
3541
- log6.debug(`reflection persisted ${slug}`);
3542
- return slug;
3691
+ log6.debug(`reflection persisted ${slug2}`);
3692
+ return slug2;
3543
3693
  }
3544
3694
  function digestRun(messages, maxChars) {
3545
3695
  const lines = [];
@@ -3566,10 +3716,18 @@ var DuplexAgentOptions = class {
3566
3716
  ai;
3567
3717
  /** The WORKER's filesystem (act + think). If omitted the worker keeps Agent's jailed-disk-at-cwd default. */
3568
3718
  fs;
3569
- reflexModel = "groq/openai/gpt-oss-20b";
3719
+ // The reflex IS the voice. 120b (not 20b) for channel discipline + instruction-following: the 20b
3720
+ // mislabels gpt-oss harmony channels under load, leaking raw analysis into the spoken `final` channel
3721
+ // (and misfiring Hold). 120b is the same price tier (~$0.15/$0.60) — the quality/cost trade is free.
3722
+ reflexModel = "groq/openai/gpt-oss-120b";
3570
3723
  actModel = "anthropic/claude-sonnet-4-6";
3571
3724
  /** Premium reasoning model. Set to `false` to disable the Think tier entirely. */
3572
3725
  thinkModel = "anthropic/claude-opus-4-8";
3726
+ /** Per-worker providerOptions, derived from the worker's actual model at spawn time (IoC — keeps duplex
3727
+ * provider-agnostic). Workers override the reflex/main model, so provider-specific options (e.g. cursor's
3728
+ * cwd/cursorSession) must be recomputed for the worker's model, never inherited from the main template —
3729
+ * leaking cursor options to an anthropic worker is a hard 400. Returns undefined → no providerOptions. */
3730
+ providerOptionsFor;
3573
3731
  /** Escape hatches merged over the derived per-agent options. */
3574
3732
  reflexOptions;
3575
3733
  actOptions;
@@ -3648,7 +3806,12 @@ var DuplexAgent = class {
3648
3806
  const canSearch = workerToolNames.some((n) => /WebSearch/i.test(n));
3649
3807
  const canFetch = workerToolNames.some((n) => /WebFetch/i.test(n));
3650
3808
  const workerWeb = canSearch ? `, and it CAN search the web and read web pages \u2014 so when the user gives you something specific to look up ("search for X", "find me\u2026", "what's the latest on\u2026"), route it to Act. But a bare capability QUESTION like "can you search the web?" just gets a short spoken "yes, I can" \u2014 do NOT dispatch and NEVER invent a query the user did not give you` : canFetch ? ", and it can fetch a specific web page URL (but cannot search the web)" : "";
3651
- const prompt = VOICE_SYSTEM_PROMPT.replace("{{MEMORY_SLOT}}", memSlot).replace("{{THINK_SLOT}}", thinkSlot).replace("{{WORKER_WEB}}", workerWeb) + (o.voiceStyle === "conversational" ? "\n" + VOICE_STYLE_CONVERSATIONAL : "") + `
3809
+ const mcpNames = [
3810
+ ...Object.keys(o.actOptions?.providerOptions?.mcpServers ?? {}),
3811
+ ...new Set(workerToolNames.filter((n) => n.startsWith("mcp__")).map((n) => n.slice(5).split("__")[0]))
3812
+ ];
3813
+ const workerMcp = mcpNames.length ? `, and it can use these MCP servers: ${[...new Set(mcpNames)].join(", ")}` + (mcpNames.some((n) => /browser/i.test(n)) ? ' \u2014 including driving a REAL browser (open tabs, navigate, click, screenshot), so answer "yes" if asked whether you can control/drive a browser and route an actual browse to Act' : "") : "";
3814
+ const prompt = VOICE_SYSTEM_PROMPT.replace("{{MEMORY_SLOT}}", memSlot).replace("{{THINK_SLOT}}", thinkSlot).replace("{{WORKER_WEB}}", workerWeb + workerMcp) + (o.voiceStyle === "conversational" ? "\n" + VOICE_STYLE_CONVERSATIONAL : "") + `
3652
3815
  Today's date: ${(/* @__PURE__ */ new Date()).toDateString()}.`;
3653
3816
  const tools = [
3654
3817
  ...o.reflexOptions?.tools ?? [],
@@ -3832,6 +3995,9 @@ ${recent}` : brief) + verify;
3832
3995
  model: tierModel,
3833
3996
  ...tier === "think" ? { reasoning: tierOpts?.reasoning ?? "high" } : {},
3834
3997
  ...tierOpts,
3998
+ // Recompute providerOptions for THIS worker's model (after tierOpts so it wins over any inherited
3999
+ // main-template value) — prevents cursor-only cwd/cursorSession leaking onto an anthropic worker.
4000
+ providerOptions: o.providerOptionsFor?.(tierModel),
3835
4001
  ...workerHost ? { host: workerHost } : {},
3836
4002
  ...hooks ? { hooks } : {},
3837
4003
  signal: controller.signal
@@ -4080,8 +4246,10 @@ Another agent just implemented the above. Independently check the CURRENT state
4080
4246
  case "capabilities": {
4081
4247
  const actTools = this.options.actOptions?.tools ?? [];
4082
4248
  const names = actTools.map((t) => t.name);
4249
+ const mcpServers = Object.keys(this.options.actOptions?.providerOptions?.mcpServers ?? {});
4250
+ const mcpNote = mcpServers.length ? ` Plus MCP servers your worker can use: ${mcpServers.join(", ")} (e.g. browser-bridge \u2192 drive a real browser: open tabs, navigate, click, screenshot).` : "";
4083
4251
  if (!names.length)
4084
- return "Your worker uses Act's default local toolset (reading/editing files, running shell commands). No extra tools (e.g. web/internet) are configured; if a request is not a basic file or shell operation, assume you can't do it and say so.";
4252
+ return "Your worker uses Act's default local toolset (reading/editing files, running shell commands). No extra tools (e.g. web/internet) are configured; if a request is not a basic file or shell operation, assume you can't do it and say so." + mcpNote;
4085
4253
  const hasFetch = names.some((n) => /WebFetch/i.test(n));
4086
4254
  const hasBrowser = names.some((n) => /browser.*(navigate|click|page|type)/i.test(n));
4087
4255
  const hasSearch = names.some((n) => /(^|_)WebSearch$|search/i.test(n) && !/WebFetch|browser/i.test(n));
@@ -4090,7 +4258,7 @@ Another agent just implemented the above. Independently check the CURRENT state
4090
4258
  if (hasBrowser) notes.push("The browser tools drive a real browser: you CAN open a site and, if needed, navigate to a search engine and search there \u2014 but it is manual and takes a moment, not an instant lookup.");
4091
4259
  else if (!hasSearch && hasFetch) notes.push('You have no general web-search tool, so for an instant "search the web" you can only fetch a URL they provide.');
4092
4260
  const webNote = notes.length ? " NOTE: " + notes.join(" ") : "";
4093
- return `Tools your background worker (Act) can actually use: ${names.join(", ")}. Read each name literally and match the request to a SPECIFIC tool; if none fits, you do NOT have that ability \u2014 say so honestly.` + webNote;
4261
+ return `Tools your background worker (Act) can actually use: ${names.join(", ")}. Read each name literally and match the request to a SPECIFIC tool; if none fits, you do NOT have that ability \u2014 say so honestly.` + webNote + mcpNote;
4094
4262
  }
4095
4263
  case "time":
4096
4264
  return (/* @__PURE__ */ new Date()).toString();
@@ -5475,6 +5643,10 @@ function toCursorMcp(servers) {
5475
5643
  }
5476
5644
  return Object.keys(out).length ? { mcpServers: out } : void 0;
5477
5645
  }
5646
+ function cursorProviderOptions(model, cwd, mcpServers) {
5647
+ if (!(model ?? "").startsWith("cursor/")) return void 0;
5648
+ return { cwd, ...toCursorMcp(mcpServers) ?? {}, ...process.env.CURSOR_WARM === "0" ? {} : { cursorSession: randomUUID() } };
5649
+ }
5478
5650
  async function buildAgent(o) {
5479
5651
  if (typeof o.sandbox !== "boolean")
5480
5652
  throw new Error(
@@ -5566,6 +5738,8 @@ Reference files in them by their mount path (the left side).`;
5566
5738
  const jobs = new ShellJobRegistry({ cwd, killOnExit: true });
5567
5739
  realShell = [makeRealShellTool({ cwd, registry: jobs }), ...makeShellJobTools(jobs)];
5568
5740
  }
5741
+ const scratchDir = o.scratch ? o.scratchDir ?? `${cwd}/.agent/scratch` : void 0;
5742
+ const scratch = scratchDir ? new Scratch(fs, { dir: scratchDir }) : void 0;
5569
5743
  return new Agent({
5570
5744
  ai: o.ai,
5571
5745
  fs,
@@ -5576,7 +5750,10 @@ Reference files in them by their mount path (the left side).`;
5576
5750
  // would corrupt those calls.
5577
5751
  // Warm cursor session (default on; CURSOR_WARM=0 to disable): one long-lived agent reused across
5578
5752
  // turns, amortising the ~2s Agent.create + h2 handshake. Keyed per agentx launch.
5579
- ...isCursor ? { providerOptions: { cwd, ...toCursorMcp(o.mcpServers) ?? {}, ...process.env.CURSOR_WARM === "0" ? {} : { cursorSession: randomUUID() } } } : {},
5753
+ ...(() => {
5754
+ const po = cursorProviderOptions(o.model, cwd, o.mcpServers);
5755
+ return po ? { providerOptions: po } : {};
5756
+ })(),
5580
5757
  ...(() => {
5581
5758
  const now5 = /* @__PURE__ */ new Date();
5582
5759
  const platformNames = { darwin: "macOS", linux: "Linux", win32: "Windows" };
@@ -5590,7 +5767,13 @@ SANDBOX: you operate on an in-memory COPY of this directory \u2014 the real disk
5590
5767
  The filesystem root '/' is the real machine root \u2014 you have full filesystem access, like a normal shell. Use paths relative to the working directory, or absolute paths. Secret files (.env, .ssh, keys, .git) are hidden for safety.`;
5591
5768
  const shellNote = useRealShell && !virtual ? "The `Shell` tool runs /bin/sh \u2014 you can run any installed binary (git, bun, node, curl, etc). Use `Shell` for all shell commands." : void 0;
5592
5769
  const toneNote = "Be concise. Do not use emojis unless the user asks. Do not explain what you are about to do \u2014 just do it.";
5593
- const extra = [o.appendSystemPrompt, envNote, cwdNote, shellNote, toneNote, mountNote].filter(Boolean).join("\n\n");
5770
+ const mcpNote = (() => {
5771
+ const names = new Set(Object.keys(o.mcpServers ?? {}));
5772
+ for (const t of o.extraTools ?? []) if (t.name.startsWith("mcp__")) names.add(t.name.slice(5).split("__")[0]);
5773
+ const list = [...names];
5774
+ return `You are agentx (the agent.libx.js runtime) \u2014 NOT Claude Code or Claude Desktop, which are separate agents with their own config. Your MCP servers are configured in .agent/settings.json or .agent/config.* (this project's .agent/ or ~/.agent/), and their tools are already in your tool list as \`mcp__<server>__<tool>\`.${list.length ? ` Configured MCP servers: ${list.join(", ")}.` : " No MCP servers are currently configured."} When asked which MCPs/tools you have, answer from your own tool list \u2014 never read Claude Code/Desktop config (e.g. ~/.claude.json, claude_desktop_config.json) to answer; those belong to a different agent.`;
5775
+ })();
5776
+ const extra = [o.appendSystemPrompt, envNote, cwdNote, shellNote, toneNote, mountNote, mcpNote].filter(Boolean).join("\n\n");
5594
5777
  const basePrompt = (() => {
5595
5778
  let p = new AgentOptions().systemPrompt;
5596
5779
  if (!virtual) p = p.replace("operating on a virtual filesystem", "operating on the host filesystem");
@@ -5601,9 +5784,11 @@ The filesystem root '/' is the real machine root \u2014 you have full filesystem
5601
5784
  })(),
5602
5785
  tools: (() => {
5603
5786
  const base = toolsByName([...o.tools ?? DEFAULT_TOOLS, ...autoWebTools()]);
5604
- if (!realShell.length) return [...base, ...o.extraTools ?? []];
5787
+ const tail = [...o.extraTools ?? []];
5788
+ if (scratch) tail.push(makeAskTool({ fs, ai: o.ai, model: o.scratchAskModel ?? o.model ?? "anthropic/claude-sonnet-4-6", dir: scratchDir }));
5789
+ if (!realShell.length) return [...base, ...tail];
5605
5790
  const filtered = base.filter((t) => t.name !== "bash");
5606
- return [...filtered, ...realShell, ...o.extraTools ?? []];
5791
+ return [...filtered, ...realShell, ...tail];
5607
5792
  })(),
5608
5793
  maxSteps: o.maxSteps ?? 30,
5609
5794
  ...o.reasoning != null ? { reasoning: o.reasoning } : {},
@@ -5613,6 +5798,9 @@ The filesystem root '/' is the real machine root \u2014 you have full filesystem
5613
5798
  planMode: o.planMode ?? false,
5614
5799
  permissions: o.permissions,
5615
5800
  subagents: o.subagents ?? false,
5801
+ // When scratch is on, an oversized tool result spills to a scratch file + recoverable paginated stub
5802
+ // (lossless). Without scratch, the Agent's default crop (lossy) still guards the context window.
5803
+ ...scratch ? { capToolResult: (full, info) => scratch.spill(full, info) } : {},
5616
5804
  backgroundJobs: o.backgroundJobs ?? virtual,
5617
5805
  // default ON in virtual modes (no real shell there); disk uses ShellJobRegistry
5618
5806
  skillsDir: dots("skills"),
@@ -7837,6 +8025,7 @@ function parseArgs(argv) {
7837
8025
  else if (x === "--ask") a.ask = true;
7838
8026
  else if (x === "--yes" || x === "-y") a.yes = true;
7839
8027
  else if (x === "--vfs" || x === "--sandbox") a.vfs = true;
8028
+ else if (x === "--scratch") a.scratch = true;
7840
8029
  else if (x === "--boddb") a.boddb = val(++i, x);
7841
8030
  else if (x === "--seed") a.seed = true;
7842
8031
  else if (x === "--shell") a.shell = true;
@@ -7890,6 +8079,7 @@ Flags:
7890
8079
  --no-stream disable token streaming
7891
8080
  (default: disk mode \u2014 full real filesystem access, like Claude Code)
7892
8081
  --vfs, --sandbox sandbox mode: work over an in-memory copy of cwd \u2014 real disk is NEVER modified
8082
+ --scratch spill big web outputs to scratch files (kept out of context; peek via Grep/Ask)
7893
8083
  --boddb <dir> database-backed workspace: files live in a persistent bod-db store at <dir>,
7894
8084
  surviving across runs \u2014 real disk is NEVER modified (DB-native; add --seed below)
7895
8085
  --seed with --boddb: hydrate the store from cwd on the first run (empty DB) only
@@ -7907,7 +8097,7 @@ Flags:
7907
8097
  impulsive reactions, human pacing (implies --duplex; aliases: --convo, --voice)
7908
8098
  with SONIOX_API_KEY + CARTESIA_API_KEY(+VOICE_ID) set: real voice I/O \u2014 mic in,
7909
8099
  spoken replies out (echo-cancelled; speak over it to interrupt)
7910
- --voice-model <id> with --duplex: the fast voice model (default groq/openai/gpt-oss-20b)
8100
+ --voice-model <id> with --duplex: the fast voice model (default groq/openai/gpt-oss-120b)
7911
8101
  --think-model <id> with --duplex: the premium deep-reasoning model (default anthropic/claude-opus-4-6)
7912
8102
  --no-think with --duplex: disable the Think tier (Act handles everything)
7913
8103
  --add-dir <path> mount another directory into the workspace (repeatable; disk mode only)
@@ -7939,7 +8129,7 @@ Project instructions: ./AGENTS.md or ./CLAUDE.md are auto-loaded (scaffold with
7939
8129
  Auto-loaded from ./.agent/: commands/, skills/, memory/, agents/.
7940
8130
 
7941
8131
  REPL shortcuts: !<cmd> runs a shell command inline \xB7 #<note> saves a memory \xB7 @path inlines a file
7942
- REPL slash commands: /help /version /tools /permissions /status /cost /context /cwd /model /reasoning /config /rename /compact /rewind /undo /clear /sessions /resume /commands /skills /mcp /init /export /paste /goal /exit (duplex: /act /think /voice-model /think-model)
8132
+ REPL slash commands: /help /version /tools /permissions /status /cost /context /cwd /model /reasoning /config /rename /compact /rewind /undo /clear /sessions /resume /commands /skills /mcp /init /export /paste /goal /exit (duplex: /act /think /voice /voice-model /think-model)
7943
8133
  REPL completion: type / (commands+skills) or @ (files) for a LIVE menu \u2014 \u2191/\u2193 select, \u23CE/Tab accept, Esc dismiss.
7944
8134
  REPL multi-line: Option/Alt+Enter inserts a newline, or end a line with \\ to continue. Esc cancels a running turn / clears the input line; double-Esc jumps back to edit a previous message.
7945
8135
  REPL shortcuts: Shift+Tab cycles permission posture (ask \u2192 accept-edits \u2192 plan) \xB7 Alt+T toggles reasoning \xB7 Alt+P switches model \xB7 Ctrl+O toggles verbose tool output \xB7 \u2192 or Tab accepts the dim history ghost-suggestion \xB7 Alt+S/Ctrl+S stash/unstash.
@@ -8356,6 +8546,7 @@ function optsFor(args, ai, cfg = {}, extraTools = []) {
8356
8546
  seed: args.seed,
8357
8547
  realShell: args.shell,
8358
8548
  // undefined → core.ts defaults (on for disk, off for sandbox/boddb)
8549
+ scratch: args.scratch,
8359
8550
  appendSystemPrompt: args.appendSystemPrompt,
8360
8551
  addDirs: args.addDirs,
8361
8552
  stream: args.stream,
@@ -8676,6 +8867,7 @@ async function repl(args, ai, cfg, cwd) {
8676
8867
  const duplex = args.duplex;
8677
8868
  let dx;
8678
8869
  let voiceIO;
8870
+ let toggleVoice;
8679
8871
  let editorRef;
8680
8872
  let repaintStash = () => {
8681
8873
  };
@@ -8719,7 +8911,7 @@ async function repl(args, ai, cfg, cwd) {
8719
8911
  return { decision: "deny" };
8720
8912
  };
8721
8913
  if (duplex) {
8722
- const { host: _host, stream: _stream, signal: _signal, ...wo } = agent.options;
8914
+ const { host: _host, stream: _stream, signal: _signal, providerOptions: _po, ...wo } = agent.options;
8723
8915
  workerOptions = wo;
8724
8916
  if (workerOptions.permissions)
8725
8917
  workerOptions.permissions = new PermissionPolicy({ ...workerOptions.permissions.options, host: void 0, ask: duplexAsk });
@@ -8799,6 +8991,7 @@ async function repl(args, ai, cfg, cwd) {
8799
8991
  ...args.voiceModel ?? cfg.reflexModel ? { reflexModel: resolveModelOrNewest(args.voiceModel ?? cfg.reflexModel) } : {},
8800
8992
  actModel: agent.options.model,
8801
8993
  actOptions: workerOptions,
8994
+ providerOptionsFor: (m) => cursorProviderOptions(m, cwd, cfg.mcpServers),
8802
8995
  ...(args.thinkModel ?? cfg.thinkModel) !== void 0 ? { thinkModel: (args.thinkModel ?? cfg.thinkModel) === false ? false : resolveModelOrNewest(String(args.thinkModel ?? cfg.thinkModel)) } : {},
8803
8996
  host,
8804
8997
  ...args.voice ? { voiceStyle: "conversational", progressUpdates: true, askRelay: true } : {},
@@ -8901,15 +9094,30 @@ async function repl(args, ai, cfg, cwd) {
8901
9094
  const img = grabClipboardImage(dir, String(Date.now()));
8902
9095
  return img ? { display: "Image", ref: "@" + img.path, path: img.path } : null;
8903
9096
  };
9097
+ const forceQuit = (code = 130) => {
9098
+ try {
9099
+ voiceIO?.stop();
9100
+ } catch {
9101
+ }
9102
+ try {
9103
+ disposeCursorSessions();
9104
+ } catch {
9105
+ }
9106
+ void closeMcp(mounted);
9107
+ process.exit(code);
9108
+ };
8904
9109
  process.on("SIGINT", () => {
8905
9110
  if (activeTurn) {
9111
+ if (aborting) {
9112
+ err(red("\n \u23FB force-quit\n"));
9113
+ forceQuit();
9114
+ return;
9115
+ }
8906
9116
  activeTurn.abort();
8907
9117
  voiceIO?.interrupt();
8908
9118
  return;
8909
9119
  }
8910
- voiceIO?.stop();
8911
- void closeMcp(mounted);
8912
- process.exit(130);
9120
+ forceQuit();
8913
9121
  });
8914
9122
  installCancelGuards(mounted);
8915
9123
  const store = new SessionStore(cwd);
@@ -9267,6 +9475,15 @@ ${extra}` : body);
9267
9475
  err(dim(` worker chrome: ${workerChrome} (use /workers full|minimal)
9268
9476
  `));
9269
9477
  }
9478
+ }, voice: {
9479
+ desc: "toggle live voice I/O on/off mid-session (needs SONIOX/CARTESIA keys + a TTY)",
9480
+ run: async () => {
9481
+ if (!toggleVoice) {
9482
+ err(dim(" (voice needs --duplex on a TTY)\n"));
9483
+ return;
9484
+ }
9485
+ await toggleVoice();
9486
+ }
9270
9487
  }, "voice-model": {
9271
9488
  desc: "switch the reflex (voice) model \u2014 /voice-model <id>, or alone for a picker",
9272
9489
  run: async (a) => {
@@ -9492,7 +9709,7 @@ ${extra}` : body);
9492
9709
  commands: { desc: "pick a custom slash command to run (./.agent/commands)", run: () => pickAndRun("command") },
9493
9710
  skills: { desc: "pick a skill to run (./.agent/skills)", run: () => pickAndRun("skill") },
9494
9711
  mcp: {
9495
- desc: "manage MCP servers \u2014 /mcp [add <name> <cmd|url>] [login <name>] [remove <name>] [resources [name]]",
9712
+ desc: "manage MCP servers \u2014 /mcp [add <name> <cmd|url>] [login <name>] [remove <name>] [tools [name]] [resources [name]]",
9496
9713
  run: async (a) => {
9497
9714
  const sub = a[0]?.toLowerCase();
9498
9715
  if (sub === "login") {
@@ -9568,6 +9785,22 @@ ${extra}` : body);
9568
9785
  `));
9569
9786
  return;
9570
9787
  }
9788
+ if (sub === "tools") {
9789
+ const filter = a[1];
9790
+ const targets = filter ? mounted.filter((m) => m.name === filter) : mounted;
9791
+ if (!targets.length) {
9792
+ err(dim(` (no ${filter ? `MCP server "${filter}"` : "MCP servers"} found)
9793
+ `));
9794
+ return;
9795
+ }
9796
+ for (const m of targets) {
9797
+ err(` ${cyan(m.name)} ${dim(`(${m.tools.length} tools)`)}
9798
+ `);
9799
+ for (const t of m.tools) err(dim(` - ${t.name.replace(`mcp__${m.name}__`, "")}${t.description ? " \u2014 " + t.description.split("\n")[0] : ""}
9800
+ `));
9801
+ }
9802
+ return;
9803
+ }
9571
9804
  if (sub === "resources") {
9572
9805
  const filter = a[1];
9573
9806
  const targets = filter ? mounted.filter((m) => m.name === filter) : mounted;
@@ -9602,16 +9835,16 @@ ${extra}` : body);
9602
9835
  }
9603
9836
  for (const m of mounted) {
9604
9837
  const ver = m.serverInfo?.name ? dim(` \xB7 ${m.serverInfo.name}${m.serverInfo.version ? " v" + m.serverInfo.version : ""}`) : "";
9605
- err(` ${cyan(m.name)}${ver} ${dim(`(${m.tools.length} tools)`)}
9838
+ err(` ${green("\u2713")} ${cyan(m.name)}${ver} ${dim(`(${m.tools.length} tools)`)}
9606
9839
  `);
9607
- for (const t of m.tools) err(dim(` - ${t.name.replace(`mcp__${m.name}__`, "")}${t.description ? " \u2014 " + t.description.split("\n")[0] : ""}
9608
- `));
9609
9840
  }
9841
+ err(dim(" /mcp tools [name] to list tools \xB7 /mcp resources [name] for resources\n"));
9610
9842
  };
9611
9843
  const items = [
9612
9844
  { label: "list", value: "list", desc: `show mounted servers (${mounted.length})` },
9613
9845
  { label: "add", value: "add", desc: "mount a new MCP server" },
9614
9846
  ...mounted.length ? [
9847
+ { label: "tools", value: "tools", desc: "list a server's tools" },
9615
9848
  { label: "remove", value: "remove", desc: "unmount an MCP server" },
9616
9849
  { label: "resources", value: "resources", desc: "list server resources" }
9617
9850
  ] : []
@@ -9640,13 +9873,13 @@ ${extra}` : body);
9640
9873
  const rv = await selectMenu(process.stderr, { title: "remove server", items: mounted.map((m) => ({ label: m.name, value: m.name })) });
9641
9874
  if (!rv) return;
9642
9875
  a = ["remove", rv];
9643
- } else if (picked === "resources") {
9876
+ } else if (picked === "tools" || picked === "resources") {
9644
9877
  if (mounted.length === 1) {
9645
- a = ["resources", mounted[0].name];
9878
+ a = [picked, mounted[0].name];
9646
9879
  } else {
9647
- const rv = await selectMenu(process.stderr, { title: "server resources", items: [{ label: "(all)", value: "" }, ...mounted.map((m) => ({ label: m.name, value: m.name }))] });
9880
+ const rv = await selectMenu(process.stderr, { title: `server ${picked}`, items: [{ label: "(all)", value: "" }, ...mounted.map((m) => ({ label: m.name, value: m.name }))] });
9648
9881
  if (rv === null) return;
9649
- a = rv ? ["resources", rv] : ["resources"];
9882
+ a = rv ? [picked, rv] : [picked];
9650
9883
  }
9651
9884
  }
9652
9885
  return builtins.mcp.run(a);
@@ -9786,7 +10019,13 @@ ${extra}` : body);
9786
10019
  aborting = true;
9787
10020
  activeTurn.abort();
9788
10021
  voiceIO?.interrupt();
9789
- err(yellow("\n \u238B cancelling\u2026\n"));
10022
+ err(yellow("\n \u238B cancelling\u2026") + dim(" (Ctrl-C again to force-quit)\n"));
10023
+ setTimeout(() => {
10024
+ if (activeTurn) err(red(" \u26A0 still cancelling \u2014 press Ctrl-C to force-quit\n"));
10025
+ }, 4e3).unref?.();
10026
+ } else if (key?.ctrl && k === "c") {
10027
+ err(red("\n \u23FB force-quit\n"));
10028
+ forceQuit();
9790
10029
  } else if (k === "escape" && !pendingRewind) {
9791
10030
  pendingRewind = true;
9792
10031
  err(dim(" \u238B\u238B jumping back to edit\u2026\n"));
@@ -9889,67 +10128,91 @@ ${extra}` : body);
9889
10128
  };
9890
10129
  let voicePartial = "";
9891
10130
  let partialRedraw = null;
9892
- if (args.voice && duplex && process.stdin.isTTY) {
10131
+ const startVoice = async (greet) => {
10132
+ if (voiceIO) return true;
10133
+ if (!duplex || !process.stdin.isTTY) {
10134
+ err(dim(" (voice needs --duplex on a TTY)\n"));
10135
+ return false;
10136
+ }
9893
10137
  if (!VoiceIO.available()) {
9894
10138
  err(dim(" (voice I/O off \u2014 set SONIOX_API_KEY, CARTESIA_API_KEY, CARTESIA_VOICE_ID to talk)\n"));
9895
- } else {
9896
- voiceIO = new VoiceIO({
9897
- // No ack phrase by default: a fixed "Mm-hm," every turn reads robotic, Haiku's TTFT doesn't
9898
- // need masking (~0.7-1.2s full turns), and the conversational register already opens with a
9899
- // natural reaction. The mechanism (+ echo-leak guard) stays for slower voice models.
9900
- onState: () => editorRef?.redrawNow(),
9901
- // Throttled: each redraw clears the screen below the prompt — a partial-per-token storm
9902
- // (fast speech, or echo bleed if AEC degrades) would continuously erase streamed text.
9903
- onPartial: (text) => {
9904
- if (text === voicePartial) return;
9905
- voicePartial = text;
9906
- if (!partialRedraw) partialRedraw = setTimeout(() => {
9907
- partialRedraw = null;
9908
- editorRef?.redrawNow();
9909
- }, 250);
9910
- },
9911
- onBargeIn: (phase) => {
9912
- activeTurn?.abort();
9913
- if (phase === "speaking") err(yellow("\n \u270B interrupted\n"));
9914
- },
9915
- onUtterance: (text) => {
9916
- voicePartial = "";
9917
- if (!text.trim()) return;
9918
- const cut = voiceIO.takeInterruptedReply();
9919
- const note = cut && cut.full.length - cut.heard.length > 40 ? `
10139
+ return false;
10140
+ }
10141
+ voiceIO = new VoiceIO({
10142
+ // No ack phrase by default: a fixed "Mm-hm," every turn reads robotic, Haiku's TTFT doesn't
10143
+ // need masking (~0.7-1.2s full turns), and the conversational register already opens with a
10144
+ // natural reaction. The mechanism (+ echo-leak guard) stays for slower voice models.
10145
+ onState: () => editorRef?.redrawNow(),
10146
+ // Throttled: each redraw clears the screen below the prompt a partial-per-token storm
10147
+ // (fast speech, or echo bleed if AEC degrades) would continuously erase streamed text.
10148
+ onPartial: (text) => {
10149
+ if (text === voicePartial) return;
10150
+ voicePartial = text;
10151
+ if (!partialRedraw) partialRedraw = setTimeout(() => {
10152
+ partialRedraw = null;
10153
+ editorRef?.redrawNow();
10154
+ }, 250);
10155
+ },
10156
+ onBargeIn: (phase) => {
10157
+ activeTurn?.abort();
10158
+ if (phase === "speaking") err(yellow("\n \u270B interrupted\n"));
10159
+ },
10160
+ onUtterance: (text) => {
10161
+ voicePartial = "";
10162
+ if (!text.trim()) return;
10163
+ const cut = voiceIO.takeInterruptedReply();
10164
+ const note = cut && cut.full.length - cut.heard.length > 40 ? `
9920
10165
  [the user interrupted you mid-speech \u2014 they only heard up to: "\u2026${cut.heard.slice(-80)}". Work any unheard essentials into your reply naturally, only if still relevant.]` : "";
9921
- if (!/^[!#/]/.test(text.trim())) voiceIO.beginSpeech(true);
9922
- err(`\r\x1B[K ${bold(cyan("\u{1F3A4} \u203A"))} ${text}
10166
+ if (!/^[!#/]/.test(text.trim())) voiceIO.beginSpeech(true);
10167
+ err(`\r\x1B[K ${bold(cyan("\u{1F3A4} \u203A"))} ${text}
9923
10168
  `);
9924
- void dispatchLine(text + note).then(async (r) => {
9925
- if (r === "quit") {
9926
- await voiceIO?.awaitIdle();
9927
- editorRef?.abort();
9928
- }
9929
- }).finally(() => editorRef?.redrawNow());
9930
- }
9931
- });
9932
- try {
9933
- await voiceIO.start();
9934
- process.on("exit", () => voiceIO?.stop());
9935
- for (const sig of ["SIGHUP", "SIGTERM"]) process.on(sig, () => {
9936
- voiceIO?.stop();
9937
- process.exit(0);
9938
- });
9939
- err(dim(` \u{1F3A4} voice on (${voiceIO.usingAec ? "echo-cancelled" : "heuristic echo \u2014 headphones recommended"}) \u2014 just talk; speak over it to interrupt
10169
+ void dispatchLine(text + note).then(async (r) => {
10170
+ if (r === "quit") {
10171
+ await voiceIO?.awaitIdle();
10172
+ editorRef?.abort();
10173
+ }
10174
+ }).finally(() => editorRef?.redrawNow());
10175
+ }
10176
+ });
10177
+ try {
10178
+ await voiceIO.start();
10179
+ err(dim(` \u{1F3A4} voice on (${voiceIO.usingAec ? "echo-cancelled" : "heuristic echo \u2014 headphones recommended"}) \u2014 just talk; speak over it to interrupt
9940
10180
  `));
10181
+ if (greet) {
9941
10182
  const where = cwd.split("/").pop();
9942
10183
  const resumed = session.messages.length > 0;
9943
10184
  void turn(
9944
10185
  `[session started] First call QuickLook with what:"memory" \u2014 if it knows the user's name or preferences, use them. Then greet the user warmly in one or two short sentences, as the opener of a live voice conversation. Context: working directory "${where}"${resumed ? "; this resumes an earlier conversation \u2014 glance at it and pick up naturally" : ""}. Personalize from whatever you learned (memory, prior conversation). Then ask what they'd like to do.`
9945
10186
  ).finally(() => editorRef?.redrawNow());
9946
- } catch (e) {
9947
- err(yellow(` \u26A0 voice I/O failed to start: ${e?.message ?? e} \u2014 continuing text-only
9948
- `));
9949
- voiceIO = void 0;
9950
10187
  }
10188
+ return true;
10189
+ } catch (e) {
10190
+ err(yellow(` \u26A0 voice I/O failed to start: ${e?.message ?? e} \u2014 continuing text-only
10191
+ `));
10192
+ voiceIO = void 0;
10193
+ return false;
9951
10194
  }
10195
+ };
10196
+ if (duplex && process.stdin.isTTY) {
10197
+ process.on("exit", () => voiceIO?.stop());
10198
+ for (const sig of ["SIGHUP", "SIGTERM"]) process.on(sig, () => {
10199
+ voiceIO?.stop();
10200
+ process.exit(0);
10201
+ });
9952
10202
  }
10203
+ if (duplex && process.stdin.isTTY) toggleVoice = async () => {
10204
+ if (voiceIO) {
10205
+ voiceIO.stop();
10206
+ voiceIO = void 0;
10207
+ voicePartial = "";
10208
+ err(dim(" \u{1F507} voice off\n"));
10209
+ editorRef?.redrawNow();
10210
+ return;
10211
+ }
10212
+ await startVoice(false);
10213
+ editorRef?.redrawNow();
10214
+ };
10215
+ if (args.voice && duplex && process.stdin.isTTY) await startVoice(true);
9953
10216
  while (true) {
9954
10217
  if (pendingRewind) {
9955
10218
  pendingRewind = false;
@@ -10084,6 +10347,7 @@ ${extra}` : body);
10084
10347
  if (forced) {
10085
10348
  voiceIO?.stop();
10086
10349
  releaseStdin();
10350
+ disposeCursorSessions();
10087
10351
  await closeMcp(mounted);
10088
10352
  process.exit(130);
10089
10353
  }
@@ -10092,6 +10356,7 @@ ${extra}` : body);
10092
10356
  }
10093
10357
  }
10094
10358
  releaseStdin();
10359
+ disposeCursorSessions();
10095
10360
  await closeMcp(mounted);
10096
10361
  }
10097
10362
  function readAllStdin() {
@@ -10168,8 +10433,8 @@ async function main() {
10168
10433
  const { ok, res } = await runTurn(agent, store, session, args.task, void 0, cwd);
10169
10434
  if (cfg.reflectOnFailure && !ok && res && agent.options.memoryDir) {
10170
10435
  const _fsBase = agent.options.fs.getCwd() === "/" ? "" : agent.options.fs.getCwd();
10171
- const slug = await reflectOnRun({ ai, model: agent.options.model, fs: agent.options.fs, dir: primaryMemDir(agent.options.memoryDir, `${_fsBase}/.agent/memory`), result: res });
10172
- if (slug) err(dim(` \u270E learned a lesson \u2192 ${slug}
10436
+ const slug2 = await reflectOnRun({ ai, model: agent.options.model, fs: agent.options.fs, dir: primaryMemDir(agent.options.memoryDir, `${_fsBase}/.agent/memory`), result: res });
10437
+ if (slug2) err(dim(` \u270E learned a lesson \u2192 ${slug2}
10173
10438
  `));
10174
10439
  }
10175
10440
  await closeMcp(mounted);