agent.libx.js 0.89.6 → 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(
@@ -3845,7 +4056,7 @@ async function buildAgent(o) {
3845
4056
  );
3846
4057
  let fs = jailedDisk;
3847
4058
  if (o.sandbox) {
3848
- const mem = new MemFilesystem2();
4059
+ const mem = new MemFilesystem3();
3849
4060
  await mkdirp(mem, cwd);
3850
4061
  await hydrate(jailedDisk, mem, cwd);
3851
4062
  mem.setCwd(cwd);
@@ -3904,10 +4115,11 @@ Reference files in them by their mount path (the left side).`;
3904
4115
  ai: o.ai,
3905
4116
  fs,
3906
4117
  model: o.model ?? "anthropic/claude-sonnet-4-6",
3907
- // Anchor cursor to the launch dir (its adapter defaults to TMPDIR otherwise). Gated to cursor:
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:
3908
4120
  // openai/google adapters Object.assign providerOptions into the request body, so a blanket cwd
3909
4121
  // would corrupt those calls.
3910
- ...isCursor ? { providerOptions: { cwd } } : {},
4122
+ ...isCursor ? { providerOptions: { cwd, ...toCursorMcp(o.mcpServers) ?? {} } } : {},
3911
4123
  ...(() => {
3912
4124
  const now = /* @__PURE__ */ new Date();
3913
4125
  const platformNames = { darwin: "macOS", linux: "Linux", win32: "Windows" };
@@ -3971,6 +4183,7 @@ function summarizeCall(name, args) {
3971
4183
  if (args.command) return String(args.command);
3972
4184
  if (args.pattern) return `/${args.pattern}/${args.glob ? " in " + args.glob : ""}`;
3973
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);
3974
4187
  return trunc(JSON.stringify(args), 60);
3975
4188
  }
3976
4189
  var trunc = (s, n) => (s == null ? "" : String(s).length > n ? String(s).slice(0, n) + "\u2026" : String(s)).replace(/\n/g, "\u23CE");
@@ -4033,7 +4246,7 @@ async function loadConfig(cwd) {
4033
4246
 
4034
4247
  // cli/hooks-config.ts
4035
4248
  import { spawnSync } from "child_process";
4036
- var log8 = forComponent("hooks");
4249
+ var log9 = forComponent("hooks");
4037
4250
  var escapeRegex = (s) => s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
4038
4251
  function ruleMatches(rule, toolName) {
4039
4252
  if (!rule.tool || rule.tool === "*") return true;
@@ -4050,7 +4263,7 @@ function runCmd(rule, env) {
4050
4263
  });
4051
4264
  return { code: r.status ?? 1, out: ((r.stdout ?? "") + (r.stderr ?? "")).trim() };
4052
4265
  } catch (e) {
4053
- log8.debug(`hook command failed: ${rule.command}`, e);
4266
+ log9.debug(`hook command failed: ${rule.command}`, e);
4054
4267
  return { code: 1, out: String(e?.message ?? e) };
4055
4268
  }
4056
4269
  }
@@ -4156,7 +4369,7 @@ function formatDiff(ops, opts = {}) {
4156
4369
  // cli/session.ts
4157
4370
  import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, readdirSync, renameSync } from "fs";
4158
4371
  import { join as join5 } from "path";
4159
- var log9 = forComponent("session");
4372
+ var log10 = forComponent("session");
4160
4373
  var SessionStore = class {
4161
4374
  dir;
4162
4375
  constructor(cwd) {
@@ -4182,7 +4395,7 @@ var SessionStore = class {
4182
4395
  }
4183
4396
  load(id) {
4184
4397
  if (!this.safeId(id)) {
4185
- log9.debug(`rejecting unsafe session id: ${id}`);
4398
+ log10.debug(`rejecting unsafe session id: ${id}`);
4186
4399
  return void 0;
4187
4400
  }
4188
4401
  const path = join5(this.dir, `${id}.json`);
@@ -4190,7 +4403,7 @@ var SessionStore = class {
4190
4403
  try {
4191
4404
  return JSON.parse(readFileSync3(path, "utf8"));
4192
4405
  } catch (e) {
4193
- log9.debug(`unreadable session ${id} \u2014 ignoring`, e);
4406
+ log10.debug(`unreadable session ${id} \u2014 ignoring`, e);
4194
4407
  return void 0;
4195
4408
  }
4196
4409
  }
@@ -4203,7 +4416,7 @@ var SessionStore = class {
4203
4416
  try {
4204
4417
  metas.push(JSON.parse(readFileSync3(join5(this.dir, f), "utf8")).meta);
4205
4418
  } catch (e) {
4206
- log9.debug(`skipping unreadable session file ${f}`, e);
4419
+ log10.debug(`skipping unreadable session file ${f}`, e);
4207
4420
  }
4208
4421
  }
4209
4422
  return metas.sort((a, b) => b.updated - a.updated);
@@ -4297,7 +4510,7 @@ import { execFile } from "child_process";
4297
4510
  import { promisify } from "util";
4298
4511
  import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, existsSync as existsSync5 } from "fs";
4299
4512
  import { join as join6, resolve as resolve2, sep as sep2 } from "path";
4300
- var log10 = forComponent("checkpoints");
4513
+ var log11 = forComponent("checkpoints");
4301
4514
  var exec = promisify(execFile);
4302
4515
  var DEFAULT_EXCLUDE = [".agent/", ".git/", "node_modules/", "dist/", "build/", ".next/", "target/", ".venv/", "__pycache__/", "*.log"];
4303
4516
  var ShadowRepo = class {
@@ -4331,7 +4544,7 @@ var ShadowRepo = class {
4331
4544
  writeFileSync4(join6(this.gitDir, "info", "exclude"), this.exclude.join("\n") + "\n");
4332
4545
  this.ready = true;
4333
4546
  } catch (e) {
4334
- log10.debug(`git checkpoints unavailable for ${this.workTree}`, e);
4547
+ log11.debug(`git checkpoints unavailable for ${this.workTree}`, e);
4335
4548
  this.ready = false;
4336
4549
  }
4337
4550
  return this.ready;
@@ -4394,7 +4607,7 @@ var ShadowRepo = class {
4394
4607
  await this.run("gc", "--auto", "-q").catch(() => {
4395
4608
  });
4396
4609
  } catch (e) {
4397
- log10.debug("checkpoint prune failed", e);
4610
+ log11.debug("checkpoint prune failed", e);
4398
4611
  }
4399
4612
  }
4400
4613
  };
@@ -4451,7 +4664,7 @@ var GitCheckpoints = class {
4451
4664
  use(sessionId) {
4452
4665
  if (sessionId === this.session) return;
4453
4666
  this.session = sessionId;
4454
- 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));
4455
4668
  }
4456
4669
  async begin(label) {
4457
4670
  if (!await this.start()) return;
@@ -4462,7 +4675,7 @@ var GitCheckpoints = class {
4462
4675
  try {
4463
4676
  await r.commit(msg);
4464
4677
  } catch (e) {
4465
- log10.debug("checkpoint commit failed", e);
4678
+ log11.debug("checkpoint commit failed", e);
4466
4679
  }
4467
4680
  }
4468
4681
  if (slow) clearTimeout(slow);
@@ -5411,6 +5624,9 @@ function createLineEditor(out) {
5411
5624
  process.on("SIGWINCH", onResize);
5412
5625
  return new Promise((resolve4) => {
5413
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;
5414
5630
  const onKey = (str, key) => {
5415
5631
  if (key?.ctrl && key.name === "l") {
5416
5632
  out.write("\x1B[2J\x1B[3J\x1B[H");
@@ -5428,6 +5644,11 @@ function createLineEditor(out) {
5428
5644
  redraw();
5429
5645
  return;
5430
5646
  }
5647
+ if (key?.ctrl && key.name === "o" && opts.onToggleVerbose) {
5648
+ opts.onToggleVerbose();
5649
+ redraw();
5650
+ return;
5651
+ }
5431
5652
  if (key?.meta && key.name === "p" && opts.onPickModel) {
5432
5653
  process.stdin.off("keypress", onKey);
5433
5654
  void opts.onPickModel().finally(() => {
@@ -5464,6 +5685,7 @@ function createLineEditor(out) {
5464
5685
  render(s, opts.prompt, maxVisible, opts.status);
5465
5686
  };
5466
5687
  const finish = () => {
5688
+ if (ticker) clearInterval(ticker);
5467
5689
  process.stdin.off("keypress", onKey);
5468
5690
  process.removeListener("SIGWINCH", onResize);
5469
5691
  out.write("\x1B[?2004l");
@@ -5776,7 +5998,7 @@ var red = C("31");
5776
5998
  var bold = C("1");
5777
5999
  var yellow = C("33");
5778
6000
  var err = (s) => process.stderr.write(s);
5779
- var log11 = forComponent("cli");
6001
+ var log12 = forComponent("cli");
5780
6002
  var VERSION = (() => {
5781
6003
  try {
5782
6004
  return JSON.parse(readFileSync5(new URL("../package.json", import.meta.url), "utf8")).version ?? "?";
@@ -5815,7 +6037,7 @@ function parseReasoning(raw) {
5815
6037
  throw new Error(`invalid --reasoning: ${raw} (use off|low|medium|high or a token budget)`);
5816
6038
  }
5817
6039
  function parseArgs(argv) {
5818
- 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 };
5819
6041
  const rest = [];
5820
6042
  const val = (i, flag) => {
5821
6043
  const v = argv[i];
@@ -5847,6 +6069,11 @@ function parseArgs(argv) {
5847
6069
  else if (x === "--shell") a.shell = true;
5848
6070
  else if (x === "--no-shell") a.shell = false;
5849
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);
5850
6077
  else if (x === "--allowedTools" || x === "--allowed-tools") a.allowedTools = val(++i, x).split(",").map((s) => s.trim()).filter(Boolean);
5851
6078
  else if (x === "--disallowedTools" || x === "--disallowed-tools") a.disallowedTools = val(++i, x).split(",").map((s) => s.trim()).filter(Boolean);
5852
6079
  else if (x === "--append-system-prompt") a.appendSystemPrompt = val(++i, x);
@@ -5866,6 +6093,9 @@ function parseArgs(argv) {
5866
6093
  if (!a.task && rest.length) a.task = rest.join(" ");
5867
6094
  if (a.boddb && a.vfs) throw new Error("--boddb and --sandbox are mutually exclusive (both are non-disk filesystems; pick one)");
5868
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");
5869
6099
  return a;
5870
6100
  }
5871
6101
  var HELP = `agentx \u2014 agent.libx.js CLI
@@ -5895,6 +6125,11 @@ Flags:
5895
6125
  --allowedTools <l> comma-list of tools to allow w/o asking, e.g. "Edit,Shell(git *)"
5896
6126
  --disallowedTools <l> comma-list of tools to deny outright (wins over allow), e.g. "Shell(rm *)"
5897
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)
5898
6133
  --add-dir <path> mount another directory into the workspace (repeatable; disk mode only)
5899
6134
  --subagents allow the Task tool (spawn child agents)
5900
6135
  --reasoning <e> extended thinking: off|low|medium|high or a token budget (anthropic/openai)
@@ -5927,7 +6162,7 @@ REPL shortcuts: !<cmd> runs a shell command inline \xB7 #<note> saves a memory \
5927
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
5928
6163
  REPL completion: type / (commands+skills) or @ (files) for a LIVE menu \u2014 \u2191/\u2193 select, \u23CE/Tab accept, Esc dismiss.
5929
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.
5930
- 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.
5931
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.
5932
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).`;
5933
6168
  function newestModel() {
@@ -6066,7 +6301,7 @@ function displayHooks(fs) {
6066
6301
  const text = String(result).replace(/\s+$/, "");
6067
6302
  if (text && !/^Edited|^Wrote|^Applied/.test(text)) {
6068
6303
  const lines = text.split("\n");
6069
- const shown = lines.slice(0, RESULT_PREVIEW_LINES);
6304
+ const shown = lines.slice(0, previewLines());
6070
6305
  for (const ln of shown) err(dim(` ${ln.length > 200 ? ln.slice(0, 200) + "\u2026" : ln}
6071
6306
  `));
6072
6307
  const more = lines.length - shown.length;
@@ -6194,6 +6429,14 @@ async function appendMemoryNote(fs, dir, text) {
6194
6429
  }
6195
6430
  var ASK_MUTATING = ["bash", "Shell", "Write", "Edit", "MultiEdit", "deleteFile"];
6196
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
+ };
6197
6440
  var canPrompt = !!(process.stdin.isTTY && process.stderr.isTTY);
6198
6441
  function resolvePermMode(args, interactiveCapable) {
6199
6442
  if (args.yes) return { gate: "allow" };
@@ -6274,7 +6517,10 @@ function optsFor(args, ai, cfg = {}, extraTools = []) {
6274
6517
  maxToolCalls: cfg.maxToolCalls,
6275
6518
  keepToolOutputs: cfg.keepToolOutputs,
6276
6519
  maxContextTokens: cfg.maxContextTokens,
6277
- 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
6278
6524
  };
6279
6525
  }
6280
6526
  async function makeAgent(args, ai, cfg, extraTools = []) {
@@ -6318,7 +6564,7 @@ async function mountMcp(cfg, oauth) {
6318
6564
  return mounted;
6319
6565
  }
6320
6566
  async function closeMcp(mounted) {
6321
- 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))));
6322
6568
  }
6323
6569
  var IMG_EXT = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
6324
6570
  function readImageParts(cwd, line) {
@@ -6398,7 +6644,7 @@ async function readMultiline(readLine) {
6398
6644
  return parts.join("\n");
6399
6645
  }
6400
6646
  }
6401
- async function runTurn(agent, store, session, task, cp, cwd = process.cwd()) {
6647
+ async function runTurn(agent, store, session, task, cp, cwd = process.cwd(), sendFn) {
6402
6648
  const t0 = Date.now();
6403
6649
  await cp?.begin(task);
6404
6650
  const { text, loaded, missing } = await expandMentions(agent.options.fs, task);
@@ -6418,9 +6664,9 @@ async function runTurn(agent, store, session, task, cp, cwd = process.cwd()) {
6418
6664
  agent.options.signal = ctrl.signal;
6419
6665
  const content = images.length ? [{ type: "text", text }, ...images] : text;
6420
6666
  let res;
6421
- spinner.start();
6667
+ spinner.start(sendFn ? "voice\u2026" : void 0);
6422
6668
  try {
6423
- res = await agent.send(content);
6669
+ res = await (sendFn ? sendFn(content) : agent.send(content));
6424
6670
  } catch (e) {
6425
6671
  spinner.stop();
6426
6672
  err(red(` error: ${e?.message ?? e}
@@ -6541,25 +6787,127 @@ function persistSetting(cwd, key, value) {
6541
6787
  }
6542
6788
  }
6543
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
+ }
6544
6814
  async function repl(args, ai, cfg, cwd) {
6545
6815
  const oauth = new McpOAuth({ storePath: join8(cwd, ".agent", "mcp-auth.json") });
6546
6816
  const mounted = await mountMcp(cfg, oauth);
6547
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;
6548
6894
  const baseRules = () => [
6549
6895
  ...parsePermRules({ deny: args.disallowedTools }),
6550
6896
  ...parsePermRules({ allow: args.allowedTools }),
6551
6897
  ...parsePermRules(mergePerms(loadPersistedRules(cwd), cfg.permissions))
6552
6898
  ];
6553
6899
  const askFor = (tools) => tools.map((t) => ({ tool: t, decision: "ask" }));
6554
- const POSTURES = ["default", "acceptEdits", "plan"];
6555
- 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";
6556
6902
  const postureLabel = () => posture === "default" ? "ask (default)" : posture === "acceptEdits" ? "accept edits" : "plan mode";
6557
6903
  const applyPosture = (p) => {
6558
6904
  posture = p;
6559
6905
  const ask = p === "acceptEdits" ? askFor(["bash", "Shell"]) : askFor(ASK_MUTATING);
6560
- agent.options.permissions = new PermissionPolicy({ rules: [...baseRules(), ...ask], default: "allow", host: makeHost(), ask: makeAskResolver(cwd) });
6561
- agent.options.planMode = p === "plan";
6562
- 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
+ }
6563
6911
  };
6564
6912
  const cyclePosture = () => {
6565
6913
  applyPosture(POSTURES[(POSTURES.indexOf(posture) + 1) % POSTURES.length]);
@@ -6570,13 +6918,27 @@ async function repl(args, ai, cfg, cwd) {
6570
6918
  if (!args.yes) applyPosture(posture);
6571
6919
  const REASONING_CYCLE = ["off", "low", "medium", "high"];
6572
6920
  const toggleReasoning = () => {
6573
- const cur = String(agent.options.reasoning ?? "off");
6921
+ const cur = String(work.reasoning ?? "off");
6574
6922
  const next = REASONING_CYCLE[(Math.max(0, REASONING_CYCLE.indexOf(cur)) + 1) % REASONING_CYCLE.length];
6575
- agent.options.reasoning = next;
6923
+ work.reasoning = next;
6576
6924
  err(dim(` ~ reasoning \u2192 ${next}
6577
6925
  `));
6578
6926
  return next;
6579
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
+ };
6580
6942
  const pendingImages = [];
6581
6943
  const grabClipboardAttachment = () => {
6582
6944
  const dir = join8(tmpdir(), "agentx-pasted");
@@ -6595,33 +6957,33 @@ async function repl(args, ai, cfg, cwd) {
6595
6957
  void closeMcp(mounted);
6596
6958
  process.exit(130);
6597
6959
  });
6598
- const isCancelTeardown = (e) => {
6599
- if (!e) return false;
6600
- if (e.code === 20 || e.cause?.code === 20) return true;
6601
- const blob = `${e.code ?? ""} ${e.name ?? ""} ${e.cause?.name ?? ""} ${e.rawMessage ?? ""} ${e.message ?? ""}`;
6602
- return /NGHTTP2_FRAME_SIZE_ERROR|ERR_HTTP2_STREAM_ERROR|operation was aborted|\bAbortError\b/i.test(blob);
6603
- };
6604
- process.on("unhandledRejection", (e) => {
6605
- if (isCancelTeardown(e)) {
6606
- log11.debug("suppressed unhandledRejection (cursor stream cancel)", e);
6607
- return;
6608
- }
6609
- log11.error("unhandledRejection", e);
6610
- });
6611
- process.on("uncaughtException", (e) => {
6612
- if (isCancelTeardown(e)) {
6613
- log11.debug("suppressed uncaughtException (cursor stream cancel)", e);
6614
- return;
6615
- }
6616
- console.error(e);
6617
- void closeMcp(mounted);
6618
- process.exit(1);
6619
- });
6960
+ installCancelGuards(mounted);
6620
6961
  const store = new SessionStore(cwd);
6621
- let session = startSession(args, store, agent, cwd);
6962
+ let session = startSession(args, store, face, cwd);
6622
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 });
6623
6964
  const cpHooks = checkpoints.hooks?.();
6624
- 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
+ };
6625
6987
  const fs = agent.options.fs;
6626
6988
  const fsBase = fs.getCwd() === "/" ? "" : fs.getCwd();
6627
6989
  const adot = (sub) => `${fsBase}/.agent/${sub}`;
@@ -6635,7 +6997,7 @@ async function repl(args, ai, cfg, cwd) {
6635
6997
  mkdirSync6(join8(cwd, ".agent"), { recursive: true });
6636
6998
  appendFileSync(histPath, line + "\n");
6637
6999
  } catch (e) {
6638
- log11.debug("history write failed", e);
7000
+ log12.debug("history write failed", e);
6639
7001
  }
6640
7002
  };
6641
7003
  const ago = (t) => {
@@ -6643,7 +7005,7 @@ async function repl(args, ai, cfg, cwd) {
6643
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`;
6644
7006
  };
6645
7007
  const resumeInto = (data) => {
6646
- agent.transcript = data.messages;
7008
+ face.transcript = data.messages;
6647
7009
  session = data;
6648
7010
  checkpoints.use?.(data.meta.id);
6649
7011
  err(dim(` resumed ${data.meta.id} (${data.meta.turns} turns)${data.meta.title ? " \u2014 " + data.meta.title : ""}
@@ -6651,7 +7013,7 @@ async function repl(args, ai, cfg, cwd) {
6651
7013
  printHistory(data.messages);
6652
7014
  };
6653
7015
  const rewindToMessage = async () => {
6654
- 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");
6655
7017
  if (!users.length) {
6656
7018
  err(dim(" (no earlier messages to jump back to)\n"));
6657
7019
  return void 0;
@@ -6667,7 +7029,7 @@ async function repl(args, ai, cfg, cwd) {
6667
7029
  const idx = users[p].i;
6668
7030
  const frame = p - (users.length - checkpoints.size);
6669
7031
  let mode = "convo";
6670
- if (frame >= 0 && frame < checkpoints.size) {
7032
+ if (!duplex && frame >= 0 && frame < checkpoints.size) {
6671
7033
  const m = await selectMenu(process.stderr, { title: "Restore\u2026", items: [
6672
7034
  { label: "Conversation and code", value: "both" },
6673
7035
  { label: "Conversation only", value: "convo" },
@@ -6676,7 +7038,7 @@ async function repl(args, ai, cfg, cwd) {
6676
7038
  if (m == null) return void 0;
6677
7039
  mode = m;
6678
7040
  }
6679
- 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();
6680
7042
  if (mode === "code" || mode === "both") {
6681
7043
  try {
6682
7044
  const { restored, deleted } = await checkpoints.rewindTo(frame);
@@ -6688,14 +7050,14 @@ async function repl(args, ai, cfg, cwd) {
6688
7050
  }
6689
7051
  }
6690
7052
  if (mode === "convo" || mode === "both") {
6691
- agent.transcript = agent.transcript.slice(0, idx);
6692
- session.messages = agent.transcript;
7053
+ face.transcript = face.transcript.slice(0, idx);
7054
+ session.messages = face.transcript;
6693
7055
  try {
6694
7056
  store.save(session);
6695
7057
  } catch (e) {
6696
- log11.debug("session save after rewind failed", e);
7058
+ log12.debug("session save after rewind failed", e);
6697
7059
  }
6698
- 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
6699
7061
  `));
6700
7062
  return text;
6701
7063
  }
@@ -6714,19 +7076,20 @@ async function repl(args, ai, cfg, cwd) {
6714
7076
  if (data) resumeInto(data);
6715
7077
  else err(red(" no such session\n"));
6716
7078
  };
7079
+ const turn = (task) => runTurn(face, store, session, task, duplex ? void 0 : checkpoints, cwd, sendVia);
6717
7080
  const runSkill = async (sk, extra = "") => {
6718
7081
  try {
6719
7082
  const body = await fs.readFile(sk.path);
6720
- await runTurn(agent, store, session, extra ? `${body}
7083
+ await turn(extra ? `${body}
6721
7084
 
6722
- ${extra}` : body, checkpoints, cwd);
7085
+ ${extra}` : body);
6723
7086
  } catch (e) {
6724
7087
  err(red(` couldn't load skill ${sk.name}: ${e?.message ?? e}
6725
7088
  `));
6726
7089
  }
6727
7090
  };
6728
7091
  const runCommand = async (c, extra = "") => {
6729
- await runTurn(agent, store, session, await expandCommand(fs, c, extra), checkpoints, cwd);
7092
+ await turn(await expandCommand(fs, c, extra));
6730
7093
  };
6731
7094
  const pickAndRun = async (kind) => {
6732
7095
  const pool = kind === "skill" ? skills : cmds;
@@ -6783,15 +7146,21 @@ ${extra}` : body, checkpoints, cwd);
6783
7146
  desc: "show CLI version + runtime",
6784
7147
  run: () => {
6785
7148
  const rt = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`;
6786
- 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}`)}
6787
7150
  `);
6788
7151
  }
6789
7152
  },
6790
- 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
+ },
6791
7160
  permissions: {
6792
7161
  desc: "show the active permission rules + default posture",
6793
7162
  run: () => {
6794
- const pol = agent.options.permissions;
7163
+ const pol = work.permissions;
6795
7164
  const rules = pol?.options.rules ?? [];
6796
7165
  if (!rules.length) err(dim(" (no rules \u2014 default: " + (pol?.options.default ?? "allow") + ")\n"));
6797
7166
  else {
@@ -6805,19 +7174,22 @@ ${extra}` : body, checkpoints, cwd);
6805
7174
  desc: "session status \u2014 model, dir, fs-mode, permissions, tools, usage",
6806
7175
  run: () => {
6807
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)";
6808
- const pol = agent.options.permissions;
7177
+ const pol = work.permissions;
6809
7178
  const perm = !pol ? "allow all (unattended)" : `${pol.options.rules.length} rule(s), default ${pol.options.default}`;
6810
- 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
+ `));
6811
7183
  }
6812
7184
  },
6813
7185
  cost: {
6814
7186
  desc: "cumulative cost + token usage for this session",
6815
7187
  run: () => {
6816
7188
  const t = session.meta.tokens ?? 0, usd = session.meta.costUsd ?? 0;
6817
- const priced = getModelInfo(agent.options.model)?.pricing;
7189
+ const priced = getModelInfo(work.model)?.pricing;
6818
7190
  const est = session.meta.costEstimated ?? false;
6819
7191
  const m = est ? "~" : "";
6820
- 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})`;
6821
7193
  err(dim(` ${usd > 0 ? m + fmtUsd(usd) + " \xB7 " : ""}${m}${(t / 1e3).toFixed(1)}k tokens across ${session.meta.turns} turn(s)${note}
6822
7194
  `));
6823
7195
  }
@@ -6825,9 +7197,9 @@ ${extra}` : body, checkpoints, cwd);
6825
7197
  context: {
6826
7198
  desc: "context-window usage (messages + estimated tokens)",
6827
7199
  run: () => {
6828
- const est = estimateTranscriptTokens(agent.transcript);
6829
- const cap = agent.options.maxTokens || 2e5;
6830
- 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)
6831
7203
  `));
6832
7204
  }
6833
7205
  },
@@ -6858,26 +7230,21 @@ ${extra}` : body, checkpoints, cwd);
6858
7230
  }
6859
7231
  },
6860
7232
  model: {
6861
- 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)",
6862
7234
  run: async (a) => {
6863
7235
  if (a[0]) {
6864
- agent.options.model = a[0];
6865
- persistModel(cwd, a[0]);
6866
- err(dim(" model \u2192 " + a[0] + "\n"));
7236
+ setModel(a[0]);
6867
7237
  return;
6868
7238
  }
6869
- const picked = await pickModel(agent.options.model);
6870
- if (picked) {
6871
- agent.options.model = picked;
6872
- persistModel(cwd, picked);
6873
- err(dim(" model \u2192 " + picked + "\n"));
6874
- } 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"));
6875
7242
  }
6876
7243
  },
6877
7244
  reasoning: {
6878
- 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')",
6879
7246
  run: async (a) => {
6880
- const current = String(agent.options.reasoning ?? "off");
7247
+ const current = String(work.reasoning ?? "off");
6881
7248
  let next;
6882
7249
  if (a[0]) {
6883
7250
  try {
@@ -6900,9 +7267,9 @@ ${extra}` : body, checkpoints, cwd);
6900
7267
  }
6901
7268
  next = picked;
6902
7269
  }
6903
- agent.options.reasoning = next;
6904
- if (next !== "off" && getModelInfo(agent.options.model)?.reasoning === false)
6905
- 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
6906
7273
  `));
6907
7274
  err(dim(" reasoning \u2192 " + next + "\n"));
6908
7275
  }
@@ -6912,23 +7279,21 @@ ${extra}` : body, checkpoints, cwd);
6912
7279
  run: async () => {
6913
7280
  for (; ; ) {
6914
7281
  const items = [
6915
- { label: "model", value: "model", desc: agent.options.model },
6916
- { 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") },
6917
7284
  { label: "permission posture", value: "posture", desc: postureLabel() + " (Shift+Tab)" },
6918
- { 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" }],
6919
7287
  { label: "editor mode", value: "editor", desc: cfg.editorMode === "vim" ? "vim" : "normal" }
6920
7288
  ];
6921
7289
  const pick = await selectMenu(process.stderr, { title: "Settings \xB7 \u21B5 change \xB7 esc close", items });
6922
7290
  if (!pick) return;
6923
7291
  if (pick === "model") {
6924
- const m = await pickModel(agent.options.model);
6925
- if (m) {
6926
- agent.options.model = m;
6927
- persistModel(cwd, m);
6928
- }
7292
+ const m = await pickModel(work.model);
7293
+ if (m) setModel(m);
6929
7294
  } else if (pick === "reasoning") {
6930
7295
  await builtins.reasoning.run([]);
6931
- persistSetting(cwd, "reasoning", agent.options.reasoning ?? "off");
7296
+ persistSetting(cwd, "reasoning", work.reasoning ?? "off");
6932
7297
  } else if (pick === "posture") {
6933
7298
  cyclePosture();
6934
7299
  persistSetting(cwd, "permissionMode", posture);
@@ -6963,8 +7328,8 @@ ${extra}` : body, checkpoints, cwd);
6963
7328
  compact: {
6964
7329
  desc: "summarize older context to free up the window",
6965
7330
  run: () => {
6966
- const n = agent.compactNow();
6967
- session.messages = agent.transcript;
7331
+ const n = face.compactNow();
7332
+ session.messages = face.transcript;
6968
7333
  try {
6969
7334
  store.save(session);
6970
7335
  } catch {
@@ -7011,8 +7376,8 @@ ${extra}` : body, checkpoints, cwd);
7011
7376
  clear: {
7012
7377
  desc: "start a fresh conversation (and clear the screen)",
7013
7378
  run: () => {
7014
- agent.transcript = [];
7015
- 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);
7016
7381
  err("\x1Bc");
7017
7382
  }
7018
7383
  },
@@ -7050,13 +7415,13 @@ ${extra}` : body, checkpoints, cwd);
7050
7415
  err(green(` \u2713 authorized "${name}"`) + dim(" \u2014 remounting with the new token\n"));
7051
7416
  const idx = mounted.findIndex((m2) => m2.name === name);
7052
7417
  if (idx >= 0) {
7053
- agent.removeTools(mounted[idx].tools.map((t) => t.name));
7418
+ removeWorkTools(mounted[idx].tools.map((t) => t.name));
7054
7419
  await mounted.splice(idx, 1)[0].client.close().catch(() => {
7055
7420
  });
7056
7421
  }
7057
7422
  const m = await mountMcpServer(name, { ...target, bearerToken: await oauth.tokenFor(target.url) });
7058
7423
  mounted.push(m);
7059
- agent.addTools(m.tools);
7424
+ addWorkTools(m.tools);
7060
7425
  err(green(` \u2713 ${m.name}`) + dim(` \u2014 ${m.tools.length} tool(s)
7061
7426
  `));
7062
7427
  } catch (e) {
@@ -7081,7 +7446,7 @@ ${extra}` : body, checkpoints, cwd);
7081
7446
  try {
7082
7447
  const m = await mountMcpServer(name, cfg2);
7083
7448
  mounted.push(m);
7084
- agent.addTools(m.tools);
7449
+ addWorkTools(m.tools);
7085
7450
  err(green(` \u2713 ${m.name}`) + dim(` \u2014 ${m.tools.length} tool(s)${m.serverInfo?.name ? ` from ${m.serverInfo.name}` : ""}
7086
7451
  `));
7087
7452
  } catch (e) {
@@ -7103,8 +7468,8 @@ ${extra}` : body, checkpoints, cwd);
7103
7468
  return;
7104
7469
  }
7105
7470
  const m = mounted.splice(idx, 1)[0];
7106
- agent.removeTools(m.tools.map((t) => t.name));
7107
- 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));
7108
7473
  err(dim(` removed "${name}"
7109
7474
  `));
7110
7475
  return;
@@ -7203,7 +7568,7 @@ ${extra}` : body, checkpoints, cwd);
7203
7568
  return;
7204
7569
  }
7205
7570
  const msg = a.join(" ").trim();
7206
- if (msg) await runTurn(agent, store, session, `${msg} ${att.ref}`, checkpoints, cwd);
7571
+ if (msg) await turn(`${msg} ${att.ref}`);
7207
7572
  else {
7208
7573
  pendingImages.push(att.path);
7209
7574
  err(green(` \u2713 image attached (#${pendingImages.length})`) + dim(" \u2014 type your message to send it\n"));
@@ -7213,7 +7578,7 @@ ${extra}` : body, checkpoints, cwd);
7213
7578
  export: {
7214
7579
  desc: "save this conversation to Markdown \u2014 /export [path] (default ./.agent/exports/<id>.md)",
7215
7580
  run: (a) => {
7216
- const shown = agent.transcript.filter((m) => m.role !== "system");
7581
+ const shown = face.transcript.filter((m) => m.role !== "system");
7217
7582
  if (!shown.length) {
7218
7583
  err(dim(" (nothing to export yet)\n"));
7219
7584
  return;
@@ -7236,14 +7601,16 @@ ${extra}` : body, checkpoints, cwd);
7236
7601
  exit: { desc: "quit", run: () => true },
7237
7602
  quit: { desc: "quit", run: () => true }
7238
7603
  };
7239
- 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}
7240
7605
  `));
7241
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
+ `));
7242
7609
  const listDir = (absDir) => {
7243
7610
  try {
7244
7611
  return readdirSync2(join8(cwd, absDir.replace(/^\/+/, "")), { withFileTypes: true }).map((d) => ({ name: d.name, dir: d.isDirectory() }));
7245
7612
  } catch (e) {
7246
- log11.debug("completion readdir failed", absDir, e);
7613
+ log12.debug("completion readdir failed", absDir, e);
7247
7614
  return null;
7248
7615
  }
7249
7616
  };
@@ -7259,6 +7626,10 @@ ${extra}` : body, checkpoints, cwd);
7259
7626
  if (process.stdin.isTTY) {
7260
7627
  process.stdin.on("keypress", (_s, key) => {
7261
7628
  if (!activeTurn) return;
7629
+ if (key?.ctrl && key?.name === "o") {
7630
+ toggleVerbose();
7631
+ return;
7632
+ }
7262
7633
  const cancel = key?.name === "escape" || key?.ctrl && key?.name === "c";
7263
7634
  if (!cancel) return;
7264
7635
  if (!aborting) {
@@ -7284,6 +7655,7 @@ ${extra}` : body, checkpoints, cwd);
7284
7655
  process.stdin.pause();
7285
7656
  };
7286
7657
  let prefill;
7658
+ let tick = 0;
7287
7659
  while (true) {
7288
7660
  if (pendingRewind) {
7289
7661
  pendingRewind = false;
@@ -7294,16 +7666,21 @@ ${extra}` : body, checkpoints, cwd);
7294
7666
  err("\n");
7295
7667
  const initial = prefill;
7296
7668
  prefill = void 0;
7297
- const ctxTok = estimateTranscriptTokens(agent.transcript);
7298
- const ctxCap = agent.options.maxTokens || 2e5;
7669
+ const ctxTok = estimateTranscriptTokens(face.transcript);
7670
+ const ctxCap = face.options.maxTokens || 2e5;
7299
7671
  const usd = session.meta.costUsd ?? 0;
7300
7672
  const computeFooter = () => {
7301
7673
  const parts = [];
7302
7674
  if (ctxTok > 400) parts.push(`${Math.round(ctxTok / ctxCap * 100)}% ctx (~${(ctxTok / 1e3).toFixed(1)}k/${Math.round(ctxCap / 1e3)}k)`);
7303
7675
  if (usd > 0) parts.push(`${session.meta.costEstimated ? "~" : ""}${fmtUsd(usd)}`);
7304
7676
  if (posture !== "default") parts.push(postureLabel());
7305
- const r = agent.options.reasoning;
7677
+ const r = work.reasoning;
7306
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
+ }
7307
7684
  return parts.join(" \xB7 ");
7308
7685
  };
7309
7686
  const result = await readMultiline((cont) => editor.readLine({
@@ -7315,15 +7692,14 @@ ${extra}` : body, checkpoints, cwd);
7315
7692
  initial: cont ? void 0 : initial,
7316
7693
  status: computeFooter,
7317
7694
  vimMode: cfg.editorMode === "vim",
7695
+ statusTickMs: dx ? 1e3 : void 0,
7696
+ // duplex: animate the running-task footer while idle at the prompt
7318
7697
  onCyclePosture: cyclePosture,
7319
7698
  onToggleThinking: toggleReasoning,
7699
+ onToggleVerbose: toggleVerbose,
7320
7700
  onPickModel: async () => {
7321
- const picked = await pickModel(agent.options.model);
7322
- if (picked) {
7323
- agent.options.model = picked;
7324
- persistModel(cwd, picked);
7325
- err(dim(" model \u2192 " + picked + "\n"));
7326
- }
7701
+ const picked = await pickModel(work.model);
7702
+ if (picked) setModel(picked);
7327
7703
  return picked;
7328
7704
  }
7329
7705
  }));
@@ -7381,7 +7757,17 @@ ${extra}` : body, checkpoints, cwd);
7381
7757
  }
7382
7758
  const task = pendingImages.length ? `${line} ${pendingImages.map((p) => "@" + p).join(" ")}` : line;
7383
7759
  pendingImages.length = 0;
7384
- 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
+ }
7385
7771
  }
7386
7772
  releaseStdin();
7387
7773
  await closeMcp(mounted);