agent.libx.js 0.89.9 → 0.92.2

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
@@ -852,6 +852,17 @@ ${out}`.trim() : out || "(command succeeded, no output)";
852
852
  );
853
853
  return `Started background job ${id} \u2014 poll with JobOutput({id:"${id}"}) / JobStatus, stop with JobKill.`;
854
854
  }
855
+ function exitSessionTool(onExit) {
856
+ return {
857
+ name: "ExitSession",
858
+ description: `End the current session and exit the CLI. Call this when the user says goodbye, asks to quit, or clearly indicates they want to stop the conversation (e.g. "ok bye", "that's all", "exit", "goodnight").`,
859
+ parameters: { type: "object", properties: {} },
860
+ async run() {
861
+ onExit();
862
+ return "Session ending. Goodbye!";
863
+ }
864
+ };
865
+ }
855
866
  function defaultTools() {
856
867
  return [bashTool, readTool, editTool];
857
868
  }
@@ -1363,7 +1374,7 @@ function makeRealShellTool(options) {
1363
1374
  const id = await options.registry.start(cmd);
1364
1375
  return `Started background job ${id}. Poll output with ShellOutput({id:"${id}"}), check ShellStatus({id:"${id}"}), stop with ShellKill({id:"${id}"}).`;
1365
1376
  }
1366
- const spawn2 = options.spawn ?? await nodeSpawn();
1377
+ const spawn3 = options.spawn ?? await nodeSpawn();
1367
1378
  const ctl = new AbortController();
1368
1379
  const onAbort = () => ctl.abort();
1369
1380
  if (ctx.signal) {
@@ -1386,7 +1397,7 @@ function makeRealShellTool(options) {
1386
1397
  };
1387
1398
  let proc;
1388
1399
  try {
1389
- proc = spawn2("/bin/sh", ["-c", cmd], { cwd: options.cwd, env: childEnv(options), signal: ctl.signal });
1400
+ proc = spawn3("/bin/sh", ["-c", cmd], { cwd: options.cwd, env: childEnv(options), signal: ctl.signal });
1390
1401
  } catch (e) {
1391
1402
  return finish(`[exit 1] failed to spawn shell: ${e?.message ?? e}`);
1392
1403
  }
@@ -1397,7 +1408,7 @@ function makeRealShellTool(options) {
1397
1408
  proc.stderr?.on("data", collect);
1398
1409
  proc.on("error", (err2) => {
1399
1410
  if (err2?.name === "AbortError" || ctl.signal.aborted) return finish(reasonFor(timedOut, timeoutMs, clean(out)));
1400
- log8.debug("shell spawn error", err2);
1411
+ log11.debug("shell spawn error", err2);
1401
1412
  finish(`[exit 1] ${err2?.message ?? err2}${out ? "\n" + clean(out) : ""}`);
1402
1413
  });
1403
1414
  proc.on("close", (code) => {
@@ -1457,14 +1468,14 @@ ${clean(out) || "(no output yet)"}`;
1457
1468
  }
1458
1469
  ];
1459
1470
  }
1460
- var log8, clean, SECRET_ENV_RE, _spawn, ShellJobRegistry, NO_JOB2;
1471
+ var log11, clean, SECRET_ENV_RE, _spawn, ShellJobRegistry, NO_JOB2;
1461
1472
  var init_tools_shell = __esm({
1462
1473
  "src/tools.shell.ts"() {
1463
1474
  "use strict";
1464
1475
  init_tools();
1465
1476
  init_redact();
1466
1477
  init_logging();
1467
- log8 = forComponent("shell");
1478
+ log11 = forComponent("shell");
1468
1479
  clean = (s) => truncateOutput(redactSecrets(s.replace(/\n+$/, "")));
1469
1480
  SECRET_ENV_RE = /(_API_KEY|_TOKEN|_SECRET|_PASSWORD|_PRIVATE_KEY|^AWS_|^GITHUB_TOKEN$|^OPENAI_|^ANTHROPIC_|^GOOGLE_|^GEMINI_|^GROQ_|^NPM_TOKEN$)/i;
1470
1481
  ShellJobRegistry = class {
@@ -1484,8 +1495,8 @@ var init_tools_shell = __esm({
1484
1495
  job.buf = (job.buf + s).slice(-max);
1485
1496
  };
1486
1497
  try {
1487
- const spawn2 = this.cfg.spawn ?? await nodeSpawn();
1488
- const proc = spawn2("/bin/sh", ["-c", command], { cwd: this.cfg.cwd, env: childEnv(this.cfg) });
1498
+ const spawn3 = this.cfg.spawn ?? await nodeSpawn();
1499
+ const proc = spawn3("/bin/sh", ["-c", command], { cwd: this.cfg.cwd, env: childEnv(this.cfg) });
1489
1500
  job.proc = proc;
1490
1501
  proc.stdout?.on("data", append);
1491
1502
  proc.stderr?.on("data", append);
@@ -1542,8 +1553,8 @@ var init_tools_shell = __esm({
1542
1553
 
1543
1554
  // cli/cli.ts
1544
1555
  import { createInterface } from "readline/promises";
1545
- import { existsSync as existsSync7, readFileSync as readFileSync5, appendFileSync, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
1546
- import { homedir as homedir4, tmpdir } from "os";
1556
+ import { existsSync as existsSync8, readFileSync as readFileSync5, appendFileSync, mkdirSync as mkdirSync7, writeFileSync as writeFileSync6, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
1557
+ import { homedir as homedir5, tmpdir } from "os";
1547
1558
 
1548
1559
  // cli/clipboard.ts
1549
1560
  import { execFileSync } from "child_process";
@@ -1599,7 +1610,7 @@ close access f`;
1599
1610
  }
1600
1611
 
1601
1612
  // cli/cli.ts
1602
- import { join as join8, resolve as resolve3, basename as basename2, extname, dirname as dirname3 } from "path";
1613
+ import { join as join9, resolve as resolve3, basename as basename2, extname, dirname as dirname4 } from "path";
1603
1614
  import { AIClient, listModels, listProviders, getProviderFromModel, getModelInfo, resolveModel, isModelSupported } from "ai.libx.js";
1604
1615
 
1605
1616
  // src/llm.ts
@@ -2839,7 +2850,15 @@ var Agent = class _Agent {
2839
2850
  toolCallsTotal += toolCalls.length;
2840
2851
  if (o.maxToolCalls && toolCallsTotal > o.maxToolCalls) return kill("max_tool_calls");
2841
2852
  for (const tc of toolCalls) {
2842
- const content = await this.dispatch(tc);
2853
+ const raw = await this.dispatch(tc);
2854
+ let content;
2855
+ if (typeof raw === "string") {
2856
+ content = raw;
2857
+ } else {
2858
+ const parts = [{ type: "text", text: raw.text }];
2859
+ for (const img of raw.images ?? []) parts.push(imagePart(`data:${img.mimeType};base64,${img.data}`));
2860
+ content = parts;
2861
+ }
2843
2862
  this.transcript.push({ role: "tool", tool_call_id: tc.id, name: tc.function.name, content });
2844
2863
  }
2845
2864
  }
@@ -2896,10 +2915,17 @@ var Agent = class _Agent {
2896
2915
  return earlyError;
2897
2916
  }
2898
2917
  let result;
2918
+ let images;
2899
2919
  let threw = false;
2900
2920
  try {
2901
2921
  log3.debug(`${tc.function.name}(${tc.function.arguments})`);
2902
- result = await tool.run(args, this.ctx);
2922
+ const raw = await tool.run(args, this.ctx);
2923
+ if (typeof raw === "string") {
2924
+ result = raw;
2925
+ } else {
2926
+ result = raw.text;
2927
+ images = raw.images;
2928
+ }
2903
2929
  } catch (e) {
2904
2930
  const msg = e instanceof Error ? e.message : String(e);
2905
2931
  log3.debug(`${tc.function.name} -> error: ${msg}`);
@@ -2909,7 +2935,12 @@ var Agent = class _Agent {
2909
2935
  if (!threw) result = await this.maybeAutoTest(tc.function.name, result);
2910
2936
  await hooks?.postToolUse?.(call, result, meta);
2911
2937
  this.options.host?.notify?.({ kind: "tool_result", id: tc.id ?? "", output: result, isError: threw });
2912
- return result;
2938
+ if (images?.length) {
2939
+ for (const img of images) {
2940
+ this.options.host?.notify?.({ kind: "tool_result_image", id: tc.id ?? "", dataUrl: `data:${img.mimeType};base64,${img.data}` });
2941
+ }
2942
+ }
2943
+ return images?.length ? { text: result, images } : result;
2913
2944
  }
2914
2945
  static WRITE_CLASS = ["Write", "Edit", "MultiEdit", "ApplyEdits"];
2915
2946
  /** Append an autoTest failure section to a write-class tool result, if configured. */
@@ -3352,6 +3383,11 @@ function digestRun(messages, maxChars) {
3352
3383
  import { MemFilesystem as MemFilesystem2 } from "@livx.cc/wcli/core";
3353
3384
  init_logging();
3354
3385
  var log6 = forComponent("DuplexAgent");
3386
+ function describeCall(call) {
3387
+ const v = call.args && Object.values(call.args).find((x) => typeof x === "string" && x.trim());
3388
+ const hint = v ? ` (${String(v).replace(/\s+/g, " ").trim().slice(0, 48)})` : "";
3389
+ return `${call.name}${hint}`;
3390
+ }
3355
3391
  var DuplexAgentOptions = class {
3356
3392
  /** Any ai.libx.js AIClient — shared by the voice and worker agents (routed by model). */
3357
3393
  ai;
@@ -3372,8 +3408,16 @@ var DuplexAgentOptions = class {
3372
3408
  /** Awaited BEFORE a delegated worker spawns — open a per-task checkpoint frame, audit, etc.
3373
3409
  * (post-spawn would race the worker's first edits). */
3374
3410
  onTaskStart;
3411
+ /** Re-voice throttled worker progress asides ('[task t1 progress] …') so long tasks aren't dead
3412
+ * air. Off by default — each update costs a voice turn (LLM call + speech). */
3413
+ progressUpdates = false;
3414
+ /** Min ms between progress re-voices per task. */
3415
+ progressIntervalMs = 25e3;
3416
+ /** Host overrides for QuickLook lookups (keyed by `what`). The engine's defaults go through the
3417
+ * (possibly jailed) fs — e.g. `.git/**` is deny-listed, so the CLI supplies 'branch' itself. */
3418
+ quickLook;
3375
3419
  };
3376
- var VOICE_SYSTEM_PROMPT = 'You are a spoken voice assistant \u2014 the user HEARS everything you say. Use short sentences. One idea per sentence. No markdown, no bullet lists, no code blocks, no headings, no emoji.\nKeep turns SHORT \u2014 one to three sentences, then stop. Never lecture, enumerate cases, or add caveats unprompted. Conversation is a fast exchange: give the one thing asked, and let the user pull more if they want it.\nYou work in a pair: you talk, and a background worker with FULL access to the user\'s environment (files, shell, web) does the hands-on work. You can find out or do ANYTHING by calling `Delegate` with a clear, self-contained brief \u2014 so NEVER tell the user you can\'t see, access, or do something. Delegate and find out. When the user mentions their project, folder, files, or environment ("this project", "the current folder", "my code"), delegate IMMEDIATELY \u2014 do not ask for paths or details the worker can discover itself. Never pretend to have done the work or invent results \u2014 the worker\'s report is your only source.\nAfter calling Delegate, tell the user you are on it in one short sentence, then end your turn. Do not wait for the result.\nResults arrive later as events like "[task t1 completed] \u2026" or "[task t1 failed] \u2026". When one arrives, summarize it for the ear in one or two short sentences. Never read raw file paths, diffs, or code aloud verbatim.\nDo not fire a second Delegate for work already in flight \u2014 check `TaskStatus` first. Use `CancelTask` when the user asks to stop something.';
3420
+ var VOICE_SYSTEM_PROMPT = 'You are a spoken voice assistant \u2014 the user HEARS everything you say. Use short sentences. One idea per sentence. No markdown, no bullet lists, no code blocks, no headings, no emoji.\nKeep turns SHORT \u2014 one to three sentences, then stop. Never lecture, enumerate cases, or add caveats unprompted. Conversation is a fast exchange: give the one thing asked, and let the user pull more if they want it.\nYou work in a pair: you talk, and a background worker with FULL access to the user\'s environment (files, shell, web) does the hands-on work. You can find out or do ANYTHING by calling `Delegate` with a clear, self-contained brief \u2014 so NEVER tell the user you can\'t see, access, or do something. Delegate and find out. When the user mentions their project, folder, files, or environment ("this project", "the current folder", "my code"), delegate IMMEDIATELY \u2014 do not ask for paths or details the worker can discover itself. Never pretend to have done the work or invent results \u2014 the worker\'s report is your only source.\nAfter calling Delegate, tell the user you are on it in one short sentence, then end your turn. Do not wait for the result.\nResults arrive later as events like "[task t1 completed] \u2026" or "[task t1 failed] \u2026". When one arrives, summarize it for the ear in one or two short sentences. "[task t1 progress] \u2026" events are interim status, NOT results \u2014 give at most a half-sentence aside ("still on it \u2014 running tests now") and end your turn. Never present progress as a finished result.\nNever read raw file paths, diffs, or code aloud verbatim.\nDo not fire a second Delegate for work already in flight \u2014 check `TaskStatus` first. Use `CancelTask` when the user asks to stop something.\nPRIORITY: when the user says goodbye or wants to end/finish/wrap up the session ("ok bye", "that\'s all", "let\'s finish", "let\'s end", "goodnight", "exit", "wrap up"), call `ExitSession` IMMEDIATELY \u2014 do not delegate, do not check status, just exit.\nFor TRIVIAL instant lookups only \u2014 current time, git branch, listing a folder, peeking at a small file \u2014 use `QuickLook` (instant, no task). Anything requiring searching, reasoning, running commands, or editing still goes through `Delegate`.\nNEVER claim to have stored, saved, or remembered something durably \u2014 you cannot. Anything the user wants persisted (their name, preferences, notes) must be Delegated so a worker writes it to memory.\nUser messages may arrive via speech-to-text and can carry transcription artifacts \u2014 odd words, cut-offs, homophones ("for you" vs "folder"). Read for INTENT, not surface text. If a message seems garbled or surprising, briefly confirm what they meant ("did you mean\u2026?") instead of answering the literal words.';
3377
3421
  var VOICE_STYLE_CONVERSATIONAL = `Speak like a person in a live conversation, not an assistant reading a script. React first, then deliver: a quick impulsive beat ("oh nice", "hmm, hold on", "ah, got it") before the substance. Use contractions always. Vary sentence length \u2014 some very short. Light fillers and backchannels are fine ("mm-hm", "right", "let's see") but at most one per reply \u2014 never stack them. When you delegate, say it like a human would ("hang on, let me actually dig into that \u2014 gimme a minute") instead of announcing a task. When a result comes back, react to it like you just found out ("okay so \u2014 turns out\u2026"). Match the user's energy: a quick question gets a quick answer \u2014 a few words is a perfectly good turn. Prefer a short answer plus an offer ("want the details?") over covering everything. Never narrate your own mechanics (no "I will now delegate", no task ids out loud).`;
3378
3422
  var DuplexAgent = class {
3379
3423
  options;
@@ -3393,7 +3437,10 @@ var DuplexAgent = class {
3393
3437
  model: o.voiceModel,
3394
3438
  stream: true,
3395
3439
  host: o.host,
3396
- systemPrompt: VOICE_SYSTEM_PROMPT + (o.voiceStyle === "conversational" ? "\n" + VOICE_STYLE_CONVERSATIONAL : ""),
3440
+ // Runtime context line: without it the voice confidently invents "facts" like today's date
3441
+ // (its training cutoff) instead of delegating or admitting it doesn't know.
3442
+ systemPrompt: VOICE_SYSTEM_PROMPT + (o.voiceStyle === "conversational" ? "\n" + VOICE_STYLE_CONVERSATIONAL : "") + `
3443
+ Today's date: ${(/* @__PURE__ */ new Date()).toDateString()}.`,
3397
3444
  instructionFiles: false,
3398
3445
  maxSteps: 8,
3399
3446
  // a voice turn should never loop
@@ -3402,7 +3449,7 @@ var DuplexAgent = class {
3402
3449
  // no defaultTools() — the voice can only Delegate, never touch files itself. Set AFTER the
3403
3450
  // voiceOptions spread (addTools() would be clobbered by the first prepare()); extra voice
3404
3451
  // tools come in via voiceOptions.tools and are merged here.
3405
- tools: [...o.voiceOptions?.tools ?? [], this.delegateTool(), this.taskStatusTool(), this.cancelTaskTool()]
3452
+ tools: [...o.voiceOptions?.tools ?? [], this.delegateTool(), this.taskStatusTool(), this.cancelTaskTool(), this.quickLookTool()]
3406
3453
  });
3407
3454
  }
3408
3455
  /** One user turn: the voice agent streams the reply (and may Delegate). Serialized with re-voice turns. */
@@ -3455,18 +3502,69 @@ ${recent}` : brief;
3455
3502
  spawnWorker(id, label, briefText) {
3456
3503
  const o = this.options;
3457
3504
  const controller = new AbortController();
3505
+ const base = o.workerOptions?.hooks;
3506
+ const report = o.progressUpdates ? this.progressReporter(id) : void 0;
3507
+ const hooks = report ? {
3508
+ ...base,
3509
+ preToolUse: async (call, meta) => {
3510
+ const d = await base?.preToolUse?.(call, meta);
3511
+ report.pre(call);
3512
+ return d;
3513
+ },
3514
+ postToolUse: async (call, result, meta) => {
3515
+ await base?.postToolUse?.(call, result, meta);
3516
+ report.post(call);
3517
+ }
3518
+ } : base;
3458
3519
  const worker = new Agent({
3459
3520
  ai: o.ai,
3460
3521
  fs: o.fs,
3461
3522
  model: o.workerModel,
3462
3523
  ...o.workerOptions,
3463
3524
  // may override ai/fs/model/tools/… —
3525
+ ...hooks ? { hooks } : {},
3464
3526
  signal: controller.signal
3465
3527
  // …but never the per-task cancellation signal
3466
3528
  });
3467
3529
  const promise = worker.run(briefText).then((res) => this.onWorkerSettled(id, res)).catch((err2) => this.onWorkerFailed(id, err2));
3468
3530
  this.tasks.set(id, { id, label, status: "running", controller, promise });
3469
3531
  }
3532
+ /** Throttled per-task progress: worker tool calls → at most one progress re-voice per interval.
3533
+ * Two sources, one throttle: completed steps (post) and a heartbeat for a SINGLE long tool call
3534
+ * (pre records the in-flight call; a self-cleaning timer narrates "still inside Bash — 70s").
3535
+ * Completion supersedes: nothing is emitted once the task has settled. */
3536
+ progressReporter(id) {
3537
+ let lastAt = Date.now();
3538
+ let steps = 0;
3539
+ let inflight = null;
3540
+ const due = () => {
3541
+ const rec = this.tasks.get(id);
3542
+ return rec && rec.status === "running" && Date.now() - lastAt >= this.options.progressIntervalMs ? rec : void 0;
3543
+ };
3544
+ const emit = (rec, line, call) => {
3545
+ lastAt = Date.now();
3546
+ this.notify("task_progress", `task ${id} (${rec.label}): ${line}`, { id, steps, call: call.name });
3547
+ this.queueRevoice(`[task ${id} progress] ${line}`);
3548
+ };
3549
+ const timer = setInterval(() => {
3550
+ const rec = this.tasks.get(id);
3551
+ if (!rec || rec.status !== "running") return clearInterval(timer);
3552
+ if (!inflight || !due()) return;
3553
+ emit(rec, `still inside ${describeCall(inflight.call)} \u2014 ${Math.round((Date.now() - inflight.at) / 1e3)}s on this step`, inflight.call);
3554
+ }, Math.max(this.options.progressIntervalMs, 250));
3555
+ timer.unref?.();
3556
+ return {
3557
+ pre: (call) => {
3558
+ inflight = { call, at: Date.now() };
3559
+ },
3560
+ post: (call) => {
3561
+ steps++;
3562
+ inflight = null;
3563
+ const rec = due();
3564
+ if (rec) emit(rec, `still running \u2014 ${steps} steps so far, now: ${describeCall(call)}`, call);
3565
+ }
3566
+ };
3567
+ }
3470
3568
  onWorkerSettled(id, res) {
3471
3569
  const rec = this.tasks.get(id);
3472
3570
  if (res.finishReason === "aborted" || rec.status === "cancelled") {
@@ -3527,6 +3625,58 @@ ${recent}` : brief;
3527
3625
  }
3528
3626
  };
3529
3627
  }
3628
+ /** Sub-100ms read-only lookups the voice may do itself — everything else stays Delegate-only.
3629
+ * fs-only (no shell; the engine is VFS-abstracted): time, git branch (.git/HEAD read), ls, file
3630
+ * head. Output is hard-capped so a lookup can never bloat the skinny voice context. */
3631
+ quickLookTool() {
3632
+ const CAP2 = 2e3;
3633
+ const kinds = [.../* @__PURE__ */ new Set(["time", "branch", "ls", "file", ...Object.keys(this.options.quickLook ?? {})])];
3634
+ return {
3635
+ name: "QuickLook",
3636
+ description: `Instant read-only lookup \u2014 one of: ${kinds.join(", ")}. For trivial facts only; anything needing search, commands, or reasoning goes through Delegate.`,
3637
+ parameters: {
3638
+ type: "object",
3639
+ required: ["what"],
3640
+ properties: {
3641
+ what: { type: "string", enum: kinds, description: "what to look up" },
3642
+ path: { type: "string", description: "for ls/file: the path to look at" }
3643
+ }
3644
+ },
3645
+ run: async ({ what, path }) => {
3646
+ const fs = this.options.fs;
3647
+ try {
3648
+ const over = this.options.quickLook?.[String(what)];
3649
+ if (over) return await over(path ? String(path) : void 0);
3650
+ switch (String(what)) {
3651
+ case "time":
3652
+ return (/* @__PURE__ */ new Date()).toString();
3653
+ case "branch": {
3654
+ if (!fs) return "unavailable (no filesystem)";
3655
+ const head = (await fs.readFile(".git/HEAD")).trim();
3656
+ return head.startsWith("ref: refs/heads/") ? `branch: ${head.slice("ref: refs/heads/".length)}` : `detached HEAD at ${head.slice(0, 12)}`;
3657
+ }
3658
+ case "ls": {
3659
+ if (!fs) return "unavailable (no filesystem)";
3660
+ const names = await fs.readDir(String(path ?? "."));
3661
+ return names.slice(0, 50).join("\n") + (names.length > 50 ? `
3662
+ \u2026 (+${names.length - 50} more)` : "");
3663
+ }
3664
+ case "file": {
3665
+ if (!fs) return "unavailable (no filesystem)";
3666
+ if (!path) return "file lookup needs a path";
3667
+ const text = await fs.readFile(String(path));
3668
+ return text.length > CAP2 ? text.slice(0, CAP2) + `
3669
+ \u2026 (truncated \u2014 ${text.length} chars total; Delegate for the full file)` : text;
3670
+ }
3671
+ default:
3672
+ return `unknown lookup '${what}'`;
3673
+ }
3674
+ } catch (e) {
3675
+ return `lookup failed: ${e?.message ?? e}`;
3676
+ }
3677
+ }
3678
+ };
3679
+ }
3530
3680
  cancelTaskTool() {
3531
3681
  return {
3532
3682
  name: "CancelTask",
@@ -3545,15 +3695,26 @@ ${recent}` : brief;
3545
3695
  };
3546
3696
 
3547
3697
  // src/mcp.ts
3548
- function toText(result) {
3549
- if (result == null) return "";
3550
- if (typeof result === "string") return result;
3698
+ function toResult(result) {
3699
+ if (result == null) return { text: "" };
3700
+ if (typeof result === "string") return { text: result };
3551
3701
  const content = result.content;
3552
3702
  if (Array.isArray(content)) {
3553
- const text = content.map((c) => typeof c?.text === "string" ? c.text : JSON.stringify(c)).join("\n");
3554
- if (text) return text;
3703
+ const texts = [];
3704
+ const images = [];
3705
+ for (const c of content) {
3706
+ if (c?.type === "image" && typeof c.data === "string" && c.mimeType) {
3707
+ images.push({ mimeType: c.mimeType, data: c.data });
3708
+ } else if (typeof c?.text === "string") {
3709
+ texts.push(c.text);
3710
+ } else {
3711
+ texts.push(JSON.stringify(c));
3712
+ }
3713
+ }
3714
+ const text = texts.join("\n");
3715
+ if (text || images.length) return { text, ...images.length ? { images } : {} };
3555
3716
  }
3556
- return JSON.stringify(result);
3717
+ return { text: JSON.stringify(result) };
3557
3718
  }
3558
3719
  function mcpToolToAgentTool(spec, callTool, prefix = "mcp__") {
3559
3720
  return {
@@ -3561,7 +3722,8 @@ function mcpToolToAgentTool(spec, callTool, prefix = "mcp__") {
3561
3722
  description: spec.description ?? `MCP tool ${spec.name}`,
3562
3723
  parameters: spec.inputSchema ?? { type: "object", properties: {} },
3563
3724
  async run(args, _ctx) {
3564
- return toText(await callTool(spec.name, args ?? {}));
3725
+ const r = toResult(await callTool(spec.name, args ?? {}));
3726
+ return r.images?.length ? r : r.text;
3565
3727
  }
3566
3728
  };
3567
3729
  }
@@ -3571,12 +3733,470 @@ function mcpToolsToAgentTools(specs, callTool, prefix = "mcp__", filter) {
3571
3733
 
3572
3734
  // src/index.ts
3573
3735
  init_logging();
3736
+
3737
+ // src/voice/engine.ts
3738
+ init_logging();
3739
+ var log7 = forComponent("VoiceEngine");
3740
+ var now = () => performance.now();
3741
+ var VoiceEngineOptions = class {
3742
+ stt;
3743
+ tts;
3744
+ player;
3745
+ /** a final utterance arrived (endpoint) — host dispatches it as a turn */
3746
+ onUtterance = () => {
3747
+ };
3748
+ /** live partial transcript while listening (host renders the 🎤 line) */
3749
+ onPartial = () => {
3750
+ };
3751
+ onState = () => {
3752
+ };
3753
+ /** user spoke/acted over playback — host aborts the in-flight turn (called AFTER audio is killed).
3754
+ * phase: 'speaking' = cut mid-speech (real interruption); 'drain' = in the final audio tail
3755
+ * (normal turn-taking — hosts shouldn't alarm). */
3756
+ onBargeIn = () => {
3757
+ };
3758
+ /** spoken micro-ack on utterance endpoint (masks LLM TTFT); '' disables */
3759
+ ackPhrase = "";
3760
+ /** Endpoint merge window (ms): hold an endpointed utterance briefly — if speech resumes (spelled
3761
+ * letters, mid-thought pauses), the next utterance MERGES instead of dispatching a truncated one
3762
+ * ("E-L-Y." / "A."). Costs this much latency per turn; 0 disables. */
3763
+ utteranceMergeMs = 350;
3764
+ /** heuristic (non-AEC) energy barge-in tuning */
3765
+ bargeRmsMult = 2;
3766
+ bargeRmsFloor = 500;
3767
+ };
3768
+ var VoiceEngine = class {
3769
+ options;
3770
+ state = "idle";
3771
+ stt;
3772
+ tts;
3773
+ player;
3774
+ speaking = false;
3775
+ // audible (deltas flowing OR audio draining)
3776
+ ctxOpen = false;
3777
+ // the current TTS context still accepts deltas (false once end-frame sent)
3778
+ interrupted = false;
3779
+ // barge-in latch: drop in-flight deltas until the next legitimate turn
3780
+ spokeDeltas = false;
3781
+ // a TTS context is open for the current spoken turn
3782
+ drainTimer = null;
3783
+ // heuristic tier state (inert under AEC) — frozen as validated in the experiment
3784
+ echoWords = /* @__PURE__ */ new Set();
3785
+ prevReply = "";
3786
+ reply = "";
3787
+ echoUntil = 0;
3788
+ baseline = 0;
3789
+ hot = 0;
3790
+ suspectUntil = 0;
3791
+ ackAt = 0;
3792
+ // when the micro-ack was spoken — its echo can leak before the AEC filter converges
3793
+ pendingUtt = "";
3794
+ // endpointed text held for the merge window
3795
+ pendingTimer = null;
3796
+ lastInterrupted = null;
3797
+ constructor(options) {
3798
+ this.options = { ...new VoiceEngineOptions(), ...options };
3799
+ const o = this.options;
3800
+ if (!o.stt || !o.tts || !o.player) throw new Error("VoiceEngine needs stt, tts and player (see cli/voice.ts VoiceIO for platform defaults)");
3801
+ this.stt = o.stt;
3802
+ this.tts = o.tts;
3803
+ this.player = o.player;
3804
+ }
3805
+ async start() {
3806
+ this.tts.onAudio = (c) => {
3807
+ if (this.speaking) this.player.write(c);
3808
+ };
3809
+ this.stt.onPartial = (text) => this.handlePartial(text);
3810
+ this.stt.onUtterance = (text) => this.handleUtterance(text);
3811
+ this.stt.onLevel = (rms) => this.handleLevel(rms);
3812
+ await Promise.all([this.tts.connect(), this.stt.start()]);
3813
+ this.setState("listening");
3814
+ log7.info(`voice I/O up (${this.stt.usingAec ? "AEC" : "heuristic echo"} capture)`);
3815
+ }
3816
+ get usingAec() {
3817
+ return this.stt.usingAec;
3818
+ }
3819
+ idleWaiters = [];
3820
+ setState(s) {
3821
+ if (this.state === s) return;
3822
+ this.state = s;
3823
+ this.options.onState(s);
3824
+ if (s !== "speaking" && s !== "thinking") {
3825
+ for (const r of this.idleWaiters.splice(0)) r();
3826
+ }
3827
+ }
3828
+ /** Resolve when the engine is no longer speaking (immediate if already idle). */
3829
+ awaitIdle() {
3830
+ if (this.state !== "speaking" && this.state !== "thinking") return Promise.resolve();
3831
+ return new Promise((r) => this.idleWaiters.push(r));
3832
+ }
3833
+ // --- speaking side (host-driven) ---
3834
+ /** open a spoken turn (idempotent — safe from both onUtterance and first-delta paths).
3835
+ * `ack` speaks the configured micro-ack as the context opener (utterance path only —
3836
+ * masks LLM TTFT; re-voice turns begun by their first delta skip it). */
3837
+ beginSpeech(ack = false) {
3838
+ if (this.speaking && this.ctxOpen) return;
3839
+ if (this.drainTimer) {
3840
+ clearTimeout(this.drainTimer);
3841
+ this.drainTimer = null;
3842
+ }
3843
+ this.interrupted = false;
3844
+ if (!this.speaking) this.player.markTurn();
3845
+ this.speaking = true;
3846
+ this.ctxOpen = true;
3847
+ this.spokeDeltas = false;
3848
+ this.reply = "";
3849
+ this.echoWords = new Set(this.words(this.prevReply));
3850
+ this.tts.newContext();
3851
+ if (ack && this.options.ackPhrase) {
3852
+ this.tts.speak(this.options.ackPhrase + " ", true);
3853
+ this.spokeDeltas = true;
3854
+ this.ackAt = now();
3855
+ }
3856
+ this.setState("thinking");
3857
+ }
3858
+ speakDelta(text) {
3859
+ if (this.interrupted) return;
3860
+ if (!this.speaking || !this.ctxOpen) this.beginSpeech();
3861
+ this.reply += text;
3862
+ for (const w of this.words(this.reply)) this.echoWords.add(w);
3863
+ this.tts.speak(text, true);
3864
+ this.spokeDeltas = true;
3865
+ this.setState("speaking");
3866
+ }
3867
+ /** close the spoken turn (idempotent); stays audible until ALL audio arrived AND playback drains */
3868
+ endSpeech() {
3869
+ this.interrupted = false;
3870
+ if (!this.speaking) return;
3871
+ this.ctxOpen = false;
3872
+ if (this.reply) this.prevReply = this.reply;
3873
+ const settle = () => {
3874
+ if (this.ctxOpen) {
3875
+ this.drainTimer = null;
3876
+ return;
3877
+ }
3878
+ this.drainTimer = null;
3879
+ this.speaking = false;
3880
+ this.echoUntil = now() + 2500;
3881
+ if (!this.usingAec) this.stt.reset();
3882
+ this.setState("listening");
3883
+ };
3884
+ const drainThenSettle = () => {
3885
+ if (this.drainTimer) clearTimeout(this.drainTimer);
3886
+ this.drainTimer = setTimeout(settle, this.player.drainMs() + 300);
3887
+ };
3888
+ if (this.spokeDeltas) {
3889
+ this.tts.onDone = drainThenSettle;
3890
+ this.tts.end();
3891
+ if (this.drainTimer) clearTimeout(this.drainTimer);
3892
+ this.drainTimer = setTimeout(drainThenSettle, 15e3);
3893
+ } else drainThenSettle();
3894
+ }
3895
+ /** text of the reply cut by the last barge-in — consumed by the host to tell the model what
3896
+ * the user did NOT hear. Cleared on read. */
3897
+ takeInterruptedReply() {
3898
+ const r = this.lastInterrupted;
3899
+ this.lastInterrupted = null;
3900
+ return r;
3901
+ }
3902
+ /** barge-in: stop audio NOW, cancel generation, reset for the user's utterance */
3903
+ interrupt() {
3904
+ if (!this.speaking && !this.drainTimer) return;
3905
+ if (this.drainTimer) {
3906
+ clearTimeout(this.drainTimer);
3907
+ this.drainTimer = null;
3908
+ }
3909
+ const heardChars = Math.round(Math.max(0, this.player.playedMs()) / 1e3 * 15);
3910
+ if (this.reply) this.lastInterrupted = { full: this.reply, heard: this.reply.slice(0, heardChars) };
3911
+ this.speaking = false;
3912
+ this.ctxOpen = false;
3913
+ this.interrupted = true;
3914
+ this.suspectUntil = 0;
3915
+ this.echoUntil = now() + 2500;
3916
+ this.tts.cancel();
3917
+ this.player.kill();
3918
+ if (!this.usingAec) this.stt.reset();
3919
+ if (this.reply) this.prevReply = this.reply;
3920
+ this.setState("listening");
3921
+ }
3922
+ stop() {
3923
+ if (this.pendingTimer) clearTimeout(this.pendingTimer);
3924
+ if (this.drainTimer) clearTimeout(this.drainTimer);
3925
+ this.stt.stop();
3926
+ this.player.kill();
3927
+ this.tts.close();
3928
+ this.setState("idle");
3929
+ }
3930
+ // --- listening side (STT-driven) ---
3931
+ words(s) {
3932
+ return s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter((w) => w.length >= 2);
3933
+ }
3934
+ novelWords(text) {
3935
+ return this.words(text).filter((w) => !this.echoWords.has(w));
3936
+ }
3937
+ echoActive() {
3938
+ return this.speaking || now() < this.echoUntil;
3939
+ }
3940
+ handlePartial(text) {
3941
+ if (this.speaking) {
3942
+ const barge = this.novelWords(text).length >= (this.usingAec ? 1 : this.suspectUntil ? 1 : 2);
3943
+ if (barge) {
3944
+ const phase = this.ctxOpen ? "speaking" : "drain";
3945
+ this.interrupt();
3946
+ this.options.onBargeIn(phase);
3947
+ }
3948
+ return;
3949
+ }
3950
+ if (this.pendingUtt && text.trim()) {
3951
+ if (this.pendingTimer) {
3952
+ clearTimeout(this.pendingTimer);
3953
+ this.pendingTimer = null;
3954
+ }
3955
+ }
3956
+ if (!this.echoActive() || this.novelWords(text).length >= 1) this.options.onPartial(text);
3957
+ }
3958
+ handleUtterance(text) {
3959
+ if (this.echoActive() && this.novelWords(text).length < (this.usingAec ? 1 : 2)) {
3960
+ this.stt.reset();
3961
+ return;
3962
+ }
3963
+ const squash = (t) => t.toLowerCase().replace(/[^a-z]/g, "").replace(/(.)\1+/g, "$1");
3964
+ if (this.ackAt && now() - this.ackAt < 6e3 && squash(text) === squash(this.options.ackPhrase)) {
3965
+ this.ackAt = 0;
3966
+ return;
3967
+ }
3968
+ this.pendingUtt = this.pendingUtt ? `${this.pendingUtt} ${text}` : text;
3969
+ if (this.pendingTimer) clearTimeout(this.pendingTimer);
3970
+ if (!this.options.utteranceMergeMs) return this.flushUtterance();
3971
+ this.pendingTimer = setTimeout(() => this.flushUtterance(), this.options.utteranceMergeMs);
3972
+ }
3973
+ flushUtterance() {
3974
+ if (this.pendingTimer) {
3975
+ clearTimeout(this.pendingTimer);
3976
+ this.pendingTimer = null;
3977
+ }
3978
+ const text = this.pendingUtt;
3979
+ this.pendingUtt = "";
3980
+ if (text) this.options.onUtterance(text);
3981
+ }
3982
+ /** energy two-stage barge-in (heuristic tier only): spike over echo baseline → pause + confirm via STT */
3983
+ handleLevel(rms) {
3984
+ if (this.usingAec) return;
3985
+ if (!this.speaking) {
3986
+ this.baseline = 0;
3987
+ this.hot = 0;
3988
+ return;
3989
+ }
3990
+ if (!this.baseline) {
3991
+ this.baseline = rms;
3992
+ return;
3993
+ }
3994
+ this.baseline = this.baseline * 0.9 + rms * 0.1;
3995
+ if (rms > Math.max(this.baseline * this.options.bargeRmsMult, this.options.bargeRmsFloor)) this.hot++;
3996
+ else this.hot = 0;
3997
+ if (this.hot >= 2 && !this.suspectUntil) {
3998
+ this.suspectUntil = now() + 1300;
3999
+ setTimeout(() => {
4000
+ this.suspectUntil = 0;
4001
+ }, 1350);
4002
+ }
4003
+ }
4004
+ };
4005
+
4006
+ // src/voice/soniox.ts
4007
+ init_logging();
4008
+
4009
+ // src/voice/types.ts
4010
+ var STT_SAMPLE_RATE = 16e3;
4011
+ var TTS_SAMPLE_RATE = 44100;
4012
+ async function resolveAuth(auth) {
4013
+ return typeof auth === "function" ? await auth() : auth;
4014
+ }
4015
+
4016
+ // src/voice/soniox.ts
4017
+ var log8 = forComponent("SonioxSTT");
4018
+ var now2 = () => performance.now();
4019
+ var SonioxSTTOptions = class {
4020
+ auth = "";
4021
+ source;
4022
+ model = "stt-rt-preview";
4023
+ languageHints = ["en"];
4024
+ };
4025
+ var SonioxSTT = class {
4026
+ options;
4027
+ ws;
4028
+ stopped = false;
4029
+ sourceStarted = false;
4030
+ onPartial = () => {
4031
+ };
4032
+ onUtterance = () => {
4033
+ };
4034
+ /** mic energy (RMS) per chunk — drives the energy-based heuristic barge-in tier */
4035
+ onLevel = () => {
4036
+ };
4037
+ finalText = "";
4038
+ partialText = "";
4039
+ constructor(options) {
4040
+ this.options = { ...new SonioxSTTOptions(), ...options };
4041
+ }
4042
+ get usingAec() {
4043
+ return this.options.source?.aec ?? false;
4044
+ }
4045
+ async connectWs() {
4046
+ const apiKey = await resolveAuth(this.options.auth);
4047
+ this.ws = new WebSocket("wss://stt-rt.soniox.com/transcribe-websocket");
4048
+ await new Promise((res, rej) => {
4049
+ this.ws.onopen = () => res();
4050
+ this.ws.onerror = (e) => rej(new Error(`soniox ws: ${e.message || "connect failed"}`));
4051
+ });
4052
+ this.ws.send(
4053
+ JSON.stringify({
4054
+ api_key: apiKey,
4055
+ model: this.options.model,
4056
+ audio_format: "pcm_s16le",
4057
+ sample_rate: STT_SAMPLE_RATE,
4058
+ num_channels: 1,
4059
+ language_hints: this.options.languageHints,
4060
+ enable_endpoint_detection: true
4061
+ })
4062
+ );
4063
+ this.ws.onmessage = (ev) => this.handle(JSON.parse(String(ev.data)));
4064
+ this.ws.onclose = (ev) => {
4065
+ if (this.stopped) return;
4066
+ log8.warn(`soniox ws closed (${ev.code} ${ev.reason || ""}) \u2014 reconnecting`);
4067
+ this.reset();
4068
+ this.connectWs().catch((e) => log8.error(`soniox reconnect failed: ${e.message}`));
4069
+ };
4070
+ }
4071
+ async start() {
4072
+ await this.connectWs();
4073
+ if (this.sourceStarted) return;
4074
+ this.sourceStarted = true;
4075
+ await this.options.source.start((chunk) => {
4076
+ let sum = 0;
4077
+ const view = new DataView(chunk.buffer, chunk.byteOffset, chunk.byteLength);
4078
+ for (let i = 0; i + 1 < chunk.byteLength; i += 2) {
4079
+ const v = view.getInt16(i, true);
4080
+ sum += v * v;
4081
+ }
4082
+ this.onLevel(Math.sqrt(sum / (chunk.byteLength / 2)));
4083
+ if (this.ws.readyState === WebSocket.OPEN) this.ws.send(chunk);
4084
+ });
4085
+ }
4086
+ handle(m) {
4087
+ if (m.error_message) return log8.error(`soniox: ${m.error_message}`);
4088
+ let endpoint = false;
4089
+ for (const t of m.tokens ?? []) {
4090
+ if (t.text === "<end>") endpoint = true;
4091
+ else if (t.is_final) this.finalText += t.text;
4092
+ }
4093
+ this.partialText = (m.tokens ?? []).filter((t) => !t.is_final && t.text !== "<end>").map((t) => t.text).join("");
4094
+ this.onPartial(this.finalText + this.partialText);
4095
+ if (endpoint && this.finalText.trim()) {
4096
+ const utterance = this.finalText.trim();
4097
+ this.reset();
4098
+ this.onUtterance(utterance, now2());
4099
+ }
4100
+ }
4101
+ reset() {
4102
+ this.finalText = "";
4103
+ this.partialText = "";
4104
+ }
4105
+ stop() {
4106
+ this.stopped = true;
4107
+ this.options.source?.stop();
4108
+ if (this.ws) this.ws.onclose = null;
4109
+ this.ws?.close();
4110
+ }
4111
+ };
4112
+
4113
+ // src/voice/cartesia.ts
4114
+ init_logging();
4115
+ var log9 = forComponent("CartesiaTTS");
4116
+ var now3 = () => performance.now();
4117
+ var CartesiaTTSOptions = class {
4118
+ auth = "";
4119
+ voiceId = "";
4120
+ model = "sonic-3.5";
4121
+ /** 'apiKey' (server/CLI) → `api_key=` URL param; 'token' (browser, BE-minted) → `access_token=`. */
4122
+ authMode = "apiKey";
4123
+ };
4124
+ var CartesiaTTS = class {
4125
+ options;
4126
+ ws;
4127
+ ctxSeq = 0;
4128
+ ctxId = "";
4129
+ onAudio = () => {
4130
+ };
4131
+ onDone = () => {
4132
+ };
4133
+ firstAudioAt = 0;
4134
+ constructor(options) {
4135
+ this.options = { ...new CartesiaTTSOptions(), ...options };
4136
+ }
4137
+ async connect() {
4138
+ const key = await resolveAuth(this.options.auth);
4139
+ const param = this.options.authMode === "token" ? "access_token" : "api_key";
4140
+ this.ws = new WebSocket(`wss://api.cartesia.ai/tts/websocket?cartesia_version=2026-03-01&${param}=${key}`);
4141
+ await new Promise((res, rej) => {
4142
+ this.ws.onopen = () => res();
4143
+ this.ws.onerror = (e) => rej(new Error(`cartesia ws: ${e.message || "connect failed"}`));
4144
+ });
4145
+ this.ws.onclose = (ev) => log9.warn(`cartesia ws closed (${ev.code} ${ev.reason || ""})`);
4146
+ this.ws.onmessage = (ev) => {
4147
+ const m = JSON.parse(String(ev.data));
4148
+ if (m.context_id && m.context_id !== this.ctxId) return;
4149
+ if (m.type === "chunk" && m.data) {
4150
+ if (!this.firstAudioAt) this.firstAudioAt = now3();
4151
+ this.onAudio(base64ToBytes(m.data));
4152
+ } else if (m.type === "done") this.onDone();
4153
+ else if (m.type === "error" && !/already been cancelled|does not exist/.test(m.message || "")) log9.warn(`cartesia: ${JSON.stringify(m)}`);
4154
+ };
4155
+ }
4156
+ newContext() {
4157
+ this.ctxId = `ctx-${++this.ctxSeq}`;
4158
+ this.firstAudioAt = 0;
4159
+ return this.ctxId;
4160
+ }
4161
+ frame(transcript, cont) {
4162
+ return JSON.stringify({
4163
+ model_id: this.options.model,
4164
+ transcript,
4165
+ voice: { mode: "id", id: this.options.voiceId },
4166
+ output_format: { container: "raw", encoding: "pcm_s16le", sample_rate: TTS_SAMPLE_RATE },
4167
+ context_id: this.ctxId,
4168
+ continue: cont
4169
+ });
4170
+ }
4171
+ speak(text, cont) {
4172
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(this.frame(text, cont));
4173
+ }
4174
+ end() {
4175
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(this.frame("", false));
4176
+ }
4177
+ cancel() {
4178
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify({ context_id: this.ctxId, cancel: true }));
4179
+ }
4180
+ close() {
4181
+ if (this.ws) this.ws.onclose = null;
4182
+ this.ws?.close();
4183
+ }
4184
+ };
4185
+ function base64ToBytes(b64) {
4186
+ if (typeof Buffer !== "undefined") return Buffer.from(b64, "base64");
4187
+ const bin = atob(b64);
4188
+ const out = new Uint8Array(bin.length);
4189
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
4190
+ return out;
4191
+ }
4192
+
4193
+ // src/index.ts
3574
4194
  import { MemFilesystem as MemFilesystem3, IndexedDbFilesystem, CommandExecutor as CommandExecutor2, registerHeadlessCommands as registerHeadlessCommands2 } from "@livx.cc/wcli/core";
3575
4195
 
3576
4196
  // src/mcp.client.ts
3577
4197
  init_logging();
3578
4198
  import { spawn } from "child_process";
3579
- var log7 = forComponent("mcp");
4199
+ var log10 = forComponent("mcp");
3580
4200
  var PROTOCOL_VERSION = "2025-06-18";
3581
4201
  var DEFAULT_TIMEOUT_MS = 3e4;
3582
4202
  var StdioTransport = class {
@@ -3595,7 +4215,7 @@ var StdioTransport = class {
3595
4215
  proc.stdout.setEncoding("utf8");
3596
4216
  proc.stdout.on("data", (chunk) => this.onData(chunk));
3597
4217
  proc.stderr.setEncoding("utf8");
3598
- proc.stderr.on("data", (chunk) => log7.debug(`[${command}] stderr:`, chunk.trimEnd()));
4218
+ proc.stderr.on("data", (chunk) => log10.debug(`[${command}] stderr:`, chunk.trimEnd()));
3599
4219
  proc.on("exit", (code) => this.failAll(new Error(`MCP server "${command}" exited (code ${code})`)));
3600
4220
  proc.on("error", (e) => this.failAll(e instanceof Error ? e : new Error(String(e))));
3601
4221
  }
@@ -3609,7 +4229,7 @@ var StdioTransport = class {
3609
4229
  try {
3610
4230
  this.dispatch(JSON.parse(line));
3611
4231
  } catch (e) {
3612
- log7.debug("dropping non-JSON line from MCP server:", line, e);
4232
+ log10.debug("dropping non-JSON line from MCP server:", line, e);
3613
4233
  }
3614
4234
  }
3615
4235
  }
@@ -3658,7 +4278,7 @@ var StdioTransport = class {
3658
4278
  try {
3659
4279
  this.proc?.stdin?.end();
3660
4280
  } catch (e) {
3661
- log7.debug("stdin end failed", e);
4281
+ log10.debug("stdin end failed", e);
3662
4282
  }
3663
4283
  this.proc?.kill();
3664
4284
  }
@@ -3727,7 +4347,7 @@ function parseSseResponse(body) {
3727
4347
  const obj = JSON.parse(trimmed.slice(5).trim());
3728
4348
  if (obj && (obj.result !== void 0 || obj.error !== void 0)) return obj;
3729
4349
  } catch (e) {
3730
- log7.debug("skipping unparseable SSE data line", e);
4350
+ log10.debug("skipping unparseable SSE data line", e);
3731
4351
  }
3732
4352
  }
3733
4353
  return {};
@@ -3781,16 +4401,16 @@ async function mountMcpServers(servers = {}) {
3781
4401
  for (const [name, cfg] of Object.entries(servers)) {
3782
4402
  if (!cfg || cfg.disabled) continue;
3783
4403
  if (!cfg.command && !cfg.url) {
3784
- log7.warn(`MCP server "${name}" needs a command (stdio) or url (http) \u2014 skipping`);
4404
+ log10.warn(`MCP server "${name}" needs a command (stdio) or url (http) \u2014 skipping`);
3785
4405
  continue;
3786
4406
  }
3787
4407
  try {
3788
4408
  const m = await mountMcpServer(name, cfg);
3789
4409
  out.push(m);
3790
- log7.info(`MCP "${name}" mounted \u2014 ${m.tools.length} tool(s)${m.serverInfo?.name ? ` from ${m.serverInfo.name}` : ""}`);
4410
+ log10.info(`MCP "${name}" mounted \u2014 ${m.tools.length} tool(s)${m.serverInfo?.name ? ` from ${m.serverInfo.name}` : ""}`);
3791
4411
  } catch (e) {
3792
- if (e instanceof McpAuthError) log7.warn(`MCP "${name}" needs-auth: HTTP ${e.status} \u2014 set bearerToken or headers in its config; skipping`);
3793
- else log7.error(`MCP server "${name}" failed to mount: ${e?.message ?? e}`);
4412
+ if (e instanceof McpAuthError) log10.warn(`MCP "${name}" needs-auth: HTTP ${e.status} \u2014 set bearerToken or headers in its config; skipping`);
4413
+ else log10.error(`MCP server "${name}" failed to mount: ${e?.message ?? e}`);
3794
4414
  }
3795
4415
  }
3796
4416
  return out;
@@ -3986,7 +4606,7 @@ function b64url(buf) {
3986
4606
  function defaultOpenBrowser(url) {
3987
4607
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
3988
4608
  const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
3989
- import("child_process").then(({ spawn: spawn2 }) => spawn2(cmd, args, { stdio: "ignore", detached: true }).unref());
4609
+ import("child_process").then(({ spawn: spawn3 }) => spawn3(cmd, args, { stdio: "ignore", detached: true }).unref());
3990
4610
  }
3991
4611
 
3992
4612
  // cli/core.ts
@@ -4121,9 +4741,9 @@ Reference files in them by their mount path (the left side).`;
4121
4741
  // would corrupt those calls.
4122
4742
  ...isCursor ? { providerOptions: { cwd, ...toCursorMcp(o.mcpServers) ?? {} } } : {},
4123
4743
  ...(() => {
4124
- const now = /* @__PURE__ */ new Date();
4744
+ const now5 = /* @__PURE__ */ new Date();
4125
4745
  const platformNames = { darwin: "macOS", linux: "Linux", win32: "Windows" };
4126
- const envNote = `Current date: ${now.toLocaleDateString("en-CA")} ${now.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })} (${Intl.DateTimeFormat().resolvedOptions().timeZone})
4746
+ const envNote = `Current date: ${now5.toLocaleDateString("en-CA")} ${now5.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })} (${Intl.DateTimeFormat().resolvedOptions().timeZone})
4127
4747
  Platform: ${platformNames[platform()] ?? platform()} ${arch()} (${release()})
4128
4748
  User: ${userInfo().username}
4129
4749
  Shell: ${process.env.SHELL ?? "unknown"}`;
@@ -4188,16 +4808,162 @@ function summarizeCall(name, args) {
4188
4808
  }
4189
4809
  var trunc = (s, n) => (s == null ? "" : String(s).length > n ? String(s).slice(0, n) + "\u2026" : String(s)).replace(/\n/g, "\u23CE");
4190
4810
 
4191
- // cli/config.ts
4811
+ // cli/voice.ts
4812
+ init_logging();
4813
+ import { spawn as spawn2, spawnSync } from "child_process";
4814
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, statSync as statSync2 } from "fs";
4192
4815
  import { homedir as homedir2 } from "os";
4193
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
4194
- import { join as join4 } from "path";
4816
+ import { dirname as dirname3, join as join4 } from "path";
4817
+ import { fileURLToPath } from "url";
4818
+ var log12 = forComponent("VoiceIO");
4819
+ var now4 = () => performance.now();
4820
+ var Player = class {
4821
+ proc = null;
4822
+ bytesWritten = 0;
4823
+ startedAt = 0;
4824
+ /** start a new spoken turn: kill any previous player, spawn a fresh one */
4825
+ markTurn() {
4826
+ this.kill();
4827
+ this.proc = spawn2(
4828
+ "ffplay",
4829
+ ["-loglevel", "quiet", "-nodisp", "-fflags", "nobuffer", "-flags", "low_delay", "-probesize", "32", "-f", "s16le", "-ar", String(TTS_SAMPLE_RATE), "-ch_layout", "mono", "-i", "-"],
4830
+ { stdio: ["pipe", "ignore", "ignore"] }
4831
+ );
4832
+ this.proc.on("error", (e) => log12.warn(`ffplay error: ${e.message}`));
4833
+ this.proc.stdin.on("error", () => {
4834
+ });
4835
+ this.bytesWritten = 0;
4836
+ this.startedAt = 0;
4837
+ }
4838
+ write(chunk) {
4839
+ if (!this.proc) this.markTurn();
4840
+ if (!this.startedAt) this.startedAt = now4();
4841
+ this.bytesWritten += chunk.length;
4842
+ this.proc.stdin.write(chunk);
4843
+ }
4844
+ /** ms of audio actually played so far this turn */
4845
+ playedMs() {
4846
+ return this.startedAt ? now4() - this.startedAt : 0;
4847
+ }
4848
+ /** estimated ms until queued audio finishes playing */
4849
+ drainMs() {
4850
+ if (!this.startedAt) return 0;
4851
+ const queuedMs = this.bytesWritten / (TTS_SAMPLE_RATE * 2) * 1e3;
4852
+ return Math.max(0, queuedMs - (now4() - this.startedAt));
4853
+ }
4854
+ kill() {
4855
+ this.proc?.kill("SIGKILL");
4856
+ this.proc = null;
4857
+ }
4858
+ };
4859
+ var nativeDir = () => join4(dirname3(fileURLToPath(import.meta.url)), "native");
4860
+ function detectFfmpegMic() {
4861
+ if (process.env.MIC_DEVICE) return process.env.MIC_DEVICE;
4862
+ const out = spawnSync("ffmpeg", ["-f", "avfoundation", "-list_devices", "true", "-i", ""], { encoding: "utf8" }).stderr;
4863
+ const audio = out.slice(out.indexOf("audio devices"));
4864
+ const devices = [...audio.matchAll(/\[(\d+)\] (.+)/g)].map(([, idx, name]) => ({ idx, name: name.trim() }));
4865
+ const mic = devices.find((d) => /microphone|built-in/i.test(d.name) && !/teams|blackhole|loopback/i.test(d.name)) ?? devices[0];
4866
+ if (!mic) throw new Error("no audio input device found");
4867
+ log12.debug(`ffmpeg mic: [${mic.idx}] ${mic.name}`);
4868
+ return `:${mic.idx}`;
4869
+ }
4870
+ function resolveAecBinary() {
4871
+ if (process.env.MIC_AEC === "0" || process.platform !== "darwin") return null;
4872
+ const src = join4(nativeDir(), "mic-aec.swift");
4873
+ const plist = join4(nativeDir(), "Info.plist");
4874
+ if (!existsSync3(src)) return null;
4875
+ const cacheDir = join4(homedir2(), ".agent", "cache");
4876
+ const bin = join4(cacheDir, "mic-aec");
4877
+ if (existsSync3(bin) && statSync2(bin).mtimeMs >= statSync2(src).mtimeMs) return bin;
4878
+ if (spawnSync("which", ["swiftc"]).status !== 0) return null;
4879
+ mkdirSync3(cacheDir, { recursive: true });
4880
+ log12.info("compiling AEC mic helper (first run)\u2026");
4881
+ const build = spawnSync("swiftc", ["-O", "-o", bin, src, "-Xlinker", "-sectcreate", "-Xlinker", "__TEXT", "-Xlinker", "__info_plist", "-Xlinker", plist], { encoding: "utf8" });
4882
+ if (build.status !== 0) {
4883
+ log12.warn(`AEC build failed: ${build.stderr?.slice(0, 400)}`);
4884
+ return null;
4885
+ }
4886
+ const sign = spawnSync("codesign", ["-fs", "-", bin], { encoding: "utf8" });
4887
+ if (sign.status !== 0) {
4888
+ log12.warn(`codesign failed: ${sign.stderr?.slice(0, 200)}`);
4889
+ return null;
4890
+ }
4891
+ return bin;
4892
+ }
4893
+ var NodeMicSource = class {
4894
+ aec;
4895
+ bin;
4896
+ proc = null;
4897
+ stopped = false;
4898
+ constructor() {
4899
+ this.bin = resolveAecBinary();
4900
+ this.aec = !!this.bin;
4901
+ }
4902
+ start(onChunk) {
4903
+ if (this.bin) {
4904
+ this.proc = spawn2(this.bin, [], { stdio: ["ignore", "pipe", "ignore"] });
4905
+ } else {
4906
+ if (spawnSync("which", ["ffmpeg"]).status !== 0) throw new Error("voice I/O unavailable: no AEC helper and no ffmpeg on PATH");
4907
+ log12.info("mic: raw capture (no AEC) \u2014 echo handled heuristically; headphones recommended");
4908
+ this.proc = spawn2(
4909
+ "ffmpeg",
4910
+ ["-loglevel", "error", "-f", "avfoundation", "-i", detectFfmpegMic(), "-ar", String(STT_SAMPLE_RATE), "-ac", "1", "-f", "s16le", "-"],
4911
+ { stdio: ["ignore", "pipe", "pipe"] }
4912
+ );
4913
+ this.proc.stderr.on("data", (d) => log12.warn(`ffmpeg: ${String(d).trim()}`));
4914
+ }
4915
+ this.proc.on("exit", (c) => {
4916
+ if (c && !this.stopped) log12.error(`mic capture exited (${c}) \u2014 check mic permission / MIC_DEVICE / MIC_AEC=0`);
4917
+ });
4918
+ this.proc.stdout.on("data", (chunk) => onChunk(chunk));
4919
+ }
4920
+ stop() {
4921
+ this.stopped = true;
4922
+ const p = this.proc;
4923
+ this.proc = null;
4924
+ if (!p) return;
4925
+ p.kill("SIGTERM");
4926
+ setTimeout(() => {
4927
+ try {
4928
+ p.kill("SIGKILL");
4929
+ } catch {
4930
+ }
4931
+ }, 500).unref?.();
4932
+ }
4933
+ };
4934
+ var VoiceIOOptions = class extends VoiceEngineOptions {
4935
+ sonioxApiKey = process.env.SONIOX_API_KEY ?? "";
4936
+ cartesiaApiKey = process.env.CARTESIA_API_KEY ?? "";
4937
+ cartesiaVoiceId = process.env.CARTESIA_VOICE_ID ?? "";
4938
+ };
4939
+ var VoiceIO = class extends VoiceEngine {
4940
+ constructor(options) {
4941
+ const o = { ...new VoiceIOOptions(), ...options };
4942
+ super({
4943
+ ...o,
4944
+ stt: o.stt ?? new SonioxSTT({ auth: o.sonioxApiKey, source: new NodeMicSource() }),
4945
+ tts: o.tts ?? new CartesiaTTS({ auth: o.cartesiaApiKey, voiceId: o.cartesiaVoiceId }),
4946
+ player: o.player ?? new Player(),
4947
+ bargeRmsMult: Number(process.env.BARGE_RMS_MULT || o.bargeRmsMult),
4948
+ bargeRmsFloor: Number(process.env.BARGE_RMS_FLOOR || o.bargeRmsFloor)
4949
+ });
4950
+ }
4951
+ /** ready = keys present (AEC vs heuristic is decided at start()) */
4952
+ static available(env = process.env) {
4953
+ return !!(env.SONIOX_API_KEY && env.CARTESIA_API_KEY && env.CARTESIA_VOICE_ID);
4954
+ }
4955
+ };
4956
+
4957
+ // cli/config.ts
4958
+ import { homedir as homedir3 } from "os";
4959
+ import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
4960
+ import { join as join5 } from "path";
4195
4961
  import { pathToFileURL } from "url";
4196
4962
  var FILES = ["config.ts", "config.js", "config.mjs", "config.json"];
4197
4963
  async function loadFrom(dir) {
4198
4964
  for (const f of FILES) {
4199
- const p = join4(dir, ".agent", f);
4200
- if (!existsSync3(p)) continue;
4965
+ const p = join5(dir, ".agent", f);
4966
+ if (!existsSync4(p)) continue;
4201
4967
  try {
4202
4968
  const mod = await import(pathToFileURL(p).href, f.endsWith(".json") ? { with: { type: "json" } } : void 0);
4203
4969
  return mod.default ?? mod.config ?? mod;
@@ -4209,8 +4975,8 @@ async function loadFrom(dir) {
4209
4975
  return {};
4210
4976
  }
4211
4977
  function loadSettings(dir) {
4212
- const p = join4(dir, ".agent", "settings.json");
4213
- if (!existsSync3(p)) return {};
4978
+ const p = join5(dir, ".agent", "settings.json");
4979
+ if (!existsSync4(p)) return {};
4214
4980
  try {
4215
4981
  const raw = JSON.parse(readFileSync2(p, "utf8"));
4216
4982
  const cfg = {};
@@ -4233,8 +4999,8 @@ function loadSettings(dir) {
4233
4999
  }
4234
5000
  }
4235
5001
  async function loadConfig(cwd) {
4236
- const userSettings = loadSettings(homedir2());
4237
- const user = await loadFrom(homedir2());
5002
+ const userSettings = loadSettings(homedir3());
5003
+ const user = await loadFrom(homedir3());
4238
5004
  const projectSettings = loadSettings(cwd);
4239
5005
  const project = await loadFrom(cwd);
4240
5006
  const merged = { ...userSettings, ...user, ...projectSettings, ...project };
@@ -4245,8 +5011,8 @@ async function loadConfig(cwd) {
4245
5011
  }
4246
5012
 
4247
5013
  // cli/hooks-config.ts
4248
- import { spawnSync } from "child_process";
4249
- var log9 = forComponent("hooks");
5014
+ import { spawnSync as spawnSync2 } from "child_process";
5015
+ var log13 = forComponent("hooks");
4250
5016
  var escapeRegex = (s) => s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
4251
5017
  function ruleMatches(rule, toolName) {
4252
5018
  if (!rule.tool || rule.tool === "*") return true;
@@ -4255,7 +5021,7 @@ function ruleMatches(rule, toolName) {
4255
5021
  }
4256
5022
  function runCmd(rule, env) {
4257
5023
  try {
4258
- const r = spawnSync(rule.command, {
5024
+ const r = spawnSync2(rule.command, {
4259
5025
  shell: true,
4260
5026
  encoding: "utf8",
4261
5027
  timeout: rule.timeoutMs ?? 1e4,
@@ -4263,7 +5029,7 @@ function runCmd(rule, env) {
4263
5029
  });
4264
5030
  return { code: r.status ?? 1, out: ((r.stdout ?? "") + (r.stderr ?? "")).trim() };
4265
5031
  } catch (e) {
4266
- log9.debug(`hook command failed: ${rule.command}`, e);
5032
+ log13.debug(`hook command failed: ${rule.command}`, e);
4267
5033
  return { code: 1, out: String(e?.message ?? e) };
4268
5034
  }
4269
5035
  }
@@ -4367,17 +5133,17 @@ function formatDiff(ops, opts = {}) {
4367
5133
  }
4368
5134
 
4369
5135
  // cli/session.ts
4370
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, readdirSync, renameSync } from "fs";
4371
- import { join as join5 } from "path";
4372
- var log10 = forComponent("session");
5136
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync3, readdirSync, renameSync } from "fs";
5137
+ import { join as join6 } from "path";
5138
+ var log14 = forComponent("session");
4373
5139
  var SessionStore = class {
4374
5140
  dir;
4375
5141
  constructor(cwd) {
4376
- this.dir = join5(cwd, ".agent", "sessions");
5142
+ this.dir = join6(cwd, ".agent", "sessions");
4377
5143
  }
4378
5144
  /** Sortable, human-readable id: `YYYYMMDD-HHMMSS-mmm`. */
4379
- newId(now = Date.now()) {
4380
- const d = new Date(now);
5145
+ newId(now5 = Date.now()) {
5146
+ const d = new Date(now5);
4381
5147
  const p = (n, w = 2) => String(n).padStart(w, "0");
4382
5148
  return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${p(d.getMilliseconds(), 3)}`;
4383
5149
  }
@@ -4387,36 +5153,36 @@ var SessionStore = class {
4387
5153
  }
4388
5154
  save(data) {
4389
5155
  if (!this.safeId(data.meta.id)) throw new Error(`unsafe session id: ${data.meta.id}`);
4390
- if (!existsSync4(this.dir)) mkdirSync3(this.dir, { recursive: true });
4391
- const path = join5(this.dir, `${data.meta.id}.json`);
5156
+ if (!existsSync5(this.dir)) mkdirSync4(this.dir, { recursive: true });
5157
+ const path = join6(this.dir, `${data.meta.id}.json`);
4392
5158
  const tmp = `${path}.${process.pid}.tmp`;
4393
5159
  writeFileSync3(tmp, JSON.stringify(data));
4394
5160
  renameSync(tmp, path);
4395
5161
  }
4396
5162
  load(id) {
4397
5163
  if (!this.safeId(id)) {
4398
- log10.debug(`rejecting unsafe session id: ${id}`);
5164
+ log14.debug(`rejecting unsafe session id: ${id}`);
4399
5165
  return void 0;
4400
5166
  }
4401
- const path = join5(this.dir, `${id}.json`);
4402
- if (!existsSync4(path)) return void 0;
5167
+ const path = join6(this.dir, `${id}.json`);
5168
+ if (!existsSync5(path)) return void 0;
4403
5169
  try {
4404
5170
  return JSON.parse(readFileSync3(path, "utf8"));
4405
5171
  } catch (e) {
4406
- log10.debug(`unreadable session ${id} \u2014 ignoring`, e);
5172
+ log14.debug(`unreadable session ${id} \u2014 ignoring`, e);
4407
5173
  return void 0;
4408
5174
  }
4409
5175
  }
4410
5176
  /** All sessions' metadata, most-recently-updated first. */
4411
5177
  list() {
4412
- if (!existsSync4(this.dir)) return [];
5178
+ if (!existsSync5(this.dir)) return [];
4413
5179
  const metas = [];
4414
5180
  for (const f of readdirSync(this.dir)) {
4415
5181
  if (!f.endsWith(".json")) continue;
4416
5182
  try {
4417
- metas.push(JSON.parse(readFileSync3(join5(this.dir, f), "utf8")).meta);
5183
+ metas.push(JSON.parse(readFileSync3(join6(this.dir, f), "utf8")).meta);
4418
5184
  } catch (e) {
4419
- log10.debug(`skipping unreadable session file ${f}`, e);
5185
+ log14.debug(`skipping unreadable session file ${f}`, e);
4420
5186
  }
4421
5187
  }
4422
5188
  return metas.sort((a, b) => b.updated - a.updated);
@@ -4508,9 +5274,9 @@ var CheckpointStack = class {
4508
5274
  // cli/gitCheckpoints.ts
4509
5275
  import { execFile } from "child_process";
4510
5276
  import { promisify } from "util";
4511
- import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, existsSync as existsSync5 } from "fs";
4512
- import { join as join6, resolve as resolve2, sep as sep2 } from "path";
4513
- var log11 = forComponent("checkpoints");
5277
+ import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, existsSync as existsSync6 } from "fs";
5278
+ import { join as join7, resolve as resolve2, sep as sep2 } from "path";
5279
+ var log15 = forComponent("checkpoints");
4514
5280
  var exec = promisify(execFile);
4515
5281
  var DEFAULT_EXCLUDE = [".agent/", ".git/", "node_modules/", "dist/", "build/", ".next/", "target/", ".venv/", "__pycache__/", "*.log"];
4516
5282
  var ShadowRepo = class {
@@ -4537,14 +5303,14 @@ var ShadowRepo = class {
4537
5303
  if (this.ready !== void 0) return this.ready;
4538
5304
  try {
4539
5305
  await exec(this.git, ["--version"]);
4540
- if (!existsSync5(this.gitDir)) {
4541
- mkdirSync4(this.gitDir, { recursive: true });
5306
+ if (!existsSync6(this.gitDir)) {
5307
+ mkdirSync5(this.gitDir, { recursive: true });
4542
5308
  await this.run("init", "-q");
4543
5309
  }
4544
- writeFileSync4(join6(this.gitDir, "info", "exclude"), this.exclude.join("\n") + "\n");
5310
+ writeFileSync4(join7(this.gitDir, "info", "exclude"), this.exclude.join("\n") + "\n");
4545
5311
  this.ready = true;
4546
5312
  } catch (e) {
4547
- log11.debug(`git checkpoints unavailable for ${this.workTree}`, e);
5313
+ log15.debug(`git checkpoints unavailable for ${this.workTree}`, e);
4548
5314
  this.ready = false;
4549
5315
  }
4550
5316
  return this.ready;
@@ -4607,7 +5373,7 @@ var ShadowRepo = class {
4607
5373
  await this.run("gc", "--auto", "-q").catch(() => {
4608
5374
  });
4609
5375
  } catch (e) {
4610
- log11.debug("checkpoint prune failed", e);
5376
+ log15.debug("checkpoint prune failed", e);
4611
5377
  }
4612
5378
  }
4613
5379
  };
@@ -4637,7 +5403,7 @@ var GitCheckpoints = class {
4637
5403
  const abs = resolve2(d);
4638
5404
  if (abs === cwd || abs.startsWith(cwd + sep2)) continue;
4639
5405
  if (cwd.startsWith(abs + sep2)) continue;
4640
- out.push({ workTree: abs, gitDir: join6(abs, ".agent", "checkpoints.git") });
5406
+ out.push({ workTree: abs, gitDir: join7(abs, ".agent", "checkpoints.git") });
4641
5407
  }
4642
5408
  return out;
4643
5409
  }
@@ -4664,7 +5430,7 @@ var GitCheckpoints = class {
4664
5430
  use(sessionId) {
4665
5431
  if (sessionId === this.session) return;
4666
5432
  this.session = sessionId;
4667
- if (this.started) for (const r of this.repos) void r.point(this.ref()).catch((e) => log11.debug("re-point failed", e));
5433
+ if (this.started) for (const r of this.repos) void r.point(this.ref()).catch((e) => log15.debug("re-point failed", e));
4668
5434
  }
4669
5435
  async begin(label) {
4670
5436
  if (!await this.start()) return;
@@ -4675,7 +5441,7 @@ var GitCheckpoints = class {
4675
5441
  try {
4676
5442
  await r.commit(msg);
4677
5443
  } catch (e) {
4678
- log11.debug("checkpoint commit failed", e);
5444
+ log15.debug("checkpoint commit failed", e);
4679
5445
  }
4680
5446
  }
4681
5447
  if (slow) clearTimeout(slow);
@@ -4718,7 +5484,7 @@ var GitCheckpointsOptions = class {
4718
5484
  /** Real working tree to snapshot (the launch cwd). */
4719
5485
  workTree = process.cwd();
4720
5486
  /** Isolated git dir for the cwd shadow repo (kept out of the user's real .git). */
4721
- gitDir = join6(process.cwd(), ".agent", "checkpoints.git");
5487
+ gitDir = join7(process.cwd(), ".agent", "checkpoints.git");
4722
5488
  /** Extra mounted dirs (`--add-dir`); those outside cwd each get their own shadow repo. */
4723
5489
  addDirs = [];
4724
5490
  /** Conversation id → per-session restore-point ref. */
@@ -4732,9 +5498,9 @@ var GitCheckpointsOptions = class {
4732
5498
  };
4733
5499
 
4734
5500
  // cli/permissions.ts
4735
- import { existsSync as existsSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync5, mkdirSync as mkdirSync5 } from "fs";
4736
- import { homedir as homedir3 } from "os";
4737
- import { join as join7 } from "path";
5501
+ import { existsSync as existsSync7, readFileSync as readFileSync4, writeFileSync as writeFileSync5, mkdirSync as mkdirSync6 } from "fs";
5502
+ import { homedir as homedir4 } from "os";
5503
+ import { join as join8 } from "path";
4738
5504
  var RULE_RE = /^(\w+)(?:\((.+)\))?$/;
4739
5505
  function parseOne(raw, decision) {
4740
5506
  const m = RULE_RE.exec(raw.trim());
@@ -4756,10 +5522,10 @@ function parsePermRules(perms) {
4756
5522
  function describeRule(r) {
4757
5523
  return `${r.decision.padEnd(5)} ${r.tool ?? "*"}${r.pathGlob ? `(${r.pathGlob})` : ""}`;
4758
5524
  }
4759
- var PERM_FILE = (cwd) => join7(cwd, ".agent", "permissions.json");
5525
+ var PERM_FILE = (cwd) => join8(cwd, ".agent", "permissions.json");
4760
5526
  function loadPersistedRules(cwd) {
4761
5527
  const p = PERM_FILE(cwd);
4762
- if (!existsSync6(p)) return {};
5528
+ if (!existsSync7(p)) return {};
4763
5529
  try {
4764
5530
  const j = JSON.parse(readFileSync4(p, "utf8"));
4765
5531
  return { allow: j.allow ?? [], ask: j.ask ?? [], deny: j.deny ?? [] };
@@ -4767,11 +5533,11 @@ function loadPersistedRules(cwd) {
4767
5533
  return {};
4768
5534
  }
4769
5535
  }
4770
- function loadClaudeSettings(cwd, home = homedir3()) {
4771
- const files = [join7(home, ".claude", "settings.json"), join7(cwd, ".claude", "settings.json"), join7(cwd, ".claude", "settings.local.json")];
5536
+ function loadClaudeSettings(cwd, home = homedir4()) {
5537
+ const files = [join8(home, ".claude", "settings.json"), join8(cwd, ".claude", "settings.json"), join8(cwd, ".claude", "settings.local.json")];
4772
5538
  let out = {};
4773
5539
  for (const p of files) {
4774
- if (!existsSync6(p)) continue;
5540
+ if (!existsSync7(p)) continue;
4775
5541
  try {
4776
5542
  const perms = JSON.parse(readFileSync4(p, "utf8"))?.permissions;
4777
5543
  if (perms) out = mergePerms(out, { allow: perms.allow, ask: perms.ask, deny: perms.deny }) ?? out;
@@ -4785,7 +5551,7 @@ function persistRule(cwd, decision, ruleStr) {
4785
5551
  const list = cur[decision] ??= [];
4786
5552
  if (!list.includes(ruleStr)) list.push(ruleStr);
4787
5553
  try {
4788
- mkdirSync5(join7(cwd, ".agent"), { recursive: true });
5554
+ mkdirSync6(join8(cwd, ".agent"), { recursive: true });
4789
5555
  writeFileSync5(PERM_FILE(cwd), JSON.stringify(cur, null, 2) + "\n");
4790
5556
  } catch {
4791
5557
  }
@@ -4799,10 +5565,10 @@ function mergePerms(a, b) {
4799
5565
  }
4800
5566
  return Object.keys(out).length ? out : void 0;
4801
5567
  }
4802
- var TRUST_FILE = join7(homedir3(), ".agent", "trusted.json");
5568
+ var TRUST_FILE = join8(homedir4(), ".agent", "trusted.json");
4803
5569
  function isTrusted(cwd, file = TRUST_FILE) {
4804
5570
  try {
4805
- return existsSync6(file) && JSON.parse(readFileSync4(file, "utf8")).includes(cwd);
5571
+ return existsSync7(file) && JSON.parse(readFileSync4(file, "utf8")).includes(cwd);
4806
5572
  } catch {
4807
5573
  return false;
4808
5574
  }
@@ -4810,12 +5576,12 @@ function isTrusted(cwd, file = TRUST_FILE) {
4810
5576
  function trustDir(cwd, file = TRUST_FILE) {
4811
5577
  let list = [];
4812
5578
  try {
4813
- if (existsSync6(file)) list = JSON.parse(readFileSync4(file, "utf8"));
5579
+ if (existsSync7(file)) list = JSON.parse(readFileSync4(file, "utf8"));
4814
5580
  } catch {
4815
5581
  }
4816
5582
  if (!list.includes(cwd)) list.push(cwd);
4817
5583
  try {
4818
- mkdirSync5(join7(file, ".."), { recursive: true });
5584
+ mkdirSync6(join8(file, ".."), { recursive: true });
4819
5585
  writeFileSync5(file, JSON.stringify(list, null, 2) + "\n");
4820
5586
  } catch {
4821
5587
  }
@@ -5564,8 +6330,11 @@ function createLineEditor(out) {
5564
6330
  // cyan
5565
6331
  };
5566
6332
  let curRow = 0;
6333
+ let suspended = false;
5567
6334
  let activeRedraw;
6335
+ let activeAbort;
5568
6336
  function render(s, promptArg, maxVisible, status) {
6337
+ if (suspended) return;
5569
6338
  const cols = out.columns ?? 80;
5570
6339
  const mode = !s.searching ? inputMode(s.buf) : void 0;
5571
6340
  const vimTag = s.vim === "normal" && !s.searching && !mode ? inverse(" N ") + " " : "";
@@ -5630,9 +6399,18 @@ function createLineEditor(out) {
5630
6399
  };
5631
6400
  process.on("SIGWINCH", onResize);
5632
6401
  return new Promise((resolve4) => {
6402
+ activeAbort = () => {
6403
+ finish();
6404
+ resolve4(null);
6405
+ };
5633
6406
  const redraw = () => render(s, opts.prompt, maxVisible, opts.status);
6407
+ let lastStatus = opts.status?.() ?? "";
5634
6408
  const ticker = opts.statusTickMs && opts.status ? setInterval(() => {
5635
- if (!s.pasting) redraw();
6409
+ if (s.pasting) return;
6410
+ const cur = opts.status();
6411
+ if (cur === lastStatus) return;
6412
+ lastStatus = cur;
6413
+ redraw();
5636
6414
  }, opts.statusTickMs) : void 0;
5637
6415
  const onKey = (str, key) => {
5638
6416
  if (key?.ctrl && key.name === "l") {
@@ -5656,6 +6434,21 @@ function createLineEditor(out) {
5656
6434
  redraw();
5657
6435
  return;
5658
6436
  }
6437
+ if (key?.ctrl && key.name === "s" || key?.meta && key.name === "s") {
6438
+ if (s.buf.trim() && opts.onStash) {
6439
+ opts.onStash(s.expand());
6440
+ s.reset();
6441
+ s.refresh();
6442
+ redraw();
6443
+ } else if (!s.buf.length && opts.onUnstash) {
6444
+ const text = opts.onUnstash();
6445
+ if (text) {
6446
+ s.insert(text);
6447
+ redraw();
6448
+ }
6449
+ }
6450
+ return;
6451
+ }
5659
6452
  if (key?.meta && key.name === "p" && opts.onPickModel) {
5660
6453
  process.stdin.off("keypress", onKey);
5661
6454
  void opts.onPickModel().finally(() => {
@@ -5693,6 +6486,7 @@ function createLineEditor(out) {
5693
6486
  };
5694
6487
  const finish = () => {
5695
6488
  activeRedraw = void 0;
6489
+ activeAbort = void 0;
5696
6490
  if (ticker) clearInterval(ticker);
5697
6491
  process.stdin.off("keypress", onKey);
5698
6492
  process.removeListener("SIGWINCH", onResize);
@@ -5705,7 +6499,24 @@ function createLineEditor(out) {
5705
6499
  process.stdin.on("keypress", onKey);
5706
6500
  });
5707
6501
  }
5708
- return { readLine, redrawNow: () => activeRedraw?.() };
6502
+ return {
6503
+ readLine,
6504
+ redrawNow: () => activeRedraw?.(),
6505
+ suspend: () => {
6506
+ if (suspended) return;
6507
+ suspended = true;
6508
+ if (curRow > 0) out.write(`\x1B[${curRow}A`);
6509
+ out.write("\r\x1B[J");
6510
+ curRow = 0;
6511
+ },
6512
+ resume: () => {
6513
+ if (!suspended) return;
6514
+ suspended = false;
6515
+ curRow = 0;
6516
+ activeRedraw?.();
6517
+ },
6518
+ abort: () => activeAbort?.()
6519
+ };
5709
6520
  }
5710
6521
  function selectMenu(out, opts) {
5711
6522
  if (!out.isTTY || !process.stdin.isTTY || !opts.items.length) return Promise.resolve(null);
@@ -6006,7 +6817,7 @@ var red = C("31");
6006
6817
  var bold = C("1");
6007
6818
  var yellow = C("33");
6008
6819
  var err = (s) => process.stderr.write(s);
6009
- var log12 = forComponent("cli");
6820
+ var log16 = forComponent("cli");
6010
6821
  var VERSION = (() => {
6011
6822
  try {
6012
6823
  return JSON.parse(readFileSync5(new URL("../package.json", import.meta.url), "utf8")).version ?? "?";
@@ -6033,6 +6844,9 @@ var spinner = /* @__PURE__ */ (() => {
6033
6844
  };
6034
6845
  })();
6035
6846
  var activeTurn = null;
6847
+ var exitRequested = false;
6848
+ var inputStash = [];
6849
+ var stashBuf = "";
6036
6850
  function numFlag(raw, flag) {
6037
6851
  const n = Number(raw);
6038
6852
  if (!Number.isFinite(n) || n < 0) throw new Error(`invalid ${flag}: ${raw ?? "(missing value)"}`);
@@ -6137,6 +6951,8 @@ Flags:
6137
6951
  to a background worker agent (-m model); results are re-voiced when ready
6138
6952
  --conversational duplex with a conversation-native register \u2014 short fast turns, fillers,
6139
6953
  impulsive reactions, human pacing (implies --duplex; aliases: --convo, --voice)
6954
+ with SONIOX_API_KEY + CARTESIA_API_KEY(+VOICE_ID) set: real voice I/O \u2014 mic in,
6955
+ spoken replies out (echo-cancelled; speak over it to interrupt)
6140
6956
  --voice-model <id> with --duplex: the fast voice model (default anthropic/claude-haiku-4-5)
6141
6957
  --add-dir <path> mount another directory into the workspace (repeatable; disk mode only)
6142
6958
  --subagents allow the Task tool (spawn child agents)
@@ -6170,7 +6986,8 @@ REPL shortcuts: !<cmd> runs a shell command inline \xB7 #<note> saves a memory \
6170
6986
  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 /exit
6171
6987
  REPL completion: type / (commands+skills) or @ (files) for a LIVE menu \u2014 \u2191/\u2193 select, \u23CE/Tab accept, Esc dismiss.
6172
6988
  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.
6173
- 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.
6989
+ 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.
6990
+ REPL stash: type while a turn is running \u2192 Enter queues it (auto-submits when the turn finishes). Alt+S (or Ctrl+S) with text stashes it; on an empty prompt pops the next entry for editing.
6174
6991
  REPL editing (emacs/readline): Ctrl-A/E line start/end \xB7 Ctrl-B/F char \xB7 Alt-B/F or Alt/Ctrl-\u2190/\u2192 word \xB7 Ctrl-W kill word \xB7 Ctrl-U/K kill to start/end \xB7 Ctrl-Y yank \xB7 Alt-D kill word fwd \xB7 Ctrl-L clear screen. Set editorMode:'vim' (or /config) for modal vim editing.
6175
6992
  REPL paste: large/multi-line pastes collapse to a [Pasted text +N lines] preview (expands on send); a pasted image/file path attaches as [Image]/[File]; /paste grabs a clipboard image (macOS).`;
6176
6993
  function newestModel() {
@@ -6188,11 +7005,11 @@ function resolveModelOrNewest(model) {
6188
7005
  }
6189
7006
  var ENV_KEY_ALIASES = { google: ["GEMINI_API_KEY"] };
6190
7007
  function loadInstallEnv() {
6191
- let dir = dirname3(import.meta.path);
6192
- for (let i = 0; i < 5 && !existsSync7(join8(dir, "package.json")); i++) dir = dirname3(dir);
7008
+ let dir = dirname4(import.meta.path);
7009
+ for (let i = 0; i < 5 && !existsSync8(join9(dir, "package.json")); i++) dir = dirname4(dir);
6193
7010
  for (const name of [".env", ".env.local"]) {
6194
- const file = join8(dir, name);
6195
- if (!existsSync7(file)) continue;
7011
+ const file = join9(dir, name);
7012
+ if (!existsSync8(file)) continue;
6196
7013
  for (const line of readFileSync5(file, "utf8").split("\n")) {
6197
7014
  const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
6198
7015
  if (!m || m[1] in process.env) continue;
@@ -6572,7 +7389,7 @@ async function mountMcp(cfg, oauth) {
6572
7389
  return mounted;
6573
7390
  }
6574
7391
  async function closeMcp(mounted) {
6575
- await Promise.all(mounted.map((m) => m.client.close().catch((e) => log12.debug("mcp close failed", e))));
7392
+ await Promise.all(mounted.map((m) => m.client.close().catch((e) => log16.debug("mcp close failed", e))));
6576
7393
  }
6577
7394
  var IMG_EXT = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
6578
7395
  function readImageParts(cwd, line) {
@@ -6595,9 +7412,9 @@ function pastePathClassifier(cwd) {
6595
7412
  t = t.replace(/\\ /g, " ").replace(/^['"]|['"]$/g, "");
6596
7413
  if (/\s/.test(t)) return null;
6597
7414
  if (!/^(\/|~\/|\.\/|\.\.\/)/.test(t)) return null;
6598
- const abs = t.startsWith("~/") ? join8(homedir4(), t.slice(2)) : resolve3(cwd, t);
7415
+ const abs = t.startsWith("~/") ? join9(homedir5(), t.slice(2)) : resolve3(cwd, t);
6599
7416
  try {
6600
- if (!statSync2(abs).isFile()) return null;
7417
+ if (!statSync3(abs).isFile()) return null;
6601
7418
  } catch {
6602
7419
  return null;
6603
7420
  }
@@ -6700,7 +7517,7 @@ async function runTurn(agent, store, session, task, cp, cwd = process.cwd(), sen
6700
7517
  const tools = res.messages.slice(lastUser).filter((m2) => m2.role === "tool").length;
6701
7518
  const ok = res.finishReason === "stop";
6702
7519
  const shortId = session.meta.id.slice(-10);
6703
- err("\n" + (ok ? green(" \u2713 done") : red(` \u2717 ${res.finishReason}`)) + dim(` \xB7 ${res.steps} steps \xB7 ${tools} tools \xB7 ${tok}${secs}s \xB7 ${shortId}
7520
+ err("\n" + (process.stderr.isTTY ? "\r\x1B[0J" : "") + (ok ? green(" \u2713 done") : red(` \u2717 ${res.finishReason}`)) + dim(` \xB7 ${res.steps} steps \xB7 ${tools} tools \xB7 ${tok}${secs}s \xB7 ${shortId}
6704
7521
  `));
6705
7522
  if (res.finishReason === "error" && res.error) {
6706
7523
  const e = res.error;
@@ -6731,8 +7548,8 @@ function startSession(args, store, agent, cwd) {
6731
7548
  if (data) {
6732
7549
  agent.transcript = data.messages;
6733
7550
  if (args.fork) {
6734
- const now2 = Date.now();
6735
- const forked = { meta: { ...data.meta, id: args.sessionId ?? store.newId(now2), created: now2, updated: now2, turns: data.meta.turns }, messages: data.messages };
7551
+ const now6 = Date.now();
7552
+ const forked = { meta: { ...data.meta, id: args.sessionId ?? store.newId(now6), created: now6, updated: now6, turns: data.meta.turns }, messages: data.messages };
6736
7553
  err(dim(` forked ${data.meta.id} \u2192 ${forked.meta.id} (${data.meta.turns} turns)
6737
7554
  `));
6738
7555
  if (!args.task) printHistory(data.messages);
@@ -6746,11 +7563,11 @@ function startSession(args, store, agent, cwd) {
6746
7563
  err(yellow(` no session to resume \u2014 starting fresh
6747
7564
  `));
6748
7565
  }
6749
- const now = Date.now();
6750
- const id = args.sessionId ?? store.newId(now);
7566
+ const now5 = Date.now();
7567
+ const id = args.sessionId ?? store.newId(now5);
6751
7568
  if (!args.task) err(dim(` session ${id}
6752
7569
  `));
6753
- return { meta: { id, created: now, updated: now, cwd, model: agent.options.model, turns: 0, title: "" }, messages: [] };
7570
+ return { meta: { id, created: now5, updated: now5, cwd, model: agent.options.model, turns: 0, title: "" }, messages: [] };
6754
7571
  }
6755
7572
  var AGENTS_MD_TEMPLATE = `# ${"${name}"}
6756
7573
 
@@ -6770,24 +7587,24 @@ var AGENTS_MD_TEMPLATE = `# ${"${name}"}
6770
7587
  `;
6771
7588
  function initInstructions(cwd) {
6772
7589
  for (const f of ["AGENTS.md", "CLAUDE.md"]) {
6773
- if (existsSync7(join8(cwd, f))) {
7590
+ if (existsSync8(join9(cwd, f))) {
6774
7591
  err(yellow(` ${f} already exists \u2014 leaving it as-is
6775
7592
  `));
6776
7593
  return;
6777
7594
  }
6778
7595
  }
6779
- const path = join8(cwd, "AGENTS.md");
7596
+ const path = join9(cwd, "AGENTS.md");
6780
7597
  writeFileSync6(path, AGENTS_MD_TEMPLATE.replace("${name}", basename2(cwd)));
6781
7598
  err(green(` created ${path}
6782
7599
  `) + dim(" edit it, then it auto-loads into every run.\n"));
6783
7600
  }
6784
7601
  function persistSetting(cwd, key, value) {
6785
- const path = join8(cwd, ".agent", "settings.json");
7602
+ const path = join9(cwd, ".agent", "settings.json");
6786
7603
  try {
6787
- const obj = existsSync7(path) ? JSON.parse(readFileSync5(path, "utf8")) : {};
7604
+ const obj = existsSync8(path) ? JSON.parse(readFileSync5(path, "utf8")) : {};
6788
7605
  if (obj[key] === value) return;
6789
7606
  obj[key] = value;
6790
- mkdirSync6(dirname3(path), { recursive: true });
7607
+ mkdirSync7(dirname4(path), { recursive: true });
6791
7608
  writeFileSync6(path, JSON.stringify(obj, null, 2) + "\n");
6792
7609
  } catch (e) {
6793
7610
  err(yellow(` \u26A0 couldn't persist ${key} to ${path} \u2014 ${e?.message ?? e}
@@ -6804,14 +7621,14 @@ var isCancelTeardown = (e) => {
6804
7621
  function installCancelGuards(mounted) {
6805
7622
  process.on("unhandledRejection", (e) => {
6806
7623
  if (isCancelTeardown(e)) {
6807
- log12.debug("suppressed unhandledRejection (cursor stream cancel)", e);
7624
+ log16.debug("suppressed unhandledRejection (cursor stream cancel)", e);
6808
7625
  return;
6809
7626
  }
6810
- log12.error("unhandledRejection", e);
7627
+ log16.error("unhandledRejection", e);
6811
7628
  });
6812
7629
  process.on("uncaughtException", (e) => {
6813
7630
  if (isCancelTeardown(e)) {
6814
- log12.debug("suppressed uncaughtException (cursor stream cancel)", e);
7631
+ log16.debug("suppressed uncaughtException (cursor stream cancel)", e);
6815
7632
  return;
6816
7633
  }
6817
7634
  console.error(e);
@@ -6820,11 +7637,15 @@ function installCancelGuards(mounted) {
6820
7637
  });
6821
7638
  }
6822
7639
  async function repl(args, ai, cfg, cwd) {
6823
- const oauth = new McpOAuth({ storePath: join8(cwd, ".agent", "mcp-auth.json") });
7640
+ const oauth = new McpOAuth({ storePath: join9(cwd, ".agent", "mcp-auth.json") });
6824
7641
  const mounted = await mountMcp(cfg, oauth);
6825
7642
  const agent = await makeAgent(args, ai, cfg, mounted.flatMap((m) => m.tools));
7643
+ if (args.voice && !args.duplex) agent.options.tools = [...agent.options.tools ?? [], exitSessionTool(() => {
7644
+ exitRequested = true;
7645
+ })];
6826
7646
  const duplex = args.duplex;
6827
7647
  let dx;
7648
+ let voiceIO;
6828
7649
  let editorRef;
6829
7650
  let workerOptions;
6830
7651
  let duplexPersist = () => {
@@ -6846,17 +7667,23 @@ async function repl(args, ai, cfg, cwd) {
6846
7667
  const host = {
6847
7668
  ...base,
6848
7669
  notify(e) {
7670
+ if (e.kind === "text_delta" && voiceIO) {
7671
+ voiceIO.speakDelta(e.message);
7672
+ editorRef?.suspend();
7673
+ }
6849
7674
  if (e.kind === "revoice_done") {
6850
7675
  base.flushText();
6851
7676
  process.stdout.write("\n");
7677
+ voiceIO?.endSpeech();
6852
7678
  duplexPersist();
7679
+ editorRef?.resume();
6853
7680
  editorRef?.redrawNow();
6854
7681
  return;
6855
7682
  }
6856
7683
  if (e.kind === "task_done" && e.data?.text) {
6857
7684
  const lines = String(e.data.text).split("\n");
6858
7685
  const shown = lines.slice(0, previewLines());
6859
- err("\r\x1B[2K\n" + dim(` \u29BF ${e.message}
7686
+ err("\r\x1B[0J\n" + dim(` \u29BF ${e.message}
6860
7687
  `) + shown.map((l) => dim(` ${l}
6861
7688
  `)).join(""));
6862
7689
  if (lines.length > shown.length) err(dim(` \u2026 (+${lines.length - shown.length} more lines)
@@ -6884,19 +7711,45 @@ async function repl(args, ai, cfg, cwd) {
6884
7711
  dx = new DuplexAgent({
6885
7712
  ai,
6886
7713
  fs: agent.options.fs,
6887
- ...args.voiceModel ? { voiceModel: resolveModelOrNewest(args.voiceModel) } : {},
7714
+ ...args.voiceModel ?? cfg.voiceModel ? { voiceModel: resolveModelOrNewest(args.voiceModel ?? cfg.voiceModel) } : {},
6888
7715
  workerModel: agent.options.model,
6889
7716
  workerOptions,
6890
7717
  host,
6891
- ...args.voice ? { voiceStyle: "conversational" } : {},
7718
+ ...args.voice ? { voiceStyle: "conversational", progressUpdates: true } : {},
7719
+ // voice: narrate throttled worker progress (dead air is worse than a short aside)
6892
7720
  // Per-TASK checkpoint frames (the natural undo unit in duplex = one delegation): opened BEFORE
6893
7721
  // the worker spawns (post-spawn would race its first edits). `checkpoints` is bound below.
6894
7722
  onTaskStart: async (_id, label) => {
6895
7723
  await checkpoints.begin(label);
6896
7724
  },
7725
+ // The jail deny-lists .git/** (VCS internals can carry credentials), so the engine's fs-based
7726
+ // 'branch' lookup can't see it — supply it host-side (one safe read-only file).
7727
+ quickLook: {
7728
+ branch: () => {
7729
+ try {
7730
+ const head = readFileSync5(join9(cwd, ".git", "HEAD"), "utf8").trim();
7731
+ return head.startsWith("ref: refs/heads/") ? `branch: ${head.slice("ref: refs/heads/".length)}` : `detached HEAD at ${head.slice(0, 12)}`;
7732
+ } catch {
7733
+ return "not a git repository";
7734
+ }
7735
+ },
7736
+ // Memory READS are QuickLook material (instant, capped); memory WRITES stay delegated —
7737
+ // a worker creates/updates the files under .agent/memory/.
7738
+ memory: async () => {
7739
+ const dir = agent.options.memoryDir || adot("memory");
7740
+ try {
7741
+ const idx = await fs.readFile(`${dir}/MEMORY.md`);
7742
+ return idx.slice(0, 2e3) || "(memory index is empty)";
7743
+ } catch {
7744
+ return "no memory yet \u2014 to save something, Delegate it (a worker writes .agent/memory/)";
7745
+ }
7746
+ }
7747
+ },
6897
7748
  // The voice runs on the REAL fs (it has no fs tools — harmless) so @mentions, !cmd and #note
6898
7749
  // resolve against the project; + CC-parity chrome for its own tool calls (⚙ Delegate …).
6899
- voiceOptions: { fs: agent.options.fs, hooks: displayHooks(agent.options.fs), tools: [rewindFilesTool] }
7750
+ voiceOptions: { fs: agent.options.fs, hooks: displayHooks(agent.options.fs), tools: [rewindFilesTool, exitSessionTool(() => {
7751
+ exitRequested = true;
7752
+ })] }
6900
7753
  });
6901
7754
  }
6902
7755
  const face = dx ? dx.voice : agent;
@@ -6952,9 +7805,9 @@ async function repl(args, ai, cfg, cwd) {
6952
7805
  };
6953
7806
  const pendingImages = [];
6954
7807
  const grabClipboardAttachment = () => {
6955
- const dir = join8(tmpdir(), "agentx-pasted");
7808
+ const dir = join9(tmpdir(), "agentx-pasted");
6956
7809
  try {
6957
- mkdirSync6(dir, { recursive: true });
7810
+ mkdirSync7(dir, { recursive: true });
6958
7811
  } catch {
6959
7812
  }
6960
7813
  const img = grabClipboardImage(dir, String(Date.now()));
@@ -6963,15 +7816,17 @@ async function repl(args, ai, cfg, cwd) {
6963
7816
  process.on("SIGINT", () => {
6964
7817
  if (activeTurn) {
6965
7818
  activeTurn.abort();
7819
+ voiceIO?.interrupt();
6966
7820
  return;
6967
7821
  }
7822
+ voiceIO?.stop();
6968
7823
  void closeMcp(mounted);
6969
7824
  process.exit(130);
6970
7825
  });
6971
7826
  installCancelGuards(mounted);
6972
7827
  const store = new SessionStore(cwd);
6973
7828
  let session = startSession(args, store, face, cwd);
6974
- const checkpoints = args.vfs || args.boddb ? new CheckpointStack(agent.options.fs) : new GitCheckpoints({ workTree: cwd, gitDir: join8(cwd, ".agent", "checkpoints.git"), addDirs: args.addDirs, sessionId: session.meta.id });
7829
+ const checkpoints = args.vfs || args.boddb ? new CheckpointStack(agent.options.fs) : new GitCheckpoints({ workTree: cwd, gitDir: join9(cwd, ".agent", "checkpoints.git"), addDirs: args.addDirs, sessionId: session.meta.id });
6975
7830
  const cpHooks = checkpoints.hooks?.();
6976
7831
  if (cpHooks) work.hooks = composeHooks(work.hooks, cpHooks);
6977
7832
  duplexPersist = () => {
@@ -6998,17 +7853,17 @@ async function repl(args, ai, cfg, cwd) {
6998
7853
  const fs = agent.options.fs;
6999
7854
  const fsBase = fs.getCwd() === "/" ? "" : fs.getCwd();
7000
7855
  const adot = (sub) => `${fsBase}/.agent/${sub}`;
7001
- const adots = (sub) => [adot(sub), `${fsBase}/.claude/${sub}`, `${homedir4()}/.agent/${sub}`, `${homedir4()}/.claude/${sub}`];
7856
+ const adots = (sub) => [adot(sub), `${fsBase}/.claude/${sub}`, `${homedir5()}/.agent/${sub}`, `${homedir5()}/.claude/${sub}`];
7002
7857
  const cmds = (await loadCommands(fs, adots("commands"))).commands;
7003
7858
  const skills = (await loadSkills(fs, adots("skills"))).skills;
7004
- const histPath = join8(cwd, ".agent", "history");
7005
- const history = existsSync7(histPath) ? readFileSync5(histPath, "utf8").split("\n").filter(Boolean).reverse().slice(0, 500) : [];
7859
+ const histPath = join9(cwd, ".agent", "history");
7860
+ const history = existsSync8(histPath) ? readFileSync5(histPath, "utf8").split("\n").filter(Boolean).reverse().slice(0, 500) : [];
7006
7861
  const remember = (line) => {
7007
7862
  try {
7008
- mkdirSync6(join8(cwd, ".agent"), { recursive: true });
7863
+ mkdirSync7(join9(cwd, ".agent"), { recursive: true });
7009
7864
  appendFileSync(histPath, line + "\n");
7010
7865
  } catch (e) {
7011
- log12.debug("history write failed", e);
7866
+ log16.debug("history write failed", e);
7012
7867
  }
7013
7868
  };
7014
7869
  const ago = (t) => {
@@ -7066,7 +7921,7 @@ async function repl(args, ai, cfg, cwd) {
7066
7921
  try {
7067
7922
  store.save(session);
7068
7923
  } catch (e) {
7069
- log12.debug("session save after rewind failed", e);
7924
+ log16.debug("session save after rewind failed", e);
7070
7925
  }
7071
7926
  err(green(" \u27F2 jumped back") + dim(` \u2014 ${face.transcript.length} message(s) kept; edit + resend
7072
7927
  `));
@@ -7090,10 +7945,15 @@ async function repl(args, ai, cfg, cwd) {
7090
7945
  const announcedTasks = /* @__PURE__ */ new Set();
7091
7946
  const turn = async (task) => {
7092
7947
  const r = await runTurn(face, store, session, task, duplex ? void 0 : checkpoints, cwd, sendVia);
7948
+ if (voiceIO) {
7949
+ process.stdout.write("\n");
7950
+ editorRef?.resume();
7951
+ }
7952
+ voiceIO?.endSpeech();
7093
7953
  if (dx) {
7094
7954
  const fresh = [...dx.tasks.values()].filter((t) => t.status === "running" && !announcedTasks.has(t.id));
7095
7955
  fresh.forEach((t) => announcedTasks.add(t.id));
7096
- if (fresh.length) err(cyan(` \u25D4 ${fresh.length === 1 ? `task ${fresh[0].id} (${fresh[0].label})` : `${fresh.length} tasks`} working in the background`) + dim(" \u2014 the result will appear here; keep chatting meanwhile\n"));
7956
+ if (fresh.length) err("\r\x1B[0J" + cyan(` \u25D4 ${fresh.length === 1 ? `task ${fresh[0].id} (${fresh[0].label})` : `${fresh.length} tasks`} working in the background`) + dim(" \u2014 the result will appear here; keep chatting meanwhile\n"));
7097
7957
  }
7098
7958
  return r;
7099
7959
  };
@@ -7261,6 +8121,26 @@ ${extra}` : body);
7261
8121
  else err(dim(" " + (duplex ? `voice ${dx.options.voiceModel} \xB7 worker ${work.model}` : work.model) + "\n"));
7262
8122
  }
7263
8123
  },
8124
+ ...duplex ? { "voice-model": {
8125
+ desc: "switch the duplex voice (fast) model \u2014 /voice-model <id>, or alone for a picker",
8126
+ run: async (a) => {
8127
+ const apply = (id) => {
8128
+ const m = resolveModelOrNewest(id);
8129
+ dx.options.voiceModel = m;
8130
+ dx.voice.options.model = m;
8131
+ err(green(` \u2713 voice model \u2192 ${m}
8132
+ `));
8133
+ };
8134
+ if (a[0]) {
8135
+ apply(a[0]);
8136
+ return;
8137
+ }
8138
+ const picked = await pickModel(dx.options.voiceModel);
8139
+ if (picked) apply(picked);
8140
+ else err(dim(` voice ${dx.options.voiceModel}
8141
+ `));
8142
+ }
8143
+ } } : {},
7264
8144
  reasoning: {
7265
8145
  desc: "extended thinking \u2014 /reasoning <off|low|medium|high|tokens>, or alone for an interactive picker (duplex: the workers')",
7266
8146
  run: async (a) => {
@@ -7489,7 +8369,7 @@ ${extra}` : body);
7489
8369
  }
7490
8370
  const m = mounted.splice(idx, 1)[0];
7491
8371
  removeWorkTools(m.tools.map((t) => t.name));
7492
- await m.client.close().catch((e) => log12.debug("mcp close failed", e));
8372
+ await m.client.close().catch((e) => log16.debug("mcp close failed", e));
7493
8373
  err(dim(` removed "${name}"
7494
8374
  `));
7495
8375
  return;
@@ -7604,10 +8484,10 @@ ${extra}` : body);
7604
8484
  return;
7605
8485
  }
7606
8486
  const md = exportMarkdown(session.meta, shown);
7607
- const name = a[0] ? extname(a[0]) ? a[0] : a[0] + ".md" : join8(".agent", "exports", `${session.meta.id}.md`);
8487
+ const name = a[0] ? extname(a[0]) ? a[0] : a[0] + ".md" : join9(".agent", "exports", `${session.meta.id}.md`);
7608
8488
  const path = resolve3(cwd, name);
7609
8489
  try {
7610
- mkdirSync6(dirname3(path), { recursive: true });
8490
+ mkdirSync7(dirname4(path), { recursive: true });
7611
8491
  writeFileSync6(path, md);
7612
8492
  err(green(` \u2713 exported \u2192 ${path}
7613
8493
  `) + dim(` ${shown.length} message(s) \xB7 ${md.length} chars
@@ -7628,9 +8508,9 @@ ${extra}` : body);
7628
8508
  `));
7629
8509
  const listDir = (absDir) => {
7630
8510
  try {
7631
- return readdirSync2(join8(cwd, absDir.replace(/^\/+/, "")), { withFileTypes: true }).map((d) => ({ name: d.name, dir: d.isDirectory() }));
8511
+ return readdirSync2(join9(cwd, absDir.replace(/^\/+/, "")), { withFileTypes: true }).map((d) => ({ name: d.name, dir: d.isDirectory() }));
7632
8512
  } catch (e) {
7633
- log12.debug("completion readdir failed", absDir, e);
8513
+ log16.debug("completion readdir failed", absDir, e);
7634
8514
  return null;
7635
8515
  }
7636
8516
  };
@@ -7645,21 +8525,57 @@ ${extra}` : body);
7645
8525
  let aborting = false;
7646
8526
  let pendingRewind = false;
7647
8527
  if (process.stdin.isTTY) {
8528
+ const renderStashBuf = () => {
8529
+ if (!stashBuf) return;
8530
+ const q2 = inputStash.length ? dim(` [${inputStash.length} queued]`) : "";
8531
+ err(`\r\x1B[K${dim(" stash \u203A ")}${stashBuf}${q2}`);
8532
+ };
7648
8533
  process.stdin.on("keypress", (_s, key) => {
7649
8534
  if (!activeTurn) return;
7650
8535
  if (key?.ctrl && key?.name === "o") {
7651
8536
  toggleVerbose();
7652
8537
  return;
7653
8538
  }
7654
- const cancel = key?.name === "escape" || key?.ctrl && key?.name === "c";
7655
- if (!cancel) return;
7656
- if (!aborting) {
7657
- aborting = true;
7658
- activeTurn.abort();
7659
- err(yellow("\n \u238B cancelling\u2026\n"));
7660
- } else if (key?.name === "escape" && !pendingRewind) {
7661
- pendingRewind = true;
7662
- err(dim(" \u238B\u238B jumping back to edit\u2026\n"));
8539
+ const k = key?.name;
8540
+ const cancel = k === "escape" || key?.ctrl && k === "c";
8541
+ if (cancel) {
8542
+ if (stashBuf) {
8543
+ stashBuf = "";
8544
+ err("\r\x1B[K");
8545
+ return;
8546
+ }
8547
+ if (!aborting) {
8548
+ aborting = true;
8549
+ activeTurn.abort();
8550
+ voiceIO?.interrupt();
8551
+ err(yellow("\n \u238B cancelling\u2026\n"));
8552
+ } else if (k === "escape" && !pendingRewind) {
8553
+ pendingRewind = true;
8554
+ err(dim(" \u238B\u238B jumping back to edit\u2026\n"));
8555
+ }
8556
+ return;
8557
+ }
8558
+ if (k === "return" || k === "enter") {
8559
+ if (stashBuf.trim()) {
8560
+ inputStash.push(stashBuf.trim());
8561
+ err(`\r\x1B[K${green(" \u2713 stashed")} ${dim(`#${inputStash.length}: ${stashBuf.trim().slice(0, 50)}${stashBuf.trim().length > 50 ? "\u2026" : ""}`)}
8562
+ `);
8563
+ }
8564
+ stashBuf = "";
8565
+ return;
8566
+ }
8567
+ if (k === "backspace") {
8568
+ if (stashBuf.length) {
8569
+ stashBuf = stashBuf.slice(0, -1);
8570
+ if (stashBuf) renderStashBuf();
8571
+ else err("\r\x1B[K");
8572
+ }
8573
+ return;
8574
+ }
8575
+ if (!key?.ctrl && !key?.meta && isPrintable(_s)) {
8576
+ stashBuf += _s;
8577
+ renderStashBuf();
8578
+ return;
7663
8579
  }
7664
8580
  });
7665
8581
  }
@@ -7677,6 +8593,120 @@ ${extra}` : body);
7677
8593
  };
7678
8594
  let prefill;
7679
8595
  let tick = 0;
8596
+ const dispatchLine = async (line) => {
8597
+ history.unshift(line.replace(/\n+/g, " \u23CE "));
8598
+ remember(line.replace(/\n+/g, " \u23CE "));
8599
+ if (line.startsWith("!")) {
8600
+ const cmd = line.slice(1).trim();
8601
+ if (cmd) {
8602
+ err(dim(await runShellLine(agent.options.fs, cmd) + "\n"));
8603
+ }
8604
+ return;
8605
+ }
8606
+ if (line.startsWith("#")) {
8607
+ const note = line.slice(1).trim();
8608
+ if (note) {
8609
+ const where = await appendMemoryNote(agent.options.fs, agent.options.memoryDir || adot("memory"), note);
8610
+ err(green(` \u270E remembered \u2192 ${where}
8611
+ `));
8612
+ }
8613
+ return;
8614
+ }
8615
+ if (line.startsWith("/")) {
8616
+ const [name, ...a] = line.slice(1).split(/\s+/);
8617
+ if (!name) {
8618
+ err(red(" / needs a command name\n") + dim(" (try /help)\n"));
8619
+ return;
8620
+ }
8621
+ const b = builtins[name];
8622
+ if (b) {
8623
+ if (await b.run(a)) return "quit";
8624
+ return;
8625
+ }
8626
+ const c = cmds.find((x) => x.name === name);
8627
+ if (c) {
8628
+ await runCommand(c, a.join(" "));
8629
+ return;
8630
+ }
8631
+ const sk = skills.find((x) => x.name === name);
8632
+ if (sk) {
8633
+ await runSkill(sk, a.join(" "));
8634
+ return;
8635
+ }
8636
+ const known = Object.keys(builtins).filter((k) => k !== "quit").map((k) => "/" + k);
8637
+ const custom = [...cmds.map((x) => x.name), ...skills.map((x) => x.name)].map((n) => "/" + n);
8638
+ err(red(` unknown command /${name}
8639
+ `) + dim(" builtins: " + known.join(" ") + "\n") + (custom.length ? dim(" custom: " + custom.join(" ") + "\n") : "") + dim(" (or /help)\n"));
8640
+ return;
8641
+ }
8642
+ const task = pendingImages.length ? `${line} ${pendingImages.map((p) => "@" + p).join(" ")}` : line;
8643
+ pendingImages.length = 0;
8644
+ await turn(task);
8645
+ if (exitRequested) return "quit";
8646
+ };
8647
+ let voicePartial = "";
8648
+ let partialRedraw = null;
8649
+ if (args.voice && duplex && process.stdin.isTTY) {
8650
+ if (!VoiceIO.available()) {
8651
+ err(dim(" (voice I/O off \u2014 set SONIOX_API_KEY, CARTESIA_API_KEY, CARTESIA_VOICE_ID to talk)\n"));
8652
+ } else {
8653
+ voiceIO = new VoiceIO({
8654
+ // No ack phrase by default: a fixed "Mm-hm," every turn reads robotic, Haiku's TTFT doesn't
8655
+ // need masking (~0.7-1.2s full turns), and the conversational register already opens with a
8656
+ // natural reaction. The mechanism (+ echo-leak guard) stays for slower voice models.
8657
+ onState: () => editorRef?.redrawNow(),
8658
+ // Throttled: each redraw clears the screen below the prompt — a partial-per-token storm
8659
+ // (fast speech, or echo bleed if AEC degrades) would continuously erase streamed text.
8660
+ onPartial: (text) => {
8661
+ if (text === voicePartial) return;
8662
+ voicePartial = text;
8663
+ if (!partialRedraw) partialRedraw = setTimeout(() => {
8664
+ partialRedraw = null;
8665
+ editorRef?.redrawNow();
8666
+ }, 250);
8667
+ },
8668
+ onBargeIn: (phase) => {
8669
+ activeTurn?.abort();
8670
+ if (phase === "speaking") err(yellow("\n \u270B interrupted\n"));
8671
+ },
8672
+ onUtterance: (text) => {
8673
+ voicePartial = "";
8674
+ if (!text.trim()) return;
8675
+ const cut = voiceIO.takeInterruptedReply();
8676
+ const note = cut && cut.full.length - cut.heard.length > 40 ? `
8677
+ [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.]` : "";
8678
+ if (!/^[!#/]/.test(text.trim())) voiceIO.beginSpeech(true);
8679
+ err(`\r\x1B[K ${bold(cyan("\u{1F3A4} \u203A"))} ${text}
8680
+ `);
8681
+ void dispatchLine(text + note).then(async (r) => {
8682
+ if (r === "quit") {
8683
+ await voiceIO?.awaitIdle();
8684
+ editorRef?.abort();
8685
+ }
8686
+ }).finally(() => editorRef?.redrawNow());
8687
+ }
8688
+ });
8689
+ try {
8690
+ await voiceIO.start();
8691
+ process.on("exit", () => voiceIO?.stop());
8692
+ for (const sig of ["SIGHUP", "SIGTERM"]) process.on(sig, () => {
8693
+ voiceIO?.stop();
8694
+ process.exit(0);
8695
+ });
8696
+ err(dim(` \u{1F3A4} voice on (${voiceIO.usingAec ? "echo-cancelled" : "heuristic echo \u2014 headphones recommended"}) \u2014 just talk; speak over it to interrupt
8697
+ `));
8698
+ const where = cwd.split("/").pop();
8699
+ const resumed = session.messages.length > 0;
8700
+ void turn(
8701
+ `[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.`
8702
+ ).finally(() => editorRef?.redrawNow());
8703
+ } catch (e) {
8704
+ err(yellow(` \u26A0 voice I/O failed to start: ${e?.message ?? e} \u2014 continuing text-only
8705
+ `));
8706
+ voiceIO = void 0;
8707
+ }
8708
+ }
8709
+ }
7680
8710
  while (true) {
7681
8711
  if (pendingRewind) {
7682
8712
  pendingRewind = false;
@@ -7684,6 +8714,7 @@ ${extra}` : body);
7684
8714
  if (t !== void 0) prefill = t;
7685
8715
  }
7686
8716
  aborting = false;
8717
+ stashBuf = "";
7687
8718
  err("\n");
7688
8719
  const initial = prefill;
7689
8720
  prefill = void 0;
@@ -7692,12 +8723,17 @@ ${extra}` : body);
7692
8723
  const usd = session.meta.costUsd ?? 0;
7693
8724
  const computeFooter = () => {
7694
8725
  const parts = [];
8726
+ if (voiceIO) {
8727
+ const glyph = { listening: "\u{1F3A4}", thinking: "\u{1F4AD}", speaking: "\u{1F50A}", idle: "\xB7" }[voiceIO.state];
8728
+ parts.push(voicePartial && voiceIO.state === "listening" ? `\u{1F3A4} ${voicePartial.slice(-60)}` : `${glyph} ${voiceIO.state}`);
8729
+ }
7695
8730
  if (ctxTok > 400) parts.push(`${Math.round(ctxTok / ctxCap * 100)}% ctx (~${(ctxTok / 1e3).toFixed(1)}k/${Math.round(ctxCap / 1e3)}k)`);
7696
8731
  if (usd > 0) parts.push(`${session.meta.costEstimated ? "~" : ""}${fmtUsd(usd)}`);
7697
8732
  if (posture !== "default") parts.push(postureLabel());
7698
8733
  const r = work.reasoning;
7699
8734
  if (r && r !== "off") parts.push(`reasoning:${r}`);
7700
8735
  if (verboseOutput) parts.push("verbose");
8736
+ if (inputStash.length) parts.push(`${inputStash.length} stashed (\u2303S to pop)`);
7701
8737
  const taskLines = [];
7702
8738
  if (dx) {
7703
8739
  const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
@@ -7724,6 +8760,18 @@ ${extra}` : body);
7724
8760
  const picked = await pickModel(work.model);
7725
8761
  if (picked) setModel(picked);
7726
8762
  return picked;
8763
+ },
8764
+ onStash: (text) => {
8765
+ inputStash.push(text);
8766
+ err(`${green(" \u2713 stashed")} ${dim(`#${inputStash.length}: ${text.slice(0, 50)}${text.length > 50 ? "\u2026" : ""}`)}
8767
+ `);
8768
+ },
8769
+ onUnstash: () => {
8770
+ if (!inputStash.length) return void 0;
8771
+ const t = inputStash.pop();
8772
+ err(dim(` \u2191 unstashed${inputStash.length ? ` (${inputStash.length} left)` : ""}
8773
+ `));
8774
+ return t;
7727
8775
  }
7728
8776
  }));
7729
8777
  if (result === null) break;
@@ -7733,59 +8781,27 @@ ${extra}` : body);
7733
8781
  }
7734
8782
  const line = result.trim();
7735
8783
  if (!line) continue;
7736
- history.unshift(line.replace(/\n+/g, " \u23CE "));
7737
- remember(line.replace(/\n+/g, " \u23CE "));
7738
- if (line.startsWith("!")) {
7739
- const cmd = line.slice(1).trim();
7740
- if (cmd) {
7741
- err(dim(await runShellLine(agent.options.fs, cmd) + "\n"));
7742
- }
7743
- continue;
7744
- }
7745
- if (line.startsWith("#")) {
7746
- const note = line.slice(1).trim();
7747
- if (note) {
7748
- const where = await appendMemoryNote(agent.options.fs, agent.options.memoryDir || adot("memory"), note);
7749
- err(green(` \u270E remembered \u2192 ${where}
8784
+ let quit = await dispatchLine(line) === "quit";
8785
+ while (!quit && inputStash.length) {
8786
+ const next = inputStash.shift();
8787
+ err(dim(` \u23CE stashed \u203A ${next.slice(0, 60)}${next.length > 60 ? "\u2026" : ""}
7750
8788
  `));
7751
- }
7752
- continue;
7753
- }
7754
- if (line.startsWith("/")) {
7755
- const [name, ...a] = line.slice(1).split(/\s+/);
7756
- if (!name) {
7757
- err(red(" / needs a command name\n") + dim(" (try /help)\n"));
7758
- continue;
7759
- }
7760
- const b = builtins[name];
7761
- if (b) {
7762
- if (await b.run(a)) break;
7763
- continue;
7764
- }
7765
- const c = cmds.find((x) => x.name === name);
7766
- if (c) {
7767
- await runCommand(c, a.join(" "));
7768
- continue;
7769
- }
7770
- const sk = skills.find((x) => x.name === name);
7771
- if (sk) {
7772
- await runSkill(sk, a.join(" "));
7773
- continue;
7774
- }
7775
- const known = Object.keys(builtins).filter((k) => k !== "quit").map((k) => "/" + k);
7776
- const custom = [...cmds.map((x) => x.name), ...skills.map((x) => x.name)].map((n) => "/" + n);
7777
- err(red(` unknown command /${name}
7778
- `) + dim(" builtins: " + known.join(" ") + "\n") + (custom.length ? dim(" custom: " + custom.join(" ") + "\n") : "") + dim(" (or /help)\n"));
7779
- continue;
8789
+ quit = await dispatchLine(next) === "quit";
7780
8790
  }
7781
- const task = pendingImages.length ? `${line} ${pendingImages.map((p) => "@" + p).join(" ")}` : line;
7782
- pendingImages.length = 0;
7783
- await turn(task);
8791
+ if (quit) break;
7784
8792
  }
8793
+ voiceIO?.stop();
7785
8794
  if (dx) {
7786
- const running = [...dx.tasks.values()].filter((t) => t.status === "running").length;
7787
- if (running) {
7788
- err(dim(` \u2026 waiting for ${running} background task(s) (Ctrl-C to force quit)
8795
+ const running = [...dx.tasks.values()].filter((t) => t.status === "running");
8796
+ if (exitRequested && running.length) {
8797
+ for (const t of running) {
8798
+ t.status = "cancelled";
8799
+ t.controller.abort();
8800
+ }
8801
+ err(dim(` \u2026 cancelled ${running.length} background task(s)
8802
+ `));
8803
+ } else if (running.length) {
8804
+ err(dim(` \u2026 waiting for ${running.length} background task(s) (Ctrl-C to force quit)
7789
8805
  `));
7790
8806
  await dx.idle();
7791
8807
  face.options.host?.flushText?.();
@@ -7861,7 +8877,7 @@ async function main() {
7861
8877
  }
7862
8878
  });
7863
8879
  if (args.task) {
7864
- const mounted = await mountMcp(cfg, new McpOAuth({ storePath: join8(cwd, ".agent", "mcp-auth.json") }));
8880
+ const mounted = await mountMcp(cfg, new McpOAuth({ storePath: join9(cwd, ".agent", "mcp-auth.json") }));
7865
8881
  const agent = await makeAgent(args, ai, cfg, mounted.flatMap((m) => m.tools));
7866
8882
  const store = new SessionStore(cwd);
7867
8883
  const session = startSession(args, store, agent, cwd);