agent.libx.js 0.89.5 → 0.89.7

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
@@ -1397,7 +1397,7 @@ function makeRealShellTool(options) {
1397
1397
  proc.stderr?.on("data", collect);
1398
1398
  proc.on("error", (err2) => {
1399
1399
  if (err2?.name === "AbortError" || ctl.signal.aborted) return finish(reasonFor(timedOut, timeoutMs, clean(out)));
1400
- log7.debug("shell spawn error", err2);
1400
+ log8.debug("shell spawn error", err2);
1401
1401
  finish(`[exit 1] ${err2?.message ?? err2}${out ? "\n" + clean(out) : ""}`);
1402
1402
  });
1403
1403
  proc.on("close", (code) => {
@@ -1457,14 +1457,14 @@ ${clean(out) || "(no output yet)"}`;
1457
1457
  }
1458
1458
  ];
1459
1459
  }
1460
- var log7, clean, SECRET_ENV_RE, _spawn, ShellJobRegistry, NO_JOB2;
1460
+ var log8, clean, SECRET_ENV_RE, _spawn, ShellJobRegistry, NO_JOB2;
1461
1461
  var init_tools_shell = __esm({
1462
1462
  "src/tools.shell.ts"() {
1463
1463
  "use strict";
1464
1464
  init_tools();
1465
1465
  init_redact();
1466
1466
  init_logging();
1467
- log7 = forComponent("shell");
1467
+ log8 = forComponent("shell");
1468
1468
  clean = (s) => truncateOutput(redactSecrets(s.replace(/\n+$/, "")));
1469
1469
  SECRET_ENV_RE = /(_API_KEY|_TOKEN|_SECRET|_PASSWORD|_PRIVATE_KEY|^AWS_|^GITHUB_TOKEN$|^OPENAI_|^ANTHROPIC_|^GOOGLE_|^GEMINI_|^GROQ_|^NPM_TOKEN$)/i;
1470
1470
  ShellJobRegistry = class {
@@ -3348,6 +3348,202 @@ function digestRun(messages, maxChars) {
3348
3348
  return out.length > maxChars ? out.slice(0, maxChars) + "\n\u2026 (truncated)" : out;
3349
3349
  }
3350
3350
 
3351
+ // src/duplex.ts
3352
+ import { MemFilesystem as MemFilesystem2 } from "@livx.cc/wcli/core";
3353
+ init_logging();
3354
+ var log6 = forComponent("DuplexAgent");
3355
+ var DuplexAgentOptions = class {
3356
+ /** Any ai.libx.js AIClient — shared by the voice and worker agents (routed by model). */
3357
+ ai;
3358
+ /** The WORKER's filesystem. If omitted the worker keeps Agent's jailed-disk-at-cwd default. */
3359
+ fs;
3360
+ voiceModel = "anthropic/claude-haiku-4-5";
3361
+ workerModel = "anthropic/claude-sonnet-4-6";
3362
+ /** Escape hatches merged over the derived per-agent options. */
3363
+ voiceOptions;
3364
+ workerOptions;
3365
+ /** Receives the voice text_delta stream + task lifecycle events. */
3366
+ host;
3367
+ /** How many recent transcript messages are rendered into a worker's brief. */
3368
+ excerptTurns = 6;
3369
+ /** Voice register: 'neutral' = clean spoken style; 'conversational' = human-like — fillers,
3370
+ * backchannels, impulsive first reactions before content (mimics real duplex conversation). */
3371
+ voiceStyle = "neutral";
3372
+ /** Awaited BEFORE a delegated worker spawns — open a per-task checkpoint frame, audit, etc.
3373
+ * (post-spawn would race the worker's first edits). */
3374
+ onTaskStart;
3375
+ };
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.';
3377
+ 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
+ var DuplexAgent = class {
3379
+ options;
3380
+ voice;
3381
+ tasks = /* @__PURE__ */ new Map();
3382
+ queue = Promise.resolve();
3383
+ seq = 0;
3384
+ pendingEvents = [];
3385
+ flushQueued = false;
3386
+ constructor(options) {
3387
+ this.options = { ...new DuplexAgentOptions(), ...options };
3388
+ const o = this.options;
3389
+ this.voice = new Agent({
3390
+ ai: o.ai,
3391
+ fs: new MemFilesystem2(),
3392
+ // scratch — NOT Agent's jailed-disk default (voice has no fs tools; edge-safe)
3393
+ model: o.voiceModel,
3394
+ stream: true,
3395
+ host: o.host,
3396
+ systemPrompt: VOICE_SYSTEM_PROMPT + (o.voiceStyle === "conversational" ? "\n" + VOICE_STYLE_CONVERSATIONAL : ""),
3397
+ instructionFiles: false,
3398
+ maxSteps: 8,
3399
+ // a voice turn should never loop
3400
+ timeoutMs: 3e4,
3401
+ ...o.voiceOptions,
3402
+ // no defaultTools() — the voice can only Delegate, never touch files itself. Set AFTER the
3403
+ // voiceOptions spread (addTools() would be clobbered by the first prepare()); extra voice
3404
+ // tools come in via voiceOptions.tools and are merged here.
3405
+ tools: [...o.voiceOptions?.tools ?? [], this.delegateTool(), this.taskStatusTool(), this.cancelTaskTool()]
3406
+ });
3407
+ }
3408
+ /** One user turn: the voice agent streams the reply (and may Delegate). Serialized with re-voice turns. */
3409
+ send(content) {
3410
+ return this.enqueue(() => this.voice.send(content));
3411
+ }
3412
+ /** Resolve when all queued voice turns AND all in-flight worker tasks have settled (tests, graceful shutdown). */
3413
+ async idle() {
3414
+ while (true) {
3415
+ const q2 = this.queue;
3416
+ await q2.catch(() => {
3417
+ });
3418
+ await Promise.all([...this.tasks.values()].map((t) => t.promise));
3419
+ if (this.queue === q2 && ![...this.tasks.values()].some((t) => t.status === "running")) return;
3420
+ }
3421
+ }
3422
+ /** Promise-chain mutex: turns run strictly one at a time; a failed turn doesn't poison the chain. */
3423
+ enqueue(fn) {
3424
+ const run = this.queue.then(fn, fn);
3425
+ this.queue = run.then(() => {
3426
+ }, () => {
3427
+ });
3428
+ return run;
3429
+ }
3430
+ notify(kind, message, data) {
3431
+ this.options.host?.notify?.({ kind, message, data });
3432
+ }
3433
+ /** Queue a `[task …]` event for re-voicing. Events arriving while the voice is busy coalesce into ONE turn. */
3434
+ queueRevoice(event) {
3435
+ this.pendingEvents.push(event);
3436
+ if (this.flushQueued) return;
3437
+ this.flushQueued = true;
3438
+ void this.enqueue(async () => {
3439
+ this.flushQueued = false;
3440
+ const events = this.pendingEvents.splice(0);
3441
+ if (!events.length) return;
3442
+ await this.voice.send(events.join("\n"));
3443
+ this.notify("revoice_done", "");
3444
+ });
3445
+ }
3446
+ /** The worker's brief: the Delegate args + a STATIC text snapshot of the recent conversation. */
3447
+ buildBrief(brief) {
3448
+ const recent = this.voice.transcript.filter((m) => (m.role === "user" || m.role === "assistant") && contentText(m.content).trim()).slice(-this.options.excerptTurns).map((m) => `${m.role}: ${contentText(m.content)}`).join("\n");
3449
+ return recent ? `${brief}
3450
+
3451
+ ## Recent conversation (for context)
3452
+ ${recent}` : brief;
3453
+ }
3454
+ /** Spawn a detached worker for task `id`; its settlement notifies + enqueues the re-voice turn. */
3455
+ spawnWorker(id, label, briefText) {
3456
+ const o = this.options;
3457
+ const controller = new AbortController();
3458
+ const worker = new Agent({
3459
+ ai: o.ai,
3460
+ fs: o.fs,
3461
+ model: o.workerModel,
3462
+ ...o.workerOptions,
3463
+ // may override ai/fs/model/tools/… —
3464
+ signal: controller.signal
3465
+ // …but never the per-task cancellation signal
3466
+ });
3467
+ const promise = worker.run(briefText).then((res) => this.onWorkerSettled(id, res)).catch((err2) => this.onWorkerFailed(id, err2));
3468
+ this.tasks.set(id, { id, label, status: "running", controller, promise });
3469
+ }
3470
+ onWorkerSettled(id, res) {
3471
+ const rec = this.tasks.get(id);
3472
+ if (res.finishReason === "aborted" || rec.status === "cancelled") {
3473
+ rec.status = "cancelled";
3474
+ this.notify("task_cancelled", `task ${id} (${rec.label}) cancelled`);
3475
+ return;
3476
+ }
3477
+ if (res.finishReason === "error") {
3478
+ const msg = res.error instanceof Error ? res.error.message : String(res.error ?? "unknown error");
3479
+ return this.failTask(rec, msg);
3480
+ }
3481
+ rec.status = "done";
3482
+ log6.verbose(`task ${id} done (${res.steps} steps)`);
3483
+ this.notify("task_done", `task ${id} (${rec.label}) completed`, { id, text: res.text, usage: res.usage, usageEstimated: res.usageEstimated });
3484
+ this.queueRevoice(`[task ${id} completed] ${res.text}`);
3485
+ }
3486
+ onWorkerFailed(id, err2) {
3487
+ this.failTask(this.tasks.get(id), err2 instanceof Error ? err2.message : String(err2));
3488
+ }
3489
+ failTask(rec, msg) {
3490
+ rec.status = "error";
3491
+ log6.warn(`task ${rec.id} failed: ${msg}`);
3492
+ this.notify("task_error", `task ${rec.id} (${rec.label}) failed: ${msg}`);
3493
+ this.queueRevoice(`[task ${rec.id} failed] ${msg}`);
3494
+ }
3495
+ // --- the three voice tools (closures over this instance) ---
3496
+ delegateTool() {
3497
+ return {
3498
+ name: "Delegate",
3499
+ description: 'Escalate real work (reading/editing files, searching, running tasks, deep analysis) to a background worker agent. Returns immediately with a task id; the result arrives later as a "[task <id> completed]" event. Provide a clear, self-contained `brief` (the worker does not hear the live conversation).',
3500
+ parameters: {
3501
+ type: "object",
3502
+ required: ["brief"],
3503
+ properties: {
3504
+ brief: { type: "string", description: "full, self-contained instructions for the worker" },
3505
+ label: { type: "string", description: "a short (2-4 word) label for the task" }
3506
+ }
3507
+ },
3508
+ run: async ({ brief, label }) => {
3509
+ const id = `t${++this.seq}`;
3510
+ const lbl = String(label ?? "task");
3511
+ await this.options.onTaskStart?.(id, lbl);
3512
+ this.spawnWorker(id, lbl, this.buildBrief(String(brief ?? "")));
3513
+ this.notify("task_started", `task ${id} (${lbl}) started`, { id, brief });
3514
+ return `Delegated as task ${id}. Acknowledge briefly; the result will arrive as a [task ${id} completed] event.`;
3515
+ }
3516
+ };
3517
+ }
3518
+ taskStatusTool() {
3519
+ return {
3520
+ name: "TaskStatus",
3521
+ description: "Status of background tasks. Pass `id` for one task, or omit it to list all.",
3522
+ parameters: { type: "object", properties: { id: { type: "string" } } },
3523
+ run: async ({ id }) => {
3524
+ const list = id ? [this.tasks.get(String(id))].filter(Boolean) : [...this.tasks.values()];
3525
+ if (!list.length) return id ? `No task '${id}'.` : "No background tasks.";
3526
+ return list.map((t) => `${t.id} (${t.label}): ${t.status}`).join("\n");
3527
+ }
3528
+ };
3529
+ }
3530
+ cancelTaskTool() {
3531
+ return {
3532
+ name: "CancelTask",
3533
+ description: "Cancel a running background task by id.",
3534
+ parameters: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
3535
+ run: async ({ id }) => {
3536
+ const rec = this.tasks.get(String(id));
3537
+ if (!rec) return `No task '${id}'.`;
3538
+ if (rec.status !== "running") return `Task ${rec.id} is already ${rec.status}.`;
3539
+ rec.status = "cancelled";
3540
+ rec.controller.abort();
3541
+ return `Task ${rec.id} (${rec.label}) cancelled.`;
3542
+ }
3543
+ };
3544
+ }
3545
+ };
3546
+
3351
3547
  // src/mcp.ts
3352
3548
  function toText(result) {
3353
3549
  if (result == null) return "";
@@ -3375,12 +3571,12 @@ function mcpToolsToAgentTools(specs, callTool, prefix = "mcp__", filter) {
3375
3571
 
3376
3572
  // src/index.ts
3377
3573
  init_logging();
3378
- import { MemFilesystem as MemFilesystem2, IndexedDbFilesystem, CommandExecutor as CommandExecutor2, registerHeadlessCommands as registerHeadlessCommands2 } from "@livx.cc/wcli/core";
3574
+ import { MemFilesystem as MemFilesystem3, IndexedDbFilesystem, CommandExecutor as CommandExecutor2, registerHeadlessCommands as registerHeadlessCommands2 } from "@livx.cc/wcli/core";
3379
3575
 
3380
3576
  // src/mcp.client.ts
3381
3577
  init_logging();
3382
3578
  import { spawn } from "child_process";
3383
- var log6 = forComponent("mcp");
3579
+ var log7 = forComponent("mcp");
3384
3580
  var PROTOCOL_VERSION = "2025-06-18";
3385
3581
  var DEFAULT_TIMEOUT_MS = 3e4;
3386
3582
  var StdioTransport = class {
@@ -3399,7 +3595,7 @@ var StdioTransport = class {
3399
3595
  proc.stdout.setEncoding("utf8");
3400
3596
  proc.stdout.on("data", (chunk) => this.onData(chunk));
3401
3597
  proc.stderr.setEncoding("utf8");
3402
- proc.stderr.on("data", (chunk) => log6.debug(`[${command}] stderr:`, chunk.trimEnd()));
3598
+ proc.stderr.on("data", (chunk) => log7.debug(`[${command}] stderr:`, chunk.trimEnd()));
3403
3599
  proc.on("exit", (code) => this.failAll(new Error(`MCP server "${command}" exited (code ${code})`)));
3404
3600
  proc.on("error", (e) => this.failAll(e instanceof Error ? e : new Error(String(e))));
3405
3601
  }
@@ -3413,7 +3609,7 @@ var StdioTransport = class {
3413
3609
  try {
3414
3610
  this.dispatch(JSON.parse(line));
3415
3611
  } catch (e) {
3416
- log6.debug("dropping non-JSON line from MCP server:", line, e);
3612
+ log7.debug("dropping non-JSON line from MCP server:", line, e);
3417
3613
  }
3418
3614
  }
3419
3615
  }
@@ -3462,7 +3658,7 @@ var StdioTransport = class {
3462
3658
  try {
3463
3659
  this.proc?.stdin?.end();
3464
3660
  } catch (e) {
3465
- log6.debug("stdin end failed", e);
3661
+ log7.debug("stdin end failed", e);
3466
3662
  }
3467
3663
  this.proc?.kill();
3468
3664
  }
@@ -3531,7 +3727,7 @@ function parseSseResponse(body) {
3531
3727
  const obj = JSON.parse(trimmed.slice(5).trim());
3532
3728
  if (obj && (obj.result !== void 0 || obj.error !== void 0)) return obj;
3533
3729
  } catch (e) {
3534
- log6.debug("skipping unparseable SSE data line", e);
3730
+ log7.debug("skipping unparseable SSE data line", e);
3535
3731
  }
3536
3732
  }
3537
3733
  return {};
@@ -3585,16 +3781,16 @@ async function mountMcpServers(servers = {}) {
3585
3781
  for (const [name, cfg] of Object.entries(servers)) {
3586
3782
  if (!cfg || cfg.disabled) continue;
3587
3783
  if (!cfg.command && !cfg.url) {
3588
- log6.warn(`MCP server "${name}" needs a command (stdio) or url (http) \u2014 skipping`);
3784
+ log7.warn(`MCP server "${name}" needs a command (stdio) or url (http) \u2014 skipping`);
3589
3785
  continue;
3590
3786
  }
3591
3787
  try {
3592
3788
  const m = await mountMcpServer(name, cfg);
3593
3789
  out.push(m);
3594
- log6.info(`MCP "${name}" mounted \u2014 ${m.tools.length} tool(s)${m.serverInfo?.name ? ` from ${m.serverInfo.name}` : ""}`);
3790
+ log7.info(`MCP "${name}" mounted \u2014 ${m.tools.length} tool(s)${m.serverInfo?.name ? ` from ${m.serverInfo.name}` : ""}`);
3595
3791
  } catch (e) {
3596
- if (e instanceof McpAuthError) log6.warn(`MCP "${name}" needs-auth: HTTP ${e.status} \u2014 set bearerToken or headers in its config; skipping`);
3597
- else log6.error(`MCP server "${name}" failed to mount: ${e?.message ?? 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}`);
3598
3794
  }
3599
3795
  }
3600
3796
  return out;
@@ -3827,6 +4023,21 @@ async function hydrate(from, to, dir = "/") {
3827
4023
  }
3828
4024
  return n;
3829
4025
  }
4026
+ function toCursorMcp(servers) {
4027
+ if (!servers) return void 0;
4028
+ const out = {};
4029
+ for (const [name, s] of Object.entries(servers)) {
4030
+ if (!s || s.disabled) continue;
4031
+ if (s.command) {
4032
+ out[name] = { type: "stdio", command: s.command, ...s.args ? { args: s.args } : {}, ...s.env ? { env: s.env } : {}, ...s.cwd ? { cwd: s.cwd } : {} };
4033
+ } else if (s.url) {
4034
+ if (s.auth === "oauth" && !s.bearerToken) continue;
4035
+ const headers = { ...s.headers ?? {}, ...s.bearerToken ? { Authorization: `Bearer ${s.bearerToken}` } : {} };
4036
+ out[name] = { url: s.url, ...Object.keys(headers).length ? { headers } : {} };
4037
+ }
4038
+ }
4039
+ return Object.keys(out).length ? { mcpServers: out } : void 0;
4040
+ }
3830
4041
  async function buildAgent(o) {
3831
4042
  if (typeof o.sandbox !== "boolean")
3832
4043
  throw new Error(
@@ -3838,9 +4049,14 @@ async function buildAgent(o) {
3838
4049
  const jailedDisk = new JailedFilesystem(disk);
3839
4050
  jailedDisk.setCwd(cwd);
3840
4051
  const virtual = o.sandbox || !!o.boddb;
4052
+ const isCursor = (o.model ?? "").startsWith("cursor/");
4053
+ if (virtual && isCursor)
4054
+ throw new Error(
4055
+ "cursor/* models cannot run in --sandbox/--boddb: the Cursor agent runs its own real-disk tools and bypasses the VFS jail. Use disk mode (default)."
4056
+ );
3841
4057
  let fs = jailedDisk;
3842
4058
  if (o.sandbox) {
3843
- const mem = new MemFilesystem2();
4059
+ const mem = new MemFilesystem3();
3844
4060
  await mkdirp(mem, cwd);
3845
4061
  await hydrate(jailedDisk, mem, cwd);
3846
4062
  mem.setCwd(cwd);
@@ -3899,6 +4115,11 @@ Reference files in them by their mount path (the left side).`;
3899
4115
  ai: o.ai,
3900
4116
  fs,
3901
4117
  model: o.model ?? "anthropic/claude-sonnet-4-6",
4118
+ // Anchor cursor to the launch dir (its adapter defaults to TMPDIR otherwise) and forward the
4119
+ // host's MCP servers so the delegated cursor agent runs in the same environment. Gated to cursor:
4120
+ // openai/google adapters Object.assign providerOptions into the request body, so a blanket cwd
4121
+ // would corrupt those calls.
4122
+ ...isCursor ? { providerOptions: { cwd, ...toCursorMcp(o.mcpServers) ?? {} } } : {},
3902
4123
  ...(() => {
3903
4124
  const now = /* @__PURE__ */ new Date();
3904
4125
  const platformNames = { darwin: "macOS", linux: "Linux", win32: "Windows" };
@@ -3962,6 +4183,7 @@ function summarizeCall(name, args) {
3962
4183
  if (args.command) return String(args.command);
3963
4184
  if (args.pattern) return `/${args.pattern}/${args.glob ? " in " + args.glob : ""}`;
3964
4185
  if (args.prompt) return trunc(String(args.description ?? args.prompt), 50);
4186
+ if (args.brief) return trunc(String(args.label ? `${args.label}: ${args.brief}` : args.brief), 70);
3965
4187
  return trunc(JSON.stringify(args), 60);
3966
4188
  }
3967
4189
  var trunc = (s, n) => (s == null ? "" : String(s).length > n ? String(s).slice(0, n) + "\u2026" : String(s)).replace(/\n/g, "\u23CE");
@@ -4024,7 +4246,7 @@ async function loadConfig(cwd) {
4024
4246
 
4025
4247
  // cli/hooks-config.ts
4026
4248
  import { spawnSync } from "child_process";
4027
- var log8 = forComponent("hooks");
4249
+ var log9 = forComponent("hooks");
4028
4250
  var escapeRegex = (s) => s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
4029
4251
  function ruleMatches(rule, toolName) {
4030
4252
  if (!rule.tool || rule.tool === "*") return true;
@@ -4041,7 +4263,7 @@ function runCmd(rule, env) {
4041
4263
  });
4042
4264
  return { code: r.status ?? 1, out: ((r.stdout ?? "") + (r.stderr ?? "")).trim() };
4043
4265
  } catch (e) {
4044
- log8.debug(`hook command failed: ${rule.command}`, e);
4266
+ log9.debug(`hook command failed: ${rule.command}`, e);
4045
4267
  return { code: 1, out: String(e?.message ?? e) };
4046
4268
  }
4047
4269
  }
@@ -4147,7 +4369,7 @@ function formatDiff(ops, opts = {}) {
4147
4369
  // cli/session.ts
4148
4370
  import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, readdirSync, renameSync } from "fs";
4149
4371
  import { join as join5 } from "path";
4150
- var log9 = forComponent("session");
4372
+ var log10 = forComponent("session");
4151
4373
  var SessionStore = class {
4152
4374
  dir;
4153
4375
  constructor(cwd) {
@@ -4173,7 +4395,7 @@ var SessionStore = class {
4173
4395
  }
4174
4396
  load(id) {
4175
4397
  if (!this.safeId(id)) {
4176
- log9.debug(`rejecting unsafe session id: ${id}`);
4398
+ log10.debug(`rejecting unsafe session id: ${id}`);
4177
4399
  return void 0;
4178
4400
  }
4179
4401
  const path = join5(this.dir, `${id}.json`);
@@ -4181,7 +4403,7 @@ var SessionStore = class {
4181
4403
  try {
4182
4404
  return JSON.parse(readFileSync3(path, "utf8"));
4183
4405
  } catch (e) {
4184
- log9.debug(`unreadable session ${id} \u2014 ignoring`, e);
4406
+ log10.debug(`unreadable session ${id} \u2014 ignoring`, e);
4185
4407
  return void 0;
4186
4408
  }
4187
4409
  }
@@ -4194,7 +4416,7 @@ var SessionStore = class {
4194
4416
  try {
4195
4417
  metas.push(JSON.parse(readFileSync3(join5(this.dir, f), "utf8")).meta);
4196
4418
  } catch (e) {
4197
- log9.debug(`skipping unreadable session file ${f}`, e);
4419
+ log10.debug(`skipping unreadable session file ${f}`, e);
4198
4420
  }
4199
4421
  }
4200
4422
  return metas.sort((a, b) => b.updated - a.updated);
@@ -4288,7 +4510,7 @@ import { execFile } from "child_process";
4288
4510
  import { promisify } from "util";
4289
4511
  import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, existsSync as existsSync5 } from "fs";
4290
4512
  import { join as join6, resolve as resolve2, sep as sep2 } from "path";
4291
- var log10 = forComponent("checkpoints");
4513
+ var log11 = forComponent("checkpoints");
4292
4514
  var exec = promisify(execFile);
4293
4515
  var DEFAULT_EXCLUDE = [".agent/", ".git/", "node_modules/", "dist/", "build/", ".next/", "target/", ".venv/", "__pycache__/", "*.log"];
4294
4516
  var ShadowRepo = class {
@@ -4322,7 +4544,7 @@ var ShadowRepo = class {
4322
4544
  writeFileSync4(join6(this.gitDir, "info", "exclude"), this.exclude.join("\n") + "\n");
4323
4545
  this.ready = true;
4324
4546
  } catch (e) {
4325
- log10.debug(`git checkpoints unavailable for ${this.workTree}`, e);
4547
+ log11.debug(`git checkpoints unavailable for ${this.workTree}`, e);
4326
4548
  this.ready = false;
4327
4549
  }
4328
4550
  return this.ready;
@@ -4385,7 +4607,7 @@ var ShadowRepo = class {
4385
4607
  await this.run("gc", "--auto", "-q").catch(() => {
4386
4608
  });
4387
4609
  } catch (e) {
4388
- log10.debug("checkpoint prune failed", e);
4610
+ log11.debug("checkpoint prune failed", e);
4389
4611
  }
4390
4612
  }
4391
4613
  };
@@ -4442,7 +4664,7 @@ var GitCheckpoints = class {
4442
4664
  use(sessionId) {
4443
4665
  if (sessionId === this.session) return;
4444
4666
  this.session = sessionId;
4445
- if (this.started) for (const r of this.repos) void r.point(this.ref()).catch((e) => log10.debug("re-point failed", e));
4667
+ if (this.started) for (const r of this.repos) void r.point(this.ref()).catch((e) => log11.debug("re-point failed", e));
4446
4668
  }
4447
4669
  async begin(label) {
4448
4670
  if (!await this.start()) return;
@@ -4453,7 +4675,7 @@ var GitCheckpoints = class {
4453
4675
  try {
4454
4676
  await r.commit(msg);
4455
4677
  } catch (e) {
4456
- log10.debug("checkpoint commit failed", e);
4678
+ log11.debug("checkpoint commit failed", e);
4457
4679
  }
4458
4680
  }
4459
4681
  if (slow) clearTimeout(slow);
@@ -5402,6 +5624,9 @@ function createLineEditor(out) {
5402
5624
  process.on("SIGWINCH", onResize);
5403
5625
  return new Promise((resolve4) => {
5404
5626
  const redraw = () => render(s, opts.prompt, maxVisible, opts.status);
5627
+ const ticker = opts.statusTickMs && opts.status ? setInterval(() => {
5628
+ if (!s.pasting) redraw();
5629
+ }, opts.statusTickMs) : void 0;
5405
5630
  const onKey = (str, key) => {
5406
5631
  if (key?.ctrl && key.name === "l") {
5407
5632
  out.write("\x1B[2J\x1B[3J\x1B[H");
@@ -5419,6 +5644,11 @@ function createLineEditor(out) {
5419
5644
  redraw();
5420
5645
  return;
5421
5646
  }
5647
+ if (key?.ctrl && key.name === "o" && opts.onToggleVerbose) {
5648
+ opts.onToggleVerbose();
5649
+ redraw();
5650
+ return;
5651
+ }
5422
5652
  if (key?.meta && key.name === "p" && opts.onPickModel) {
5423
5653
  process.stdin.off("keypress", onKey);
5424
5654
  void opts.onPickModel().finally(() => {
@@ -5455,6 +5685,7 @@ function createLineEditor(out) {
5455
5685
  render(s, opts.prompt, maxVisible, opts.status);
5456
5686
  };
5457
5687
  const finish = () => {
5688
+ if (ticker) clearInterval(ticker);
5458
5689
  process.stdin.off("keypress", onKey);
5459
5690
  process.removeListener("SIGWINCH", onResize);
5460
5691
  out.write("\x1B[?2004l");
@@ -5767,7 +5998,7 @@ var red = C("31");
5767
5998
  var bold = C("1");
5768
5999
  var yellow = C("33");
5769
6000
  var err = (s) => process.stderr.write(s);
5770
- var log11 = forComponent("cli");
6001
+ var log12 = forComponent("cli");
5771
6002
  var VERSION = (() => {
5772
6003
  try {
5773
6004
  return JSON.parse(readFileSync5(new URL("../package.json", import.meta.url), "utf8")).version ?? "?";
@@ -5806,7 +6037,7 @@ function parseReasoning(raw) {
5806
6037
  throw new Error(`invalid --reasoning: ${raw} (use off|low|medium|high or a token budget)`);
5807
6038
  }
5808
6039
  function parseArgs(argv) {
5809
- const a = { stream: true, plan: false, ask: false, yes: false, vfs: false, shell: void 0, seed: false, subagents: false, help: false, version: false, cont: false, outputFormat: "text" };
6040
+ const a = { stream: true, plan: false, ask: false, yes: false, vfs: false, shell: void 0, seed: false, subagents: false, help: false, version: false, cont: false, outputFormat: "text", duplex: false, voice: false };
5810
6041
  const rest = [];
5811
6042
  const val = (i, flag) => {
5812
6043
  const v = argv[i];
@@ -5838,6 +6069,11 @@ function parseArgs(argv) {
5838
6069
  else if (x === "--shell") a.shell = true;
5839
6070
  else if (x === "--no-shell") a.shell = false;
5840
6071
  else if (x === "--subagents") a.subagents = true;
6072
+ else if (x === "--duplex") a.duplex = true;
6073
+ else if (x === "--conversational" || x === "--convo" || x === "--voice") {
6074
+ a.voice = true;
6075
+ a.duplex = true;
6076
+ } else if (x === "--voice-model") a.voiceModel = val(++i, x);
5841
6077
  else if (x === "--allowedTools" || x === "--allowed-tools") a.allowedTools = val(++i, x).split(",").map((s) => s.trim()).filter(Boolean);
5842
6078
  else if (x === "--disallowedTools" || x === "--disallowed-tools") a.disallowedTools = val(++i, x).split(",").map((s) => s.trim()).filter(Boolean);
5843
6079
  else if (x === "--append-system-prompt") a.appendSystemPrompt = val(++i, x);
@@ -5857,6 +6093,9 @@ function parseArgs(argv) {
5857
6093
  if (!a.task && rest.length) a.task = rest.join(" ");
5858
6094
  if (a.boddb && a.vfs) throw new Error("--boddb and --sandbox are mutually exclusive (both are non-disk filesystems; pick one)");
5859
6095
  if (a.seed && !a.boddb) throw new Error("--seed only applies with --boddb (it seeds the database from cwd on first run)");
6096
+ if (a.duplex && (a.task || a.print)) throw new Error("--duplex is interactive-only (a conversational mode) \u2014 drop the task/-p");
6097
+ if (a.duplex && a.plan) throw new Error("--plan is not supported in --duplex (workers are non-interactive; a plan could never be approved)");
6098
+ if (a.voiceModel && !a.duplex) throw new Error("--voice-model only applies with --duplex");
5860
6099
  return a;
5861
6100
  }
5862
6101
  var HELP = `agentx \u2014 agent.libx.js CLI
@@ -5886,6 +6125,11 @@ Flags:
5886
6125
  --allowedTools <l> comma-list of tools to allow w/o asking, e.g. "Edit,Shell(git *)"
5887
6126
  --disallowedTools <l> comma-list of tools to deny outright (wins over allow), e.g. "Shell(rm *)"
5888
6127
  --append-system-prompt <t> extra instructions appended to the system prompt for this run
6128
+ --duplex duplex mode: a fast voice model replies instantly and delegates real work
6129
+ to a background worker agent (-m model); results are re-voiced when ready
6130
+ --conversational duplex with a conversation-native register \u2014 short fast turns, fillers,
6131
+ impulsive reactions, human pacing (implies --duplex; aliases: --convo, --voice)
6132
+ --voice-model <id> with --duplex: the fast voice model (default anthropic/claude-haiku-4-5)
5889
6133
  --add-dir <path> mount another directory into the workspace (repeatable; disk mode only)
5890
6134
  --subagents allow the Task tool (spawn child agents)
5891
6135
  --reasoning <e> extended thinking: off|low|medium|high or a token budget (anthropic/openai)
@@ -5918,7 +6162,7 @@ REPL shortcuts: !<cmd> runs a shell command inline \xB7 #<note> saves a memory \
5918
6162
  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
5919
6163
  REPL completion: type / (commands+skills) or @ (files) for a LIVE menu \u2014 \u2191/\u2193 select, \u23CE/Tab accept, Esc dismiss.
5920
6164
  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.
5921
- REPL shortcuts: Shift+Tab cycles permission posture (ask \u2192 accept-edits \u2192 plan) \xB7 Alt+T toggles reasoning \xB7 Alt+P switches model \xB7 \u2192 or Tab accepts the dim history ghost-suggestion.
6165
+ 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.
5922
6166
  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.
5923
6167
  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).`;
5924
6168
  function newestModel() {
@@ -5980,7 +6224,10 @@ function makeHost(format = "text", opts) {
5980
6224
  }
5981
6225
  if (e.kind === "thinking_delta") {
5982
6226
  if (streamJson) process.stdout.write(JSON.stringify({ type: "thinking", text: e.message }) + "\n");
5983
- else if (!cleanStdout) process.stderr.write(dim(e.message));
6227
+ else if (!cleanStdout) {
6228
+ if (md && md.pending()) process.stdout.write(md.flush() + "\n");
6229
+ process.stderr.write(dim(e.message));
6230
+ }
5984
6231
  return;
5985
6232
  }
5986
6233
  if (md && md.pending()) process.stdout.write(md.flush() + "\n");
@@ -6054,7 +6301,7 @@ function displayHooks(fs) {
6054
6301
  const text = String(result).replace(/\s+$/, "");
6055
6302
  if (text && !/^Edited|^Wrote|^Applied/.test(text)) {
6056
6303
  const lines = text.split("\n");
6057
- const shown = lines.slice(0, RESULT_PREVIEW_LINES);
6304
+ const shown = lines.slice(0, previewLines());
6058
6305
  for (const ln of shown) err(dim(` ${ln.length > 200 ? ln.slice(0, 200) + "\u2026" : ln}
6059
6306
  `));
6060
6307
  const more = lines.length - shown.length;
@@ -6182,6 +6429,14 @@ async function appendMemoryNote(fs, dir, text) {
6182
6429
  }
6183
6430
  var ASK_MUTATING = ["bash", "Shell", "Write", "Edit", "MultiEdit", "deleteFile"];
6184
6431
  var RESULT_PREVIEW_LINES = 6;
6432
+ var verboseOutput = false;
6433
+ var previewLines = () => verboseOutput ? Number.MAX_SAFE_INTEGER : RESULT_PREVIEW_LINES;
6434
+ var toggleVerbose = () => {
6435
+ verboseOutput = !verboseOutput;
6436
+ err(dim(` \u2303O verbose output ${verboseOutput ? "on \u2014 full tool results" : "off \u2014 previews"}
6437
+ `));
6438
+ return verboseOutput ? "verbose" : "preview";
6439
+ };
6185
6440
  var canPrompt = !!(process.stdin.isTTY && process.stderr.isTTY);
6186
6441
  function resolvePermMode(args, interactiveCapable) {
6187
6442
  if (args.yes) return { gate: "allow" };
@@ -6262,7 +6517,10 @@ function optsFor(args, ai, cfg = {}, extraTools = []) {
6262
6517
  maxToolCalls: cfg.maxToolCalls,
6263
6518
  keepToolOutputs: cfg.keepToolOutputs,
6264
6519
  maxContextTokens: cfg.maxContextTokens,
6265
- learnFromMistakes: cfg.learnFromMistakes
6520
+ learnFromMistakes: cfg.learnFromMistakes,
6521
+ // Forwarded to cursor/* delegations for environment parity (chat-model providers ignore it).
6522
+ // Raw config (pre-OAuth): unresolved-oauth http servers are skipped by the cursor mapper.
6523
+ mcpServers: cfg.mcpServers
6266
6524
  };
6267
6525
  }
6268
6526
  async function makeAgent(args, ai, cfg, extraTools = []) {
@@ -6306,7 +6564,7 @@ async function mountMcp(cfg, oauth) {
6306
6564
  return mounted;
6307
6565
  }
6308
6566
  async function closeMcp(mounted) {
6309
- await Promise.all(mounted.map((m) => m.client.close().catch((e) => log11.debug("mcp close failed", e))));
6567
+ await Promise.all(mounted.map((m) => m.client.close().catch((e) => log12.debug("mcp close failed", e))));
6310
6568
  }
6311
6569
  var IMG_EXT = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
6312
6570
  function readImageParts(cwd, line) {
@@ -6386,7 +6644,7 @@ async function readMultiline(readLine) {
6386
6644
  return parts.join("\n");
6387
6645
  }
6388
6646
  }
6389
- async function runTurn(agent, store, session, task, cp, cwd = process.cwd()) {
6647
+ async function runTurn(agent, store, session, task, cp, cwd = process.cwd(), sendFn) {
6390
6648
  const t0 = Date.now();
6391
6649
  await cp?.begin(task);
6392
6650
  const { text, loaded, missing } = await expandMentions(agent.options.fs, task);
@@ -6406,9 +6664,9 @@ async function runTurn(agent, store, session, task, cp, cwd = process.cwd()) {
6406
6664
  agent.options.signal = ctrl.signal;
6407
6665
  const content = images.length ? [{ type: "text", text }, ...images] : text;
6408
6666
  let res;
6409
- spinner.start();
6667
+ spinner.start(sendFn ? "voice\u2026" : void 0);
6410
6668
  try {
6411
- res = await agent.send(content);
6669
+ res = await (sendFn ? sendFn(content) : agent.send(content));
6412
6670
  } catch (e) {
6413
6671
  spinner.stop();
6414
6672
  err(red(` error: ${e?.message ?? e}
@@ -6529,25 +6787,127 @@ function persistSetting(cwd, key, value) {
6529
6787
  }
6530
6788
  }
6531
6789
  var persistModel = (cwd, model) => persistSetting(cwd, "model", model);
6790
+ var isCancelTeardown = (e) => {
6791
+ if (!e) return false;
6792
+ if (e.code === 20 || e.cause?.code === 20) return true;
6793
+ const blob = `${e.code ?? ""} ${e.name ?? ""} ${e.cause?.name ?? ""} ${e.rawMessage ?? ""} ${e.message ?? ""}`;
6794
+ return /NGHTTP2_FRAME_SIZE_ERROR|ERR_HTTP2_STREAM_ERROR|operation was aborted|\bAbortError\b/i.test(blob);
6795
+ };
6796
+ function installCancelGuards(mounted) {
6797
+ process.on("unhandledRejection", (e) => {
6798
+ if (isCancelTeardown(e)) {
6799
+ log12.debug("suppressed unhandledRejection (cursor stream cancel)", e);
6800
+ return;
6801
+ }
6802
+ log12.error("unhandledRejection", e);
6803
+ });
6804
+ process.on("uncaughtException", (e) => {
6805
+ if (isCancelTeardown(e)) {
6806
+ log12.debug("suppressed uncaughtException (cursor stream cancel)", e);
6807
+ return;
6808
+ }
6809
+ console.error(e);
6810
+ void closeMcp(mounted);
6811
+ process.exit(1);
6812
+ });
6813
+ }
6532
6814
  async function repl(args, ai, cfg, cwd) {
6533
6815
  const oauth = new McpOAuth({ storePath: join8(cwd, ".agent", "mcp-auth.json") });
6534
6816
  const mounted = await mountMcp(cfg, oauth);
6535
6817
  const agent = await makeAgent(args, ai, cfg, mounted.flatMap((m) => m.tools));
6818
+ const duplex = args.duplex;
6819
+ let dx;
6820
+ let workerOptions;
6821
+ let duplexPersist = () => {
6822
+ };
6823
+ let duplexAccount = () => {
6824
+ };
6825
+ const duplexAsk = async (call) => {
6826
+ err(yellow(` \u2298 worker asked to run ${call.name} \u2014 auto-denied (no interactive approval in duplex; use --yes or --allowedTools)
6827
+ `));
6828
+ return { decision: "deny" };
6829
+ };
6830
+ if (duplex) {
6831
+ const { host: _host, stream: _stream, signal: _signal, ...wo } = agent.options;
6832
+ workerOptions = wo;
6833
+ if (workerOptions.permissions)
6834
+ workerOptions.permissions = new PermissionPolicy({ ...workerOptions.permissions.options, host: void 0, ask: duplexAsk });
6835
+ workerOptions.planMode = false;
6836
+ const base = makeHost("text", { stream: true });
6837
+ const host = {
6838
+ ...base,
6839
+ notify(e) {
6840
+ if (e.kind === "revoice_done") {
6841
+ base.flushText();
6842
+ process.stdout.write("\n");
6843
+ duplexPersist();
6844
+ return;
6845
+ }
6846
+ if (e.kind === "task_done" && e.data?.text) {
6847
+ const lines = String(e.data.text).split("\n");
6848
+ const shown = lines.slice(0, previewLines());
6849
+ err("\n" + dim(` \u29BF ${e.message}
6850
+ `) + shown.map((l) => dim(` ${l}
6851
+ `)).join(""));
6852
+ if (lines.length > shown.length) err(dim(` \u2026 (+${lines.length - shown.length} more lines)
6853
+ `));
6854
+ duplexAccount(e.data);
6855
+ return;
6856
+ }
6857
+ base.notify(e);
6858
+ }
6859
+ };
6860
+ const rewindFilesTool = {
6861
+ name: "RewindFiles",
6862
+ description: "Undo file changes made by delegated tasks: roll back the last N task checkpoints (default 1). Use when the user asks to undo/revert what a task changed.",
6863
+ parameters: { type: "object", properties: { steps: { type: "number", description: "how many task checkpoints to undo (default 1)" } } },
6864
+ run: async ({ steps }) => {
6865
+ if (!checkpoints.size) return "No file checkpoints to rewind yet.";
6866
+ if ([...dx?.tasks.values() ?? []].some((t) => t.status === "running"))
6867
+ return "A task is still running \u2014 cancel it first (CancelTask), then rewind.";
6868
+ const n = Math.min(Math.max(1, Number(steps ?? 1)), checkpoints.size);
6869
+ const r = await checkpoints.rewindTo(checkpoints.size - n);
6870
+ return `Rewound ${n} task checkpoint(s): restored ${r.restored} file(s), deleted ${r.deleted}.`;
6871
+ }
6872
+ };
6873
+ dx = new DuplexAgent({
6874
+ ai,
6875
+ fs: agent.options.fs,
6876
+ ...args.voiceModel ? { voiceModel: resolveModelOrNewest(args.voiceModel) } : {},
6877
+ workerModel: agent.options.model,
6878
+ workerOptions,
6879
+ host,
6880
+ ...args.voice ? { voiceStyle: "conversational" } : {},
6881
+ // Per-TASK checkpoint frames (the natural undo unit in duplex = one delegation): opened BEFORE
6882
+ // the worker spawns (post-spawn would race its first edits). `checkpoints` is bound below.
6883
+ onTaskStart: async (_id, label) => {
6884
+ await checkpoints.begin(label);
6885
+ },
6886
+ // The voice runs on the REAL fs (it has no fs tools — harmless) so @mentions, !cmd and #note
6887
+ // resolve against the project; + CC-parity chrome for its own tool calls (⚙ Delegate …).
6888
+ voiceOptions: { fs: agent.options.fs, hooks: displayHooks(agent.options.fs), tools: [rewindFilesTool] }
6889
+ });
6890
+ }
6891
+ const face = dx ? dx.voice : agent;
6892
+ const work = workerOptions ?? agent.options;
6893
+ const sendVia = dx ? (c) => dx.send(c) : void 0;
6536
6894
  const baseRules = () => [
6537
6895
  ...parsePermRules({ deny: args.disallowedTools }),
6538
6896
  ...parsePermRules({ allow: args.allowedTools }),
6539
6897
  ...parsePermRules(mergePerms(loadPersistedRules(cwd), cfg.permissions))
6540
6898
  ];
6541
6899
  const askFor = (tools) => tools.map((t) => ({ tool: t, decision: "ask" }));
6542
- const POSTURES = ["default", "acceptEdits", "plan"];
6543
- let posture = args.plan || cfg.permissionMode === "plan" ? "plan" : cfg.permissionMode === "acceptEdits" ? "acceptEdits" : "default";
6900
+ const POSTURES = duplex ? ["default", "acceptEdits"] : ["default", "acceptEdits", "plan"];
6901
+ let posture = !duplex && (args.plan || cfg.permissionMode === "plan") ? "plan" : cfg.permissionMode === "acceptEdits" ? "acceptEdits" : "default";
6544
6902
  const postureLabel = () => posture === "default" ? "ask (default)" : posture === "acceptEdits" ? "accept edits" : "plan mode";
6545
6903
  const applyPosture = (p) => {
6546
6904
  posture = p;
6547
6905
  const ask = p === "acceptEdits" ? askFor(["bash", "Shell"]) : askFor(ASK_MUTATING);
6548
- agent.options.permissions = new PermissionPolicy({ rules: [...baseRules(), ...ask], default: "allow", host: makeHost(), ask: makeAskResolver(cwd) });
6549
- agent.options.planMode = p === "plan";
6550
- agent.reprepare();
6906
+ work.permissions = new PermissionPolicy({ rules: [...baseRules(), ...ask], default: "allow", host: duplex ? void 0 : makeHost(), ask: duplex ? duplexAsk : makeAskResolver(cwd) });
6907
+ if (!duplex) {
6908
+ agent.options.planMode = p === "plan";
6909
+ agent.reprepare();
6910
+ }
6551
6911
  };
6552
6912
  const cyclePosture = () => {
6553
6913
  applyPosture(POSTURES[(POSTURES.indexOf(posture) + 1) % POSTURES.length]);
@@ -6558,13 +6918,27 @@ async function repl(args, ai, cfg, cwd) {
6558
6918
  if (!args.yes) applyPosture(posture);
6559
6919
  const REASONING_CYCLE = ["off", "low", "medium", "high"];
6560
6920
  const toggleReasoning = () => {
6561
- const cur = String(agent.options.reasoning ?? "off");
6921
+ const cur = String(work.reasoning ?? "off");
6562
6922
  const next = REASONING_CYCLE[(Math.max(0, REASONING_CYCLE.indexOf(cur)) + 1) % REASONING_CYCLE.length];
6563
- agent.options.reasoning = next;
6923
+ work.reasoning = next;
6564
6924
  err(dim(` ~ reasoning \u2192 ${next}
6565
6925
  `));
6566
6926
  return next;
6567
6927
  };
6928
+ const setModel = (m) => {
6929
+ work.model = m;
6930
+ if (dx) dx.options.workerModel = m;
6931
+ persistModel(cwd, m);
6932
+ err(dim(" model \u2192 " + m + "\n"));
6933
+ };
6934
+ const addWorkTools = (ts) => {
6935
+ if (duplex) work.tools = [...work.tools ?? [], ...ts];
6936
+ else agent.addTools(ts);
6937
+ };
6938
+ const removeWorkTools = (names) => {
6939
+ if (duplex) work.tools = (work.tools ?? []).filter((t) => !names.includes(t.name));
6940
+ else agent.removeTools(names);
6941
+ };
6568
6942
  const pendingImages = [];
6569
6943
  const grabClipboardAttachment = () => {
6570
6944
  const dir = join8(tmpdir(), "agentx-pasted");
@@ -6583,33 +6957,33 @@ async function repl(args, ai, cfg, cwd) {
6583
6957
  void closeMcp(mounted);
6584
6958
  process.exit(130);
6585
6959
  });
6586
- const isCancelTeardown = (e) => {
6587
- if (!e) return false;
6588
- if (e.code === 20 || e.cause?.code === 20) return true;
6589
- const blob = `${e.code ?? ""} ${e.name ?? ""} ${e.cause?.name ?? ""} ${e.rawMessage ?? ""} ${e.message ?? ""}`;
6590
- return /NGHTTP2_FRAME_SIZE_ERROR|ERR_HTTP2_STREAM_ERROR|operation was aborted|\bAbortError\b/i.test(blob);
6591
- };
6592
- process.on("unhandledRejection", (e) => {
6593
- if (isCancelTeardown(e)) {
6594
- log11.debug("suppressed unhandledRejection (cursor stream cancel)", e);
6595
- return;
6596
- }
6597
- log11.error("unhandledRejection", e);
6598
- });
6599
- process.on("uncaughtException", (e) => {
6600
- if (isCancelTeardown(e)) {
6601
- log11.debug("suppressed uncaughtException (cursor stream cancel)", e);
6602
- return;
6603
- }
6604
- console.error(e);
6605
- void closeMcp(mounted);
6606
- process.exit(1);
6607
- });
6960
+ installCancelGuards(mounted);
6608
6961
  const store = new SessionStore(cwd);
6609
- let session = startSession(args, store, agent, cwd);
6962
+ let session = startSession(args, store, face, cwd);
6610
6963
  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 });
6611
6964
  const cpHooks = checkpoints.hooks?.();
6612
- if (cpHooks) agent.options.hooks = composeHooks(agent.options.hooks, cpHooks);
6965
+ if (cpHooks) work.hooks = composeHooks(work.hooks, cpHooks);
6966
+ duplexPersist = () => {
6967
+ session.messages = face.transcript;
6968
+ session.meta.updated = Date.now();
6969
+ if (!session.meta.title) session.meta.title = titleOf(face.transcript);
6970
+ try {
6971
+ store.save(session);
6972
+ } catch (e) {
6973
+ err(dim(` (session not saved: ${e?.message ?? e})
6974
+ `));
6975
+ }
6976
+ };
6977
+ duplexAccount = (data) => {
6978
+ if (!data?.usage?.totalTokens) return;
6979
+ session.meta.tokens = (session.meta.tokens ?? 0) + data.usage.totalTokens;
6980
+ session.meta.costUsd = (session.meta.costUsd ?? 0) + turnCost(work.model, data.usage);
6981
+ if (data.usageEstimated) session.meta.costEstimated = true;
6982
+ try {
6983
+ store.save(session);
6984
+ } catch {
6985
+ }
6986
+ };
6613
6987
  const fs = agent.options.fs;
6614
6988
  const fsBase = fs.getCwd() === "/" ? "" : fs.getCwd();
6615
6989
  const adot = (sub) => `${fsBase}/.agent/${sub}`;
@@ -6623,7 +6997,7 @@ async function repl(args, ai, cfg, cwd) {
6623
6997
  mkdirSync6(join8(cwd, ".agent"), { recursive: true });
6624
6998
  appendFileSync(histPath, line + "\n");
6625
6999
  } catch (e) {
6626
- log11.debug("history write failed", e);
7000
+ log12.debug("history write failed", e);
6627
7001
  }
6628
7002
  };
6629
7003
  const ago = (t) => {
@@ -6631,7 +7005,7 @@ async function repl(args, ai, cfg, cwd) {
6631
7005
  return s < 60 ? "just now" : s < 3600 ? `${Math.floor(s / 60)}m ago` : s < 86400 ? `${Math.floor(s / 3600)}h ago` : `${Math.floor(s / 86400)}d ago`;
6632
7006
  };
6633
7007
  const resumeInto = (data) => {
6634
- agent.transcript = data.messages;
7008
+ face.transcript = data.messages;
6635
7009
  session = data;
6636
7010
  checkpoints.use?.(data.meta.id);
6637
7011
  err(dim(` resumed ${data.meta.id} (${data.meta.turns} turns)${data.meta.title ? " \u2014 " + data.meta.title : ""}
@@ -6639,7 +7013,7 @@ async function repl(args, ai, cfg, cwd) {
6639
7013
  printHistory(data.messages);
6640
7014
  };
6641
7015
  const rewindToMessage = async () => {
6642
- const users = agent.transcript.map((m, i) => ({ m, i })).filter((x) => x.m.role === "user");
7016
+ const users = face.transcript.map((m, i) => ({ m, i })).filter((x) => x.m.role === "user");
6643
7017
  if (!users.length) {
6644
7018
  err(dim(" (no earlier messages to jump back to)\n"));
6645
7019
  return void 0;
@@ -6655,7 +7029,7 @@ async function repl(args, ai, cfg, cwd) {
6655
7029
  const idx = users[p].i;
6656
7030
  const frame = p - (users.length - checkpoints.size);
6657
7031
  let mode = "convo";
6658
- if (frame >= 0 && frame < checkpoints.size) {
7032
+ if (!duplex && frame >= 0 && frame < checkpoints.size) {
6659
7033
  const m = await selectMenu(process.stderr, { title: "Restore\u2026", items: [
6660
7034
  { label: "Conversation and code", value: "both" },
6661
7035
  { label: "Conversation only", value: "convo" },
@@ -6664,7 +7038,7 @@ async function repl(args, ai, cfg, cwd) {
6664
7038
  if (m == null) return void 0;
6665
7039
  mode = m;
6666
7040
  }
6667
- const text = contentText(agent.transcript[idx].content).split("\n\n--- @")[0].trim();
7041
+ const text = contentText(face.transcript[idx].content).split("\n\n--- @")[0].trim();
6668
7042
  if (mode === "code" || mode === "both") {
6669
7043
  try {
6670
7044
  const { restored, deleted } = await checkpoints.rewindTo(frame);
@@ -6676,14 +7050,14 @@ async function repl(args, ai, cfg, cwd) {
6676
7050
  }
6677
7051
  }
6678
7052
  if (mode === "convo" || mode === "both") {
6679
- agent.transcript = agent.transcript.slice(0, idx);
6680
- session.messages = agent.transcript;
7053
+ face.transcript = face.transcript.slice(0, idx);
7054
+ session.messages = face.transcript;
6681
7055
  try {
6682
7056
  store.save(session);
6683
7057
  } catch (e) {
6684
- log11.debug("session save after rewind failed", e);
7058
+ log12.debug("session save after rewind failed", e);
6685
7059
  }
6686
- err(green(" \u27F2 jumped back") + dim(` \u2014 ${agent.transcript.length} message(s) kept; edit + resend
7060
+ err(green(" \u27F2 jumped back") + dim(` \u2014 ${face.transcript.length} message(s) kept; edit + resend
6687
7061
  `));
6688
7062
  return text;
6689
7063
  }
@@ -6702,19 +7076,20 @@ async function repl(args, ai, cfg, cwd) {
6702
7076
  if (data) resumeInto(data);
6703
7077
  else err(red(" no such session\n"));
6704
7078
  };
7079
+ const turn = (task) => runTurn(face, store, session, task, duplex ? void 0 : checkpoints, cwd, sendVia);
6705
7080
  const runSkill = async (sk, extra = "") => {
6706
7081
  try {
6707
7082
  const body = await fs.readFile(sk.path);
6708
- await runTurn(agent, store, session, extra ? `${body}
7083
+ await turn(extra ? `${body}
6709
7084
 
6710
- ${extra}` : body, checkpoints, cwd);
7085
+ ${extra}` : body);
6711
7086
  } catch (e) {
6712
7087
  err(red(` couldn't load skill ${sk.name}: ${e?.message ?? e}
6713
7088
  `));
6714
7089
  }
6715
7090
  };
6716
7091
  const runCommand = async (c, extra = "") => {
6717
- await runTurn(agent, store, session, await expandCommand(fs, c, extra), checkpoints, cwd);
7092
+ await turn(await expandCommand(fs, c, extra));
6718
7093
  };
6719
7094
  const pickAndRun = async (kind) => {
6720
7095
  const pool = kind === "skill" ? skills : cmds;
@@ -6771,15 +7146,21 @@ ${extra}` : body, checkpoints, cwd);
6771
7146
  desc: "show CLI version + runtime",
6772
7147
  run: () => {
6773
7148
  const rt = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`;
6774
- err(` ${bold("agent.libx.js")} ${cyan("v" + VERSION)}${dim(` \xB7 ${agent.options.model} \xB7 ${rt}`)}
7149
+ err(` ${bold("agent.libx.js")} ${cyan("v" + VERSION)}${dim(` \xB7 ${duplex ? `voice ${dx.options.voiceModel} \xB7 worker ${work.model}` : work.model} \xB7 ${rt}`)}
6775
7150
  `);
6776
7151
  }
6777
7152
  },
6778
- tools: { desc: "list available tools", run: () => err(dim(" " + agent.options.tools.map((t) => t.name).join(", ") + "\n")) },
7153
+ tools: {
7154
+ desc: "list available tools",
7155
+ run: () => {
7156
+ if (duplex) err(dim(" voice: " + face.options.tools.map((t) => t.name).join(", ") + "\n worker: " + (work.tools ?? []).map((t) => t.name).join(", ") + "\n"));
7157
+ else err(dim(" " + agent.options.tools.map((t) => t.name).join(", ") + "\n"));
7158
+ }
7159
+ },
6779
7160
  permissions: {
6780
7161
  desc: "show the active permission rules + default posture",
6781
7162
  run: () => {
6782
- const pol = agent.options.permissions;
7163
+ const pol = work.permissions;
6783
7164
  const rules = pol?.options.rules ?? [];
6784
7165
  if (!rules.length) err(dim(" (no rules \u2014 default: " + (pol?.options.default ?? "allow") + ")\n"));
6785
7166
  else {
@@ -6793,19 +7174,22 @@ ${extra}` : body, checkpoints, cwd);
6793
7174
  desc: "session status \u2014 model, dir, fs-mode, permissions, tools, usage",
6794
7175
  run: () => {
6795
7176
  const mode = args.vfs ? "sandbox (VFS \u2014 disk untouched)" : args.boddb ? `boddb (database workspace at ${args.boddb} \u2014 disk untouched)` : args.shell ? "disk + real /bin/sh" : "disk (full real FS, like Claude Code)";
6796
- const pol = agent.options.permissions;
7177
+ const pol = work.permissions;
6797
7178
  const perm = !pol ? "allow all (unattended)" : `${pol.options.rules.length} rule(s), default ${pol.options.default}`;
6798
- err(formatStatus({ model: agent.options.model, cwd, mode, tools: agent.options.tools.map((t) => t.name), permissions: perm, turns: session.meta.turns, tokens: session.meta.tokens ?? 0, sessionId: session.meta.id, estimated: session.meta.costEstimated ?? false }));
7179
+ const model = duplex ? `voice ${dx.options.voiceModel} \xB7 worker ${work.model}` : work.model;
7180
+ err(formatStatus({ model, cwd, mode, tools: (duplex ? work.tools ?? [] : agent.options.tools).map((t) => t.name), permissions: perm, turns: session.meta.turns, tokens: session.meta.tokens ?? 0, sessionId: session.meta.id, estimated: session.meta.costEstimated ?? false }));
7181
+ if (duplex && dx.tasks.size) err(dim(` tasks: ${[...dx.tasks.values()].map((t) => `${t.id}:${t.status}`).join(" ")}
7182
+ `));
6799
7183
  }
6800
7184
  },
6801
7185
  cost: {
6802
7186
  desc: "cumulative cost + token usage for this session",
6803
7187
  run: () => {
6804
7188
  const t = session.meta.tokens ?? 0, usd = session.meta.costUsd ?? 0;
6805
- const priced = getModelInfo(agent.options.model)?.pricing;
7189
+ const priced = getModelInfo(work.model)?.pricing;
6806
7190
  const est = session.meta.costEstimated ?? false;
6807
7191
  const m = est ? "~" : "";
6808
- const note = priced ? est ? " (estimated \u2014 some turns streamed without usage)" : " (exact \u2014 provider-reported usage)" : ` (no pricing for ${agent.options.model})`;
7192
+ const note = priced ? est ? " (estimated \u2014 some turns streamed without usage)" : " (exact \u2014 provider-reported usage)" : ` (no pricing for ${work.model})`;
6809
7193
  err(dim(` ${usd > 0 ? m + fmtUsd(usd) + " \xB7 " : ""}${m}${(t / 1e3).toFixed(1)}k tokens across ${session.meta.turns} turn(s)${note}
6810
7194
  `));
6811
7195
  }
@@ -6813,9 +7197,9 @@ ${extra}` : body, checkpoints, cwd);
6813
7197
  context: {
6814
7198
  desc: "context-window usage (messages + estimated tokens)",
6815
7199
  run: () => {
6816
- const est = estimateTranscriptTokens(agent.transcript);
6817
- const cap = agent.options.maxTokens || 2e5;
6818
- err(dim(` ${agent.transcript.length} message(s) \xB7 ~${(est / 1e3).toFixed(1)}k tokens (~${Math.round(est / cap * 100)}% of ${Math.round(cap / 1e3)}k budget)
7200
+ const est = estimateTranscriptTokens(face.transcript);
7201
+ const cap = face.options.maxTokens || 2e5;
7202
+ err(dim(` ${face.transcript.length} message(s) \xB7 ~${(est / 1e3).toFixed(1)}k tokens (~${Math.round(est / cap * 100)}% of ${Math.round(cap / 1e3)}k budget)
6819
7203
  `));
6820
7204
  }
6821
7205
  },
@@ -6846,26 +7230,21 @@ ${extra}` : body, checkpoints, cwd);
6846
7230
  }
6847
7231
  },
6848
7232
  model: {
6849
- desc: "switch model \u2014 /model <id>, or /model alone for an interactive picker",
7233
+ desc: "switch model \u2014 /model <id>, or /model alone for an interactive picker (duplex: the worker model)",
6850
7234
  run: async (a) => {
6851
7235
  if (a[0]) {
6852
- agent.options.model = a[0];
6853
- persistModel(cwd, a[0]);
6854
- err(dim(" model \u2192 " + a[0] + "\n"));
7236
+ setModel(a[0]);
6855
7237
  return;
6856
7238
  }
6857
- const picked = await pickModel(agent.options.model);
6858
- if (picked) {
6859
- agent.options.model = picked;
6860
- persistModel(cwd, picked);
6861
- err(dim(" model \u2192 " + picked + "\n"));
6862
- } else err(dim(" " + agent.options.model + "\n"));
7239
+ const picked = await pickModel(work.model);
7240
+ if (picked) setModel(picked);
7241
+ else err(dim(" " + (duplex ? `voice ${dx.options.voiceModel} \xB7 worker ${work.model}` : work.model) + "\n"));
6863
7242
  }
6864
7243
  },
6865
7244
  reasoning: {
6866
- desc: "extended thinking \u2014 /reasoning <off|low|medium|high|tokens>, or alone for an interactive picker",
7245
+ desc: "extended thinking \u2014 /reasoning <off|low|medium|high|tokens>, or alone for an interactive picker (duplex: the workers')",
6867
7246
  run: async (a) => {
6868
- const current = String(agent.options.reasoning ?? "off");
7247
+ const current = String(work.reasoning ?? "off");
6869
7248
  let next;
6870
7249
  if (a[0]) {
6871
7250
  try {
@@ -6888,9 +7267,9 @@ ${extra}` : body, checkpoints, cwd);
6888
7267
  }
6889
7268
  next = picked;
6890
7269
  }
6891
- agent.options.reasoning = next;
6892
- if (next !== "off" && getModelInfo(agent.options.model)?.reasoning === false)
6893
- err(yellow(` note: ${agent.options.model} has no reasoning capability \u2014 setting may be ignored
7270
+ work.reasoning = next;
7271
+ if (next !== "off" && getModelInfo(work.model)?.reasoning === false)
7272
+ err(yellow(` note: ${work.model} has no reasoning capability \u2014 setting may be ignored
6894
7273
  `));
6895
7274
  err(dim(" reasoning \u2192 " + next + "\n"));
6896
7275
  }
@@ -6900,23 +7279,21 @@ ${extra}` : body, checkpoints, cwd);
6900
7279
  run: async () => {
6901
7280
  for (; ; ) {
6902
7281
  const items = [
6903
- { label: "model", value: "model", desc: agent.options.model },
6904
- { label: "reasoning", value: "reasoning", desc: String(agent.options.reasoning ?? "off") },
7282
+ { label: "model", value: "model", desc: work.model },
7283
+ { label: "reasoning", value: "reasoning", desc: String(work.reasoning ?? "off") },
6905
7284
  { label: "permission posture", value: "posture", desc: postureLabel() + " (Shift+Tab)" },
6906
- { label: "streaming", value: "stream", desc: agent.options.stream ? "on" : "off" },
7285
+ // streaming is the voice's lifeblood in duplex (always on) only a normal-mode knob
7286
+ ...duplex ? [] : [{ label: "streaming", value: "stream", desc: agent.options.stream ? "on" : "off" }],
6907
7287
  { label: "editor mode", value: "editor", desc: cfg.editorMode === "vim" ? "vim" : "normal" }
6908
7288
  ];
6909
7289
  const pick = await selectMenu(process.stderr, { title: "Settings \xB7 \u21B5 change \xB7 esc close", items });
6910
7290
  if (!pick) return;
6911
7291
  if (pick === "model") {
6912
- const m = await pickModel(agent.options.model);
6913
- if (m) {
6914
- agent.options.model = m;
6915
- persistModel(cwd, m);
6916
- }
7292
+ const m = await pickModel(work.model);
7293
+ if (m) setModel(m);
6917
7294
  } else if (pick === "reasoning") {
6918
7295
  await builtins.reasoning.run([]);
6919
- persistSetting(cwd, "reasoning", agent.options.reasoning ?? "off");
7296
+ persistSetting(cwd, "reasoning", work.reasoning ?? "off");
6920
7297
  } else if (pick === "posture") {
6921
7298
  cyclePosture();
6922
7299
  persistSetting(cwd, "permissionMode", posture);
@@ -6951,8 +7328,8 @@ ${extra}` : body, checkpoints, cwd);
6951
7328
  compact: {
6952
7329
  desc: "summarize older context to free up the window",
6953
7330
  run: () => {
6954
- const n = agent.compactNow();
6955
- session.messages = agent.transcript;
7331
+ const n = face.compactNow();
7332
+ session.messages = face.transcript;
6956
7333
  try {
6957
7334
  store.save(session);
6958
7335
  } catch {
@@ -6999,8 +7376,8 @@ ${extra}` : body, checkpoints, cwd);
6999
7376
  clear: {
7000
7377
  desc: "start a fresh conversation (and clear the screen)",
7001
7378
  run: () => {
7002
- agent.transcript = [];
7003
- session = startSession({ ...args, cont: false, resume: void 0 }, store, agent, cwd);
7379
+ face.transcript = [];
7380
+ session = startSession({ ...args, cont: false, resume: void 0 }, store, face, cwd);
7004
7381
  err("\x1Bc");
7005
7382
  }
7006
7383
  },
@@ -7038,13 +7415,13 @@ ${extra}` : body, checkpoints, cwd);
7038
7415
  err(green(` \u2713 authorized "${name}"`) + dim(" \u2014 remounting with the new token\n"));
7039
7416
  const idx = mounted.findIndex((m2) => m2.name === name);
7040
7417
  if (idx >= 0) {
7041
- agent.removeTools(mounted[idx].tools.map((t) => t.name));
7418
+ removeWorkTools(mounted[idx].tools.map((t) => t.name));
7042
7419
  await mounted.splice(idx, 1)[0].client.close().catch(() => {
7043
7420
  });
7044
7421
  }
7045
7422
  const m = await mountMcpServer(name, { ...target, bearerToken: await oauth.tokenFor(target.url) });
7046
7423
  mounted.push(m);
7047
- agent.addTools(m.tools);
7424
+ addWorkTools(m.tools);
7048
7425
  err(green(` \u2713 ${m.name}`) + dim(` \u2014 ${m.tools.length} tool(s)
7049
7426
  `));
7050
7427
  } catch (e) {
@@ -7069,7 +7446,7 @@ ${extra}` : body, checkpoints, cwd);
7069
7446
  try {
7070
7447
  const m = await mountMcpServer(name, cfg2);
7071
7448
  mounted.push(m);
7072
- agent.addTools(m.tools);
7449
+ addWorkTools(m.tools);
7073
7450
  err(green(` \u2713 ${m.name}`) + dim(` \u2014 ${m.tools.length} tool(s)${m.serverInfo?.name ? ` from ${m.serverInfo.name}` : ""}
7074
7451
  `));
7075
7452
  } catch (e) {
@@ -7091,8 +7468,8 @@ ${extra}` : body, checkpoints, cwd);
7091
7468
  return;
7092
7469
  }
7093
7470
  const m = mounted.splice(idx, 1)[0];
7094
- agent.removeTools(m.tools.map((t) => t.name));
7095
- await m.client.close().catch((e) => log11.debug("mcp close failed", e));
7471
+ removeWorkTools(m.tools.map((t) => t.name));
7472
+ await m.client.close().catch((e) => log12.debug("mcp close failed", e));
7096
7473
  err(dim(` removed "${name}"
7097
7474
  `));
7098
7475
  return;
@@ -7191,7 +7568,7 @@ ${extra}` : body, checkpoints, cwd);
7191
7568
  return;
7192
7569
  }
7193
7570
  const msg = a.join(" ").trim();
7194
- if (msg) await runTurn(agent, store, session, `${msg} ${att.ref}`, checkpoints, cwd);
7571
+ if (msg) await turn(`${msg} ${att.ref}`);
7195
7572
  else {
7196
7573
  pendingImages.push(att.path);
7197
7574
  err(green(` \u2713 image attached (#${pendingImages.length})`) + dim(" \u2014 type your message to send it\n"));
@@ -7201,7 +7578,7 @@ ${extra}` : body, checkpoints, cwd);
7201
7578
  export: {
7202
7579
  desc: "save this conversation to Markdown \u2014 /export [path] (default ./.agent/exports/<id>.md)",
7203
7580
  run: (a) => {
7204
- const shown = agent.transcript.filter((m) => m.role !== "system");
7581
+ const shown = face.transcript.filter((m) => m.role !== "system");
7205
7582
  if (!shown.length) {
7206
7583
  err(dim(" (nothing to export yet)\n"));
7207
7584
  return;
@@ -7224,14 +7601,16 @@ ${extra}` : body, checkpoints, cwd);
7224
7601
  exit: { desc: "quit", run: () => true },
7225
7602
  quit: { desc: "quit", run: () => true }
7226
7603
  };
7227
- err(bold("agent.libx.js") + cyan(" v" + VERSION) + dim(` \u2014 ${agent.options.model} \xB7 ${cwd}
7604
+ err(bold("agent.libx.js") + cyan(" v" + VERSION) + dim(` \u2014 ${work.model} \xB7 ${cwd}
7228
7605
  `));
7229
7606
  err(dim("Type a task, or /help. Type / or @ for live suggestions (\u2191/\u2193 \u23CE). Esc cancels/clears; double-Esc jumps back; Ctrl-D exits.\n"));
7607
+ if (dx) err(dim(`\u25D1 duplex \u2014 voice: ${dx.options.voiceModel} \xB7 worker: ${work.model} (real work runs in background tasks, re-voiced when done)
7608
+ `));
7230
7609
  const listDir = (absDir) => {
7231
7610
  try {
7232
7611
  return readdirSync2(join8(cwd, absDir.replace(/^\/+/, "")), { withFileTypes: true }).map((d) => ({ name: d.name, dir: d.isDirectory() }));
7233
7612
  } catch (e) {
7234
- log11.debug("completion readdir failed", absDir, e);
7613
+ log12.debug("completion readdir failed", absDir, e);
7235
7614
  return null;
7236
7615
  }
7237
7616
  };
@@ -7247,6 +7626,10 @@ ${extra}` : body, checkpoints, cwd);
7247
7626
  if (process.stdin.isTTY) {
7248
7627
  process.stdin.on("keypress", (_s, key) => {
7249
7628
  if (!activeTurn) return;
7629
+ if (key?.ctrl && key?.name === "o") {
7630
+ toggleVerbose();
7631
+ return;
7632
+ }
7250
7633
  const cancel = key?.name === "escape" || key?.ctrl && key?.name === "c";
7251
7634
  if (!cancel) return;
7252
7635
  if (!aborting) {
@@ -7272,6 +7655,7 @@ ${extra}` : body, checkpoints, cwd);
7272
7655
  process.stdin.pause();
7273
7656
  };
7274
7657
  let prefill;
7658
+ let tick = 0;
7275
7659
  while (true) {
7276
7660
  if (pendingRewind) {
7277
7661
  pendingRewind = false;
@@ -7282,16 +7666,21 @@ ${extra}` : body, checkpoints, cwd);
7282
7666
  err("\n");
7283
7667
  const initial = prefill;
7284
7668
  prefill = void 0;
7285
- const ctxTok = estimateTranscriptTokens(agent.transcript);
7286
- const ctxCap = agent.options.maxTokens || 2e5;
7669
+ const ctxTok = estimateTranscriptTokens(face.transcript);
7670
+ const ctxCap = face.options.maxTokens || 2e5;
7287
7671
  const usd = session.meta.costUsd ?? 0;
7288
7672
  const computeFooter = () => {
7289
7673
  const parts = [];
7290
7674
  if (ctxTok > 400) parts.push(`${Math.round(ctxTok / ctxCap * 100)}% ctx (~${(ctxTok / 1e3).toFixed(1)}k/${Math.round(ctxCap / 1e3)}k)`);
7291
7675
  if (usd > 0) parts.push(`${session.meta.costEstimated ? "~" : ""}${fmtUsd(usd)}`);
7292
7676
  if (posture !== "default") parts.push(postureLabel());
7293
- const r = agent.options.reasoning;
7677
+ const r = work.reasoning;
7294
7678
  if (r && r !== "off") parts.push(`reasoning:${r}`);
7679
+ if (verboseOutput) parts.push("verbose");
7680
+ if (dx?.tasks.size) {
7681
+ const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
7682
+ parts.push(`tasks: ${[...dx.tasks.values()].map((t) => t.status === "running" ? `${frames[tick++ % frames.length]} ${t.id} working\u2026` : `${t.id}:${t.status}`).join(" ")}`);
7683
+ }
7295
7684
  return parts.join(" \xB7 ");
7296
7685
  };
7297
7686
  const result = await readMultiline((cont) => editor.readLine({
@@ -7303,15 +7692,14 @@ ${extra}` : body, checkpoints, cwd);
7303
7692
  initial: cont ? void 0 : initial,
7304
7693
  status: computeFooter,
7305
7694
  vimMode: cfg.editorMode === "vim",
7695
+ statusTickMs: dx ? 1e3 : void 0,
7696
+ // duplex: animate the running-task footer while idle at the prompt
7306
7697
  onCyclePosture: cyclePosture,
7307
7698
  onToggleThinking: toggleReasoning,
7699
+ onToggleVerbose: toggleVerbose,
7308
7700
  onPickModel: async () => {
7309
- const picked = await pickModel(agent.options.model);
7310
- if (picked) {
7311
- agent.options.model = picked;
7312
- persistModel(cwd, picked);
7313
- err(dim(" model \u2192 " + picked + "\n"));
7314
- }
7701
+ const picked = await pickModel(work.model);
7702
+ if (picked) setModel(picked);
7315
7703
  return picked;
7316
7704
  }
7317
7705
  }));
@@ -7369,7 +7757,17 @@ ${extra}` : body, checkpoints, cwd);
7369
7757
  }
7370
7758
  const task = pendingImages.length ? `${line} ${pendingImages.map((p) => "@" + p).join(" ")}` : line;
7371
7759
  pendingImages.length = 0;
7372
- await runTurn(agent, store, session, task, checkpoints, cwd);
7760
+ await turn(task);
7761
+ }
7762
+ if (dx) {
7763
+ const running = [...dx.tasks.values()].filter((t) => t.status === "running").length;
7764
+ if (running) {
7765
+ err(dim(` \u2026 waiting for ${running} background task(s) (Ctrl-C to force quit)
7766
+ `));
7767
+ await dx.idle();
7768
+ face.options.host?.flushText?.();
7769
+ duplexPersist();
7770
+ }
7373
7771
  }
7374
7772
  releaseStdin();
7375
7773
  await closeMcp(mounted);