@livx.cc/agentx 0.99.84 → 0.99.86

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
@@ -709,16 +709,16 @@ ${entries.map((s) => " " + s).join("\n")}`);
709
709
  return blocks.length ? blocks.join("\n") : `(no ${label} found)`;
710
710
  }
711
711
  function fuzzyLineReplace(content, oldStr, newStr) {
712
- const norm2 = (s) => s.trim();
712
+ const norm3 = (s) => s.trim();
713
713
  const cl = content.split("\n");
714
- const ol = oldStr.split("\n").map(norm2);
714
+ const ol = oldStr.split("\n").map(norm3);
715
715
  while (ol.length && ol[ol.length - 1] === "") ol.pop();
716
716
  while (ol.length && ol[0] === "") ol.shift();
717
717
  if (!ol.length) return null;
718
718
  const matches = [];
719
719
  for (let i2 = 0; i2 + ol.length <= cl.length; i2++) {
720
720
  let ok = true;
721
- for (let j = 0; j < ol.length; j++) if (norm2(cl[i2 + j]) !== ol[j]) {
721
+ for (let j = 0; j < ol.length; j++) if (norm3(cl[i2 + j]) !== ol[j]) {
722
722
  ok = false;
723
723
  break;
724
724
  }
@@ -1000,6 +1000,34 @@ var init_todo = __esm({
1000
1000
  }
1001
1001
  });
1002
1002
 
1003
+ // src/models.ts
1004
+ import { claudeModelAliases } from "ai.libx.js";
1005
+ function latestClaude(family, provider = "anthropic") {
1006
+ const id = MODEL_ALIASES[family];
1007
+ if (!id) throw new Error(`ai.libx.js catalog has no Claude ${family} model`);
1008
+ return `${provider}/${id}`;
1009
+ }
1010
+ function resolveModelAlias(input) {
1011
+ const slash = input.indexOf("/");
1012
+ const prefix = slash === -1 ? "" : input.slice(0, slash + 1);
1013
+ const rest = slash === -1 ? input : input.slice(slash + 1);
1014
+ if (prefix === "claude-code/") return input;
1015
+ return prefix + (MODEL_ALIASES[rest.toLowerCase()] ?? rest);
1016
+ }
1017
+ function modelProvider(model) {
1018
+ const i = model.indexOf("/");
1019
+ if (i !== -1) return model.slice(0, i);
1020
+ if (/^claude-/i.test(model)) return "anthropic";
1021
+ return `?${model}`;
1022
+ }
1023
+ var MODEL_ALIASES;
1024
+ var init_models = __esm({
1025
+ "src/models.ts"() {
1026
+ "use strict";
1027
+ MODEL_ALIASES = { ...claudeModelAliases };
1028
+ }
1029
+ });
1030
+
1003
1031
  // src/tools.web.ts
1004
1032
  function htmlToText(html) {
1005
1033
  let s = html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<title[\s\S]*?<\/title>/gi, " ").replace(/<noscript[\s\S]*?<\/noscript>/gi, " ").replace(/<textarea[\s\S]*?<\/textarea>/gi, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(/<\/(p|div|li|h[1-6]|tr|section|article|header|footer|nav)>/gi, "\n").replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ");
@@ -1221,7 +1249,7 @@ function makeWebSearchTool(options = {}) {
1221
1249
  if (provider === "anthropic") {
1222
1250
  const akey = options.anthropicApiKey ?? process.env.ANTHROPIC_API_KEY;
1223
1251
  if (!akey) return "Error: WebSearchAnthropic requires ANTHROPIC_API_KEY (set in env)";
1224
- return await anthropicSearch(q2, { key: akey, model: options.model ?? "claude-haiku-4-5", fetch: doFetch, signal: ctl.signal });
1252
+ return await anthropicSearch(q2, { key: akey, model: options.model ?? latestClaude("haiku").slice("anthropic/".length), fetch: doFetch, signal: ctl.signal });
1225
1253
  }
1226
1254
  if (useFirecrawl) {
1227
1255
  if (!fcKey) return "Error: Firecrawl provider selected but FIRECRAWL_API_KEY is not set";
@@ -1263,6 +1291,7 @@ var init_tools_web = __esm({
1263
1291
  "src/tools.web.ts"() {
1264
1292
  "use strict";
1265
1293
  init_logging();
1294
+ init_models();
1266
1295
  log4 = forComponent("web");
1267
1296
  webFetchTool = makeWebFetchTool();
1268
1297
  webSearchTool = makeWebSearchTool();
@@ -3983,36 +4012,10 @@ ${sections.join("\n\n---\n\n")}`;
3983
4012
 
3984
4013
  // src/subagent.ts
3985
4014
  init_tools();
4015
+ init_models();
3986
4016
  import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
3987
4017
  import { join as join4 } from "path";
3988
4018
 
3989
- // src/models.ts
3990
- var MODEL_ALIASES = {
3991
- fable: "claude-fable-5",
3992
- "fable-5": "claude-fable-5",
3993
- opus: "claude-opus-5-5",
3994
- "opus-5-5": "claude-opus-5-5",
3995
- "opus-5": "claude-opus-5",
3996
- "opus-4-8": "claude-opus-4-8",
3997
- "opus-4-7": "claude-opus-4-7",
3998
- "opus-4-6": "claude-opus-4-6",
3999
- sonnet: "claude-sonnet-4-6",
4000
- haiku: "claude-haiku-4-5-20251001"
4001
- };
4002
- function resolveModelAlias(input) {
4003
- const slash = input.indexOf("/");
4004
- const prefix = slash === -1 ? "" : input.slice(0, slash + 1);
4005
- const rest = slash === -1 ? input : input.slice(slash + 1);
4006
- if (prefix === "claude-code/") return input;
4007
- return prefix + (MODEL_ALIASES[rest.toLowerCase()] ?? rest);
4008
- }
4009
- function modelProvider(model) {
4010
- const i = model.indexOf("/");
4011
- if (i !== -1) return model.slice(0, i);
4012
- if (/^claude-/i.test(model)) return "anthropic";
4013
- return `?${model}`;
4014
- }
4015
-
4016
4019
  // src/permissions.ts
4017
4020
  init_tools_structured();
4018
4021
  function commandMatches(glob, cmd) {
@@ -4500,6 +4503,9 @@ ${r.error ? `ERROR: ${r.error}` : r.text || "(no summary)"}${extra}`);
4500
4503
  };
4501
4504
  }
4502
4505
 
4506
+ // src/Agent.ts
4507
+ init_models();
4508
+
4503
4509
  // src/agents.ts
4504
4510
  function parseAgentFrontmatter(md) {
4505
4511
  const m = md.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
@@ -4635,7 +4641,7 @@ var AgentOptions = class {
4635
4641
  * browser build (which always passes its own fs) never pulls in `node:fs`. Pass `fs` explicitly to
4636
4642
  * use any other backend (and to keep full control of the jail). */
4637
4643
  fs;
4638
- model = "anthropic/claude-sonnet-4-6";
4644
+ model = latestClaude("sonnet");
4639
4645
  systemPrompt = "You are a coding agent operating on a virtual filesystem. Use the `bash` tool to explore (ls, grep, find, cat) and the `Read`/`Edit` tools to view and modify files. Always Read a file before Editing it \u2014 and before overwriting it with `Write` or a whole-file `ApplyEdits`: Write replaces the ENTIRE file, so blindly writing over an existing one destroys its unseen contents. If a file already exists, Read it first, then merge your changes (or Edit the specific part). When multiple tool calls are independent (e.g. several Reads or Greps with no dependency between them), issue them together in a single turn rather than one at a time. When the task is complete, reply with a short summary and make no further tool calls.\nRecovery: every turn's file changes are checkpointed. If the user asks to undo/revert/recover edits (including a file you overwrote), tell them to run `/rewind` (or `/undo` for the last turn) to restore the working tree \u2014 checkpoints cover files you edited even in .gitignored dirs. You cannot run `/rewind` yourself; surface it as the recovery path.\nWhen working with a knowledge base or document workspace: use `RepoMap` with scope \"docs\" to see all document headings and summaries (orient first); use `MemorySearch` to find memories by content when you don't know the exact slug; use `Recall` with multiple slugs to batch-load related facts in one call. Prefer targeted loads over reading everything \u2014 orient \u2192 search \u2192 load \u2192 act.";
4640
4646
  tools = defaultTools();
4641
4647
  /** claude-code/* only: which agentx tools reach it as the in-process `agentx` MCP server (ccBridge.ts). 'memory' =
@@ -6040,14 +6046,14 @@ function summarize(messages, focus) {
6040
6046
  if (lastAssistant) lines.push(`last assistant: ${lastAssistant}`);
6041
6047
  if (carried.length) lines.push("earlier recap:", ...carried.map((l) => ` ${l}`));
6042
6048
  if (focus?.trim()) {
6043
- const words2 = focus.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
6049
+ const words3 = focus.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
6044
6050
  const kept = [];
6045
6051
  const ordered = [...messages.filter((m) => !isSyntheticSummary(m)), ...messages.filter(isSyntheticSummary)];
6046
6052
  outer: for (const msg of ordered) {
6047
6053
  if (msg.role === "tool") continue;
6048
6054
  for (const line of contentText(msg.content).split("\n")) {
6049
6055
  const l = line.toLowerCase();
6050
- if (line.trim() && words2.some((w) => l.includes(w))) {
6056
+ if (line.trim() && words3.some((w) => l.includes(w))) {
6051
6057
  kept.push(line.trim().slice(0, 200));
6052
6058
  if (kept.length >= 12) break outer;
6053
6059
  }
@@ -6070,6 +6076,7 @@ function toolKey(name) {
6070
6076
  }
6071
6077
 
6072
6078
  // src/index.ts
6079
+ init_models();
6073
6080
  init_NodeDiskFilesystem();
6074
6081
 
6075
6082
  // src/BodDbFilesystem.ts
@@ -6778,6 +6785,7 @@ function digestRun(messages, maxChars) {
6778
6785
  import { MemFilesystem as MemFilesystem2 } from "@livx.cc/wcli/core";
6779
6786
  init_llm();
6780
6787
  init_logging();
6788
+ init_models();
6781
6789
 
6782
6790
  // node_modules/@bod.ee/voice/src/core/logging.ts
6783
6791
  function consoleForComponent(name) {
@@ -7093,13 +7101,13 @@ function isTranscriptComplete(text) {
7093
7101
  const trimmed = text.trim();
7094
7102
  if (!trimmed) return false;
7095
7103
  if (/[.?!]$/.test(trimmed)) return true;
7096
- const words2 = trimmed.toLowerCase().split(/\s+/);
7097
- const lastWord = words2[words2.length - 1];
7104
+ const words3 = trimmed.toLowerCase().split(/\s+/);
7105
+ const lastWord = words3[words3.length - 1];
7098
7106
  if (COMPLETE_ENDINGS.has(lastWord)) return true;
7099
7107
  if (INCOMPLETE_ENDINGS.has(lastWord)) return false;
7100
7108
  const lower = trimmed.toLowerCase();
7101
7109
  for (const marker of CONTINUATION_MARKERS) if (lower.endsWith(marker)) return false;
7102
- if (words2.length <= 2) return false;
7110
+ if (words3.length <= 2) return false;
7103
7111
  return true;
7104
7112
  }
7105
7113
  var MAX_HOLD_MS = 2e3;
@@ -7500,13 +7508,13 @@ var VoiceEngine = class _VoiceEngine {
7500
7508
  };
7501
7509
  if (this.options.revealMode === "word" || this.options.heardTimestamps) {
7502
7510
  this.tts.wantTimestamps = true;
7503
- this.tts.onTimestamps = (words2, start) => {
7511
+ this.tts.onTimestamps = (words3, start) => {
7504
7512
  if (this.interrupted) return;
7505
7513
  start.forEach((s, i) => {
7506
- if (this.wordAnchorPending) this.wordPending.push([words2[i] ?? "", s]);
7514
+ if (this.wordAnchorPending) this.wordPending.push([words3[i] ?? "", s]);
7507
7515
  else {
7508
7516
  this.wordStarts.push(this.wordOrigin + s);
7509
- this.wordTexts.push(words2[i] ?? "");
7517
+ this.wordTexts.push(words3[i] ?? "");
7510
7518
  }
7511
7519
  });
7512
7520
  };
@@ -7654,9 +7662,9 @@ var VoiceEngine = class _VoiceEngine {
7654
7662
  this.ackProbe = null;
7655
7663
  this.adaptiveAckFired = false;
7656
7664
  const lead = m ? m[1] : bare;
7657
- const norm2 = lead.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
7665
+ const norm3 = lead.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
7658
7666
  const same = this.ackPickedFired ? bare.match(_VoiceEngine.leadingPhrase(this.lastAck)) : null;
7659
- if (_VoiceEngine.ACK_LEADING.test(norm2)) {
7667
+ if (_VoiceEngine.ACK_LEADING.test(norm3)) {
7660
7668
  const rest = m ? m[2] : "";
7661
7669
  this.diag("reflex_ack_suppressed", { lead: lead.trim() });
7662
7670
  if (!rest.trim()) return "";
@@ -7742,8 +7750,8 @@ var VoiceEngine = class _VoiceEngine {
7742
7750
  while (n < this.wordStarts.length && this.wordStarts[n] <= playedSec) n++;
7743
7751
  if (n !== this.revealedN) {
7744
7752
  this.revealedN = n;
7745
- const words2 = this.revealText.trim().split(/\s+/).filter(Boolean);
7746
- this.options.onReveal(words2.slice(0, n).join(" "));
7753
+ const words3 = this.revealText.trim().split(/\s+/).filter(Boolean);
7754
+ this.options.onReveal(words3.slice(0, n).join(" "));
7747
7755
  }
7748
7756
  this.revealPoll = this.clock.setTimeout(tick, 60);
7749
7757
  };
@@ -7770,8 +7778,8 @@ var VoiceEngine = class _VoiceEngine {
7770
7778
  this.adaptiveAckFired = false;
7771
7779
  this.ackProbe = null;
7772
7780
  if (heldProbe) {
7773
- const norm2 = heldProbe.replace(/\[emotion[^\]]*\]/gi, "").replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
7774
- if (_VoiceEngine.ACK_LEADING.test(norm2)) this.diag("reflex_ack_suppressed", { lead: heldProbe.trim() });
7781
+ const norm3 = heldProbe.replace(/\[emotion[^\]]*\]/gi, "").replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
7782
+ if (_VoiceEngine.ACK_LEADING.test(norm3)) this.diag("reflex_ack_suppressed", { lead: heldProbe.trim() });
7775
7783
  else this.speakDelta(heldProbe);
7776
7784
  }
7777
7785
  if (!this.speaking) return;
@@ -8309,14 +8317,14 @@ var VoiceEngine = class _VoiceEngine {
8309
8317
  if (!prev) return next;
8310
8318
  if (!next) return prev;
8311
8319
  const pw = prev.split(/\s+/), nw = next.split(/\s+/);
8312
- const norm2 = _VoiceEngine.normWord;
8320
+ const norm3 = _VoiceEngine.normWord;
8313
8321
  if (pw.length >= 2 && pw.length <= nw.length) {
8314
8322
  let pfx = true;
8315
- for (let i = 0; i < pw.length - 1; i++) if (norm2(pw[i]) !== norm2(nw[i])) {
8323
+ for (let i = 0; i < pw.length - 1; i++) if (norm3(pw[i]) !== norm3(nw[i])) {
8316
8324
  pfx = false;
8317
8325
  break;
8318
8326
  }
8319
- if (pfx && norm2(nw[pw.length - 1]).startsWith(norm2(pw[pw.length - 1]))) return next;
8327
+ if (pfx && norm3(nw[pw.length - 1]).startsWith(norm3(pw[pw.length - 1]))) return next;
8320
8328
  }
8321
8329
  if (_VoiceEngine.restates(prev, next)) {
8322
8330
  this.diag("merge_restated", { dropped: prev.slice(0, 60) });
@@ -8325,7 +8333,7 @@ var VoiceEngine = class _VoiceEngine {
8325
8333
  const max = Math.min(pw.length, nw.length);
8326
8334
  for (let k = max; k > 0; k--) {
8327
8335
  let match = true;
8328
- for (let i = 0; i < k; i++) if (norm2(pw[pw.length - k + i]) !== norm2(nw[i])) {
8336
+ for (let i = 0; i < k; i++) if (norm3(pw[pw.length - k + i]) !== norm3(nw[i])) {
8329
8337
  match = false;
8330
8338
  break;
8331
8339
  }
@@ -8377,11 +8385,11 @@ var VoiceEngine = class _VoiceEngine {
8377
8385
  * race), never worse. Fillers ("mhm") deliberately don't count as replies — see speakFiller. */
8378
8386
  refinalizes(next) {
8379
8387
  const pw = this.lastDispatchWords, nw = next.trim().split(/\s+/);
8380
- const norm2 = _VoiceEngine.normWord;
8388
+ const norm3 = _VoiceEngine.normWord;
8381
8389
  if (pw.length < 3 || nw.length < pw.length) return false;
8382
8390
  if (this.clock.now() - this.lastDispatchAt >= _VoiceEngine.DUP_FINAL_MS) return false;
8383
- for (let i = 0; i < pw.length - 1; i++) if (norm2(pw[i]) !== norm2(nw[i])) return false;
8384
- const pLast = norm2(pw[pw.length - 1]), nSame = norm2(nw[pw.length - 1]);
8391
+ for (let i = 0; i < pw.length - 1; i++) if (norm3(pw[i]) !== norm3(nw[i])) return false;
8392
+ const pLast = norm3(pw[pw.length - 1]), nSame = norm3(nw[pw.length - 1]);
8385
8393
  if (!nSame.startsWith(pLast)) return false;
8386
8394
  return nw.length > pw.length || nSame.length > pLast.length;
8387
8395
  }
@@ -9277,6 +9285,17 @@ var ACK_BANK = {
9277
9285
  steer: ["Ah, okay, switching\u2014", "Ah, got it, so\u2014", "Oh, gotcha, sure\u2014"],
9278
9286
  resume: ["Right, so\u2014", "Anyway, so\u2014", "So, as I was saying\u2014"]
9279
9287
  };
9288
+ var CHANNEL_ACTIONS = {
9289
+ silence: 'the default \u2014 nothing to add. ALWAYS for a greeting, thanks, a yes/no, or any short complete turn the real reply will answer instantly ("hey", "how are you?", "thanks"); also mid-word or mid-clause',
9290
+ backchannel: 'they are mid-way through a longer story or explanation (several clauses) and just closed a clause: a tiny "mm-hm"/"right" shows you follow, without taking the turn',
9291
+ filler: 'they paused mid-thought, searching for words, and a soft "hmm" keeps the line warm',
9292
+ bridge: 'they finished a substantial request or hard question and the real answer takes a moment: a short "let me check\u2014" lead-in'
9293
+ };
9294
+ var CHANNEL_BANK = {
9295
+ backchannel: ["Mm-hm.", "Right.", "Mm, yeah.", "Uh-huh.", "I see.", "Mm."],
9296
+ filler: ["Hmm\u2026", "Mm\u2026", "Hm, mm\u2026", "Ahh\u2026"],
9297
+ bridge: ["Let me check\u2014", "One sec, looking\u2014", "Hm, let me see\u2014", "Okay, give me a moment\u2014", "Let me think on that\u2014"]
9298
+ };
9280
9299
  var INTENT_ACK_OVER = {
9281
9300
  stop: Object.keys(ACK_SITUATIONS).filter((s) => s !== "silent"),
9282
9301
  steer: ["command"],
@@ -9494,6 +9513,17 @@ var Instinct = class {
9494
9513
  if (pick?.text !== text.trim()) return null;
9495
9514
  return Promise.race([pick.p.then((p) => p ? pick.spoken : null), pick.spoken]);
9496
9515
  }
9516
+ /** Duplex v2 second channel: which (if any) listener line fits THIS instant — one Choice over CHANNEL_ACTIONS.
9517
+ * `heard` is the micro-context tail only (never history). Never rejects: a failed call → null (the rule decides). */
9518
+ channel(heard, state) {
9519
+ return this.ask(
9520
+ "channel",
9521
+ heard,
9522
+ { heard_so_far: heard, ...state },
9523
+ FRAME + "You are the quick side-voice of the conversation, NOT the one who answers. Pick the tiny sound a person would make right now, or none. Silence is the default: most moments call for nothing.",
9524
+ CHANNEL_ACTIONS
9525
+ );
9526
+ }
9497
9527
  /** A random phrase from `pool`, never the one said last (no back-to-back repeat across keys). */
9498
9528
  rotate(pool) {
9499
9529
  const fresh = pool.filter((x) => x !== this.lastAckSaid);
@@ -9650,9 +9680,9 @@ var DuplexAgentOptions = class {
9650
9680
  // mislabels gpt-oss harmony channels under load, leaking raw analysis into the spoken `final` channel
9651
9681
  // (and misfiring Hold). 120b is the same price tier (~$0.15/$0.60) — the quality/cost trade is free.
9652
9682
  reflexModel = "groq/openai/gpt-oss-120b";
9653
- actModel = "anthropic/claude-sonnet-4-6";
9683
+ actModel = latestClaude("sonnet");
9654
9684
  /** Premium reasoning model. Set to `false` to disable the Think tier entirely. */
9655
- thinkModel = "anthropic/claude-opus-4-8";
9685
+ thinkModel = latestClaude("opus");
9656
9686
  /** Per-worker providerOptions, derived from the worker's actual model at spawn time (IoC — keeps duplex
9657
9687
  * provider-agnostic). Workers override the reflex/main model, so provider-specific options (e.g. cursor's
9658
9688
  * cwd/cursorSession) must be recomputed for the worker's model, never inherited from the main template —
@@ -11230,11 +11260,12 @@ Another agent just implemented the above. Independently check the CURRENT state
11230
11260
  // src/modelRouter.ts
11231
11261
  init_llm();
11232
11262
  init_logging();
11263
+ init_models();
11233
11264
  var log22 = forComponent("ModelRouter");
11234
11265
  var ModelRouterOptions = class {
11235
11266
  classifier = jevClassifier();
11236
11267
  /** Capability tiers → model id (any provider). basic/standard run the main session; premium is only ever an offload. */
11237
- models = { basic: "anthropic/claude-haiku-4-5-20251001", standard: "anthropic/claude-sonnet-4-6", premium: "anthropic/claude-opus-5-5" };
11268
+ models = { basic: latestClaude("haiku"), standard: latestClaude("sonnet"), premium: latestClaude("opus") };
11238
11269
  /** Escalate to a stronger tier when it holds at least this much probability mass (upward bias). */
11239
11270
  threshold = 0.25;
11240
11271
  /** P(same thread) at or above which the previous premium side-conversation is resumed. */
@@ -12533,7 +12564,7 @@ Reference files in them by their mount path (the left side).`;
12533
12564
  ai: o.ai,
12534
12565
  jobs: jobRegistry,
12535
12566
  fs,
12536
- model: o.model ?? "anthropic/claude-sonnet-4-6",
12567
+ model: o.model ?? latestClaude("sonnet"),
12537
12568
  // PDF reads (disk mode only — VFS paths aren't real files): poppler's pdftotext when installed.
12538
12569
  ...!virtual ? { pdfText: pdfTextViaPoppler } : {},
12539
12570
  // Anchor cursor to the launch dir (its adapter defaults to TMPDIR otherwise) and forward the
@@ -12584,7 +12615,7 @@ Windows paths: file tools accept ${cwd} style paths and the same path written ${
12584
12615
  if (o.anthropicWebSearch !== false && o.anthropicKey && !requested.includes("WebSearchAnthropic")) {
12585
12616
  tail.push(makeWebSearchAnthropicTool({ anthropicApiKey: o.anthropicKey }));
12586
12617
  }
12587
- if (scratch) tail.push(makeAskTool({ fs, ai: o.ai, model: o.scratchAskModel ?? o.model ?? "anthropic/claude-sonnet-4-6", dir: scratchDir }));
12618
+ if (scratch) tail.push(makeAskTool({ fs, ai: o.ai, model: o.scratchAskModel ?? o.model ?? latestClaude("sonnet"), dir: scratchDir }));
12588
12619
  tail.push(makeNotifyTool());
12589
12620
  if (!realShell.length) return [...base, ...tail];
12590
12621
  const filtered = base.filter((t) => t.name !== "bash");
@@ -13013,8 +13044,8 @@ var CutInAbsorber = class {
13013
13044
  const cut = this.cut || (meta?.supersedes !== void 0 && meta.supersedes === this.absorbed?.text ? this.absorbed.cut : "");
13014
13045
  this.cut = "";
13015
13046
  this.absorbed = void 0;
13016
- const words2 = (s) => s.trim().split(/\s+/).filter(Boolean).length;
13017
- if (!cut || words2(text) - words2(cut) >= 3) return false;
13047
+ const words3 = (s) => s.trim().split(/\s+/).filter(Boolean).length;
13048
+ if (!cut || words3(text) - words3(cut) >= 3) return false;
13018
13049
  this.absorbed = { text, cut };
13019
13050
  return true;
13020
13051
  }
@@ -13073,6 +13104,77 @@ function bindAckInstinct(v, instinct) {
13073
13104
  onDiag(ev);
13074
13105
  };
13075
13106
  }
13107
+ function bindSecondChannel(v, sc, live) {
13108
+ const o = v.options;
13109
+ const { onPartial, onUtterance, onState, onBargeIn, onBackchannel } = o;
13110
+ const { show } = sc.options;
13111
+ let own = false;
13112
+ let blip = "";
13113
+ o.onBackchannel = (p) => {
13114
+ blip = p;
13115
+ onBackchannel(p);
13116
+ };
13117
+ const take = v.takeInterruptedReply.bind(v);
13118
+ v.takeInterruptedReply = () => {
13119
+ const r = take();
13120
+ if (!r || !sc.spoke(r.full)) return r;
13121
+ return r.queued.length ? { ...r, full: "", heard: "" } : null;
13122
+ };
13123
+ o.onPartial = (t) => {
13124
+ if (live() && !v.audible) sc.onInterim(t);
13125
+ onPartial(t);
13126
+ };
13127
+ o.onUtterance = ((t, meta) => {
13128
+ if (live() && t.trim()) sc.onUserTurn(t);
13129
+ onUtterance(t, meta);
13130
+ });
13131
+ o.onState = (s) => {
13132
+ if (live() && !own) {
13133
+ if (s === "speaking") sc.onAgentState("first_audio");
13134
+ else if (s === "listening") sc.onAgentState("idle");
13135
+ }
13136
+ onState(s);
13137
+ };
13138
+ o.onBargeIn = (phase) => {
13139
+ sc.onBargeIn();
13140
+ onBargeIn(phase);
13141
+ };
13142
+ Object.assign(sc.options, {
13143
+ aec: v.usingAec,
13144
+ speak: (text, kind) => {
13145
+ if (!live() || v.audible) return false;
13146
+ if (kind === "backchannel") {
13147
+ blip = "";
13148
+ v.fireBackchannel();
13149
+ return;
13150
+ }
13151
+ own = true;
13152
+ v.speakFiller(text);
13153
+ return v.awaitIdle().finally(() => {
13154
+ own = false;
13155
+ });
13156
+ },
13157
+ show: (text, kind) => show(kind === "backchannel" && blip ? blip : text, kind)
13158
+ });
13159
+ }
13160
+ function channelInput(sc, m) {
13161
+ if (m.type === "interim") {
13162
+ if (typeof m.aec === "boolean") sc.options.aec = m.aec;
13163
+ if (typeof m.text === "string") sc.onInterim(m.text);
13164
+ return true;
13165
+ }
13166
+ if (m.type === "pause") {
13167
+ sc.onPause(Number(m.ms) || 0);
13168
+ return true;
13169
+ }
13170
+ if (m.type === "agent_state") {
13171
+ if (typeof m.state === "string" && MAIN_STATES.has(m.state)) sc.onAgentState(m.state);
13172
+ else log27.warn(`agent_state: unknown state ${JSON.stringify(m.state)} \u2014 ignored`);
13173
+ return true;
13174
+ }
13175
+ return false;
13176
+ }
13177
+ var MAIN_STATES = /* @__PURE__ */ new Set(["idle", "queued", "thinking", "streaming_text", "first_audio", "speaking"]);
13076
13178
  var VoiceIOOptions = class extends VoiceEngineOptions {
13077
13179
  sonioxApiKey = process.env.SONIOX_API_KEY ?? "";
13078
13180
  cartesiaApiKey = process.env.CARTESIA_API_KEY ?? "";
@@ -13242,6 +13344,316 @@ var JsonlDiagSink = class {
13242
13344
  }
13243
13345
  };
13244
13346
 
13347
+ // src/voice/secondChannel.ts
13348
+ init_logging();
13349
+ var log29 = forComponent("SecondChannel");
13350
+ var SecondChannelOptions = class {
13351
+ /** Refiner (Jev/fast LLM): the choice over CHANNEL_ACTIONS, or null on failure. Absent → the rule alone decides. */
13352
+ decide;
13353
+ /** Fast-model line writer (see fastLineGenerator). Absent or late → the canned bank. */
13354
+ generate;
13355
+ /** Kinds worth generating (content-conditioned); the rest are canned sounds. */
13356
+ generateFor = ["backchannel", "bridge"];
13357
+ /** Host TTS on the second channel; resolves when the line finished playing. `false` = the host could not play it. */
13358
+ speak = () => {
13359
+ };
13360
+ /** Host UI (caption / muted line) — never history. */
13361
+ show = () => {
13362
+ };
13363
+ /** Stop the in-flight line's audio now (barge-in only). */
13364
+ cancel = () => {
13365
+ };
13366
+ onEvent = () => {
13367
+ };
13368
+ /** Echo cancellation on: backchannels may play while the user speaks. Off (half-duplex) → fillers/bridges in silences only. */
13369
+ aec = false;
13370
+ decideEveryWords = 3;
13371
+ decisionBudgetMs = 300;
13372
+ generateBudgetMs = 500;
13373
+ minGapMs = 2500;
13374
+ /** Backchannel after backchannel needs this gap (v1 Instinct's cadence, ≤12/min). */
13375
+ backchannelGapMs = 5e3;
13376
+ /** A decision whose partial grew by this many words while in flight is stale → dropped. */
13377
+ staleWords = 3;
13378
+ microWords = 30;
13379
+ avoidLines = 4;
13380
+ /** Canned picks exclude the last N picks of the same kind. */
13381
+ bankExclude = 2;
13382
+ backchannelMinWords = 8;
13383
+ /** No new interim for this long → a pause (hosts may also call onPause). */
13384
+ fillerPauseMs = 700;
13385
+ fillerMinWords = 4;
13386
+ bridgeMinWords = 5;
13387
+ now = () => performance.now();
13388
+ random = Math.random;
13389
+ };
13390
+ var words2 = (s) => s.trim() ? s.trim().split(/\s+/) : [];
13391
+ var QUICK = /^(hey|hi|hello|yo|hiya|morning|good (morning|evening|afternoon)|thanks|thank you|ok(ay)?|yes|yeah|yep|no|nope|sure|bye|goodbye|how are you|what'?s up|can you hear me)\b/i;
13392
+ function withBudget(p, ms, what) {
13393
+ let t;
13394
+ const late = new Promise((r) => {
13395
+ t = setTimeout(() => {
13396
+ log29.debug(`${what}: over the ${ms}ms budget \u2014 dropped`);
13397
+ r(null);
13398
+ }, ms);
13399
+ });
13400
+ return Promise.race([p.catch((e) => {
13401
+ log29.warn(`${what} failed \u2014 falling back: ${e?.message ?? e}`);
13402
+ return null;
13403
+ }), late]).finally(() => clearTimeout(t));
13404
+ }
13405
+ var SecondChannel = class {
13406
+ constructor(options) {
13407
+ this.options = options;
13408
+ this.options = { ...new SecondChannelOptions(), ...options };
13409
+ this.startedAt = this.o.now();
13410
+ }
13411
+ options;
13412
+ heard = "";
13413
+ userSpeaking = false;
13414
+ main = "idle";
13415
+ lead = "";
13416
+ gen = 0;
13417
+ // bumped by barge-in / a new user turn → in-flight decisions drop
13418
+ decidedAt = 0;
13419
+ // word count at the last decision
13420
+ busy = false;
13421
+ pauseTimer = null;
13422
+ lastAt = -Infinity;
13423
+ lastKind = null;
13424
+ live = null;
13425
+ // our last line, until channel-1 audio takes the floor
13426
+ cutLine = null;
13427
+ // the line the last barge actually cut (spoke())
13428
+ bridged = false;
13429
+ // one bridge per main turn
13430
+ recent = [];
13431
+ picks = /* @__PURE__ */ new Map();
13432
+ inflight = null;
13433
+ startedAt;
13434
+ get o() {
13435
+ return this.options;
13436
+ }
13437
+ /** The user's live partial for the current turn (the whole partial, not a delta). */
13438
+ onInterim(text) {
13439
+ const t = text.trim();
13440
+ if (!t || t === this.heard) return;
13441
+ if (this.main !== "idle") this.onAgentState("idle");
13442
+ this.heard = t;
13443
+ this.userSpeaking = true;
13444
+ if (this.pauseTimer) clearTimeout(this.pauseTimer);
13445
+ this.pauseTimer = setTimeout(() => {
13446
+ this.pauseTimer = null;
13447
+ this.onPause(this.o.fillerPauseMs);
13448
+ }, this.o.fillerPauseMs);
13449
+ if (words2(t).length - this.decidedAt >= this.o.decideEveryWords) void this.consider("interim");
13450
+ }
13451
+ /** The user went quiet for `ms` (host VAD/STT pause, or the internal interim timer). */
13452
+ onPause(ms) {
13453
+ if (this.pauseTimer) {
13454
+ clearTimeout(this.pauseTimer);
13455
+ this.pauseTimer = null;
13456
+ }
13457
+ this.userSpeaking = false;
13458
+ void this.consider("pause", ms);
13459
+ }
13460
+ /** Channel-1 lifecycle. `queued` = the user's turn was dispatched to the main agent. */
13461
+ onAgentState(s) {
13462
+ if (s === this.main) return;
13463
+ this.main = s;
13464
+ if (s === "first_audio" || s === "speaking") this.live = null;
13465
+ if (s === "idle") {
13466
+ this.heard = "";
13467
+ this.decidedAt = 0;
13468
+ this.lead = "";
13469
+ this.bridged = false;
13470
+ return;
13471
+ }
13472
+ if (s === "queued") {
13473
+ this.gen++;
13474
+ this.userSpeaking = false;
13475
+ this.lead = "";
13476
+ this.bridged = false;
13477
+ if (this.pauseTimer) clearTimeout(this.pauseTimer);
13478
+ this.pauseTimer = null;
13479
+ }
13480
+ if (s === "queued" || s === "thinking" || s === "streaming_text") void this.consider("working");
13481
+ }
13482
+ /** The user's final line was dispatched to the main agent (its words are the bridge's micro-context). */
13483
+ onUserTurn(text) {
13484
+ this.heard = text.trim();
13485
+ this.main = "idle";
13486
+ this.onAgentState("queued");
13487
+ }
13488
+ /** The main answer's streamed text (before its audio): a bridge can lead into it. */
13489
+ onMainTokens(text) {
13490
+ if (this.lead.length < 120) this.lead += text;
13491
+ if (this.main === "queued" || this.main === "thinking") this.onAgentState("streaming_text");
13492
+ }
13493
+ /** The user barged in: cut the in-flight line now and drop every pending decision. */
13494
+ onBargeIn() {
13495
+ this.gen++;
13496
+ this.cutLine = this.inflight || this.live ? this.live : null;
13497
+ if (this.inflight) {
13498
+ this.inflight = null;
13499
+ try {
13500
+ this.o.cancel();
13501
+ } catch (e) {
13502
+ log29.warn(`cancel threw: ${e.message}`);
13503
+ }
13504
+ }
13505
+ }
13506
+ /** Is `text` the line the last barge-in cut (ours, still holding the floor)? Hosts strip it from the cut reply — it is
13507
+ * not the agent's speech. By identity, not by text: a real reply that merely repeats a recent line is kept. Consumed once. */
13508
+ spoke(text) {
13509
+ const c = this.cutLine;
13510
+ this.cutLine = null;
13511
+ return !!c && c === text.trim();
13512
+ }
13513
+ /** Resolves when the in-flight line finished playing (immediately when none) — hosts enqueue channel-1 audio behind it. */
13514
+ drain() {
13515
+ return this.inflight ?? Promise.resolve();
13516
+ }
13517
+ get audioLocked() {
13518
+ return this.main === "first_audio" || this.main === "speaking";
13519
+ }
13520
+ get working() {
13521
+ return this.main === "queued" || this.main === "thinking" || this.main === "streaming_text";
13522
+ }
13523
+ tail() {
13524
+ return words2(this.heard).slice(-this.o.microWords).join(" ");
13525
+ }
13526
+ /** Hard gates every line passes, whoever proposed it. */
13527
+ allowed(k) {
13528
+ if (this.audioLocked || this.inflight || this.o.now() - this.lastAt < this.o.minGapMs || k === this.lastKind && (k !== "backchannel" || this.o.now() - this.lastAt < this.o.backchannelGapMs)) return false;
13529
+ if (k === "backchannel") return this.o.aec && this.userSpeaking && this.main === "idle";
13530
+ if (k === "filler") return !this.userSpeaking && this.main === "idle";
13531
+ return this.working && !this.bridged;
13532
+ }
13533
+ /** 0ms rule: the candidate line for this trigger, or silence. */
13534
+ rule(trigger, pauseMs = 0) {
13535
+ const w = words2(this.heard);
13536
+ if (w.length <= 3 || QUICK.test(this.heard) && w.length <= 6) return "silence";
13537
+ const k = trigger === "interim" ? w.length >= this.o.backchannelMinWords && /[,.;:!?…]$/.test(this.heard) ? "backchannel" : null : trigger === "pause" ? pauseMs >= this.o.fillerPauseMs && w.length >= this.o.fillerMinWords ? "filler" : null : w.length >= this.o.bridgeMinWords ? "bridge" : null;
13538
+ return k && this.allowed(k) ? k : "silence";
13539
+ }
13540
+ async consider(trigger, pauseMs) {
13541
+ if (this.busy) return;
13542
+ const n0 = words2(this.heard).length;
13543
+ this.decidedAt = n0;
13544
+ const rule = this.rule(trigger, pauseMs);
13545
+ if (rule === "silence") return;
13546
+ this.busy = true;
13547
+ const gen = this.gen, t0 = this.o.now();
13548
+ const ev = (e) => this.o.onEvent({ trigger, rule, ms: Math.round(this.o.now() - t0), ...e });
13549
+ const stale = () => gen !== this.gen || words2(this.heard).length - n0 >= this.o.staleWords;
13550
+ try {
13551
+ let kind = rule;
13552
+ let choice;
13553
+ if (this.o.decide) {
13554
+ const d = await withBudget(this.o.decide(this.tail(), this.state(pauseMs)), this.o.decisionBudgetMs, "channel decision");
13555
+ choice = d?.choice;
13556
+ if (choice && choice in CHANNEL_ACTIONS) kind = choice;
13557
+ }
13558
+ if (kind === "silence") return ev({ choice, acted: "silence" });
13559
+ if (stale()) return ev({ choice, kind, acted: "stale" });
13560
+ if (!this.allowed(kind)) return ev({ choice, kind, acted: "gated" });
13561
+ const text = await this.line(kind);
13562
+ if (stale()) return ev({ choice, kind, text, acted: "stale" });
13563
+ if (!this.allowed(kind)) return ev({ choice, kind, text, acted: "gated" });
13564
+ ev({ choice, kind, text, acted: this.say(kind, text) ? "spoken" : "skipped" });
13565
+ } finally {
13566
+ this.busy = false;
13567
+ }
13568
+ }
13569
+ state(pauseMs) {
13570
+ const now4 = this.o.now();
13571
+ return {
13572
+ user_speaking: this.userSpeaking,
13573
+ pause_ms: pauseMs ?? null,
13574
+ agent_state: this.main,
13575
+ last_line: this.recent.at(-1) ?? null,
13576
+ ms_since_last_line: Number.isFinite(this.lastAt) ? Math.round(now4 - this.lastAt) : null,
13577
+ call_ms: Math.round(now4 - this.startedAt)
13578
+ };
13579
+ }
13580
+ async line(kind) {
13581
+ if (this.o.generate && this.o.generateFor.includes(kind)) {
13582
+ const ac = new AbortController();
13583
+ const ctx = { kind, heard: this.tail(), state: this.main, avoid: [...this.recent], ...this.lead ? { lead: this.lead } : {} };
13584
+ const raw = await withBudget(this.o.generate(ctx, ac.signal), this.o.generateBudgetMs, `${kind} generate`);
13585
+ if (raw === null) ac.abort();
13586
+ const text = cleanLine(raw ?? "");
13587
+ if (text && !this.recent.some((r) => norm2(r) === norm2(text))) return text;
13588
+ if (text) log29.debug(`generated "${text}" repeats a recent line \u2014 canned`);
13589
+ }
13590
+ return this.pick(kind);
13591
+ }
13592
+ /** Canned rotation: never one of the last `bankExclude` picks of this kind, nor a recent line. */
13593
+ pick(kind) {
13594
+ const pool = CHANNEL_BANK[kind];
13595
+ const hist = this.picks.get(kind) ?? [];
13596
+ const recent = new Set([...hist.slice(-this.o.bankExclude), ...this.recent].map(norm2));
13597
+ const fresh = pool.filter((x) => !recent.has(norm2(x)));
13598
+ const from = fresh.length ? fresh : pool;
13599
+ const p = from[Math.floor(this.o.random() * from.length)];
13600
+ this.picks.set(kind, [...hist, p].slice(-this.o.bankExclude));
13601
+ return p;
13602
+ }
13603
+ say(kind, text) {
13604
+ let r;
13605
+ try {
13606
+ r = this.o.speak(text, kind);
13607
+ } catch (e) {
13608
+ log29.warn(`speak threw \u2014 line dropped: ${e.message}`);
13609
+ return false;
13610
+ }
13611
+ if (r === false) return false;
13612
+ this.lastAt = this.o.now();
13613
+ this.lastKind = kind;
13614
+ if (kind === "bridge") this.bridged = true;
13615
+ this.recent = [...this.recent, text].slice(-this.o.avoidLines);
13616
+ this.live = text.trim();
13617
+ this.o.show(text, kind);
13618
+ const p = Promise.resolve(r).then(() => {
13619
+ }, (e) => {
13620
+ log29.warn(`channel line playback failed: ${e?.message ?? e}`);
13621
+ }).finally(() => {
13622
+ if (this.inflight === p) this.inflight = null;
13623
+ });
13624
+ this.inflight = p;
13625
+ return true;
13626
+ }
13627
+ };
13628
+ var norm2 = (s) => s.toLowerCase().replace(/[^a-z' ]/g, "").trim();
13629
+ function cleanLine(s) {
13630
+ const raw = s.trim().split("\n")[0];
13631
+ const l = raw.replace(/^["'“”]+|["'“”]+$/g, "").replace(/^\w+:\s*/, "").trim();
13632
+ if (isLeakSuspect(raw) || isLeakSuspect(l)) {
13633
+ if (l) log29.debug(`generated "${raw}" reads as meta \u2014 canned`);
13634
+ return "";
13635
+ }
13636
+ return words2(l).length > 6 ? "" : l;
13637
+ }
13638
+ var KIND_HINT = {
13639
+ backchannel: "a tiny listener cue while they keep talking \u2014 shows you follow THEIR point, does not take the turn",
13640
+ filler: "a soft thinking sound at their pause",
13641
+ bridge: "a quick lead-in before the real answer, while it is being prepared"
13642
+ };
13643
+ function fastLineGenerator(ai, model) {
13644
+ return async (c, signal) => {
13645
+ const prompt = `You voice the tiny side-sounds of a live spoken conversation. Write ONE line of 1-5 words: ${KIND_HINT[c.kind]}.
13646
+ The user just said: "${c.heard}"
13647
+ The assistant is: ${c.state}.
13648
+ ` + (c.lead ? `The real answer begins: "${c.lead.trim()}" \u2014 lead naturally into it; do not repeat it.
13649
+ ` : "") + (c.avoid.length ? `Do not reuse any of: ${c.avoid.map((a) => `"${a}"`).join(", ")}.
13650
+ ` : "") + "React to their actual words, casually. Output only the line.";
13651
+ const reasoning = /gpt-oss/.test(model) ? { providerOptions: { reasoning_effort: "low" } } : {};
13652
+ const r = await ai.chat({ model, messages: [{ role: "user", content: prompt }], stream: false, signal, maxTokens: 150, ...reasoning });
13653
+ return r.content ?? "";
13654
+ };
13655
+ }
13656
+
13245
13657
  // cli/config.ts
13246
13658
  import { homedir as homedir5 } from "os";
13247
13659
  import { existsSync as existsSync5, readFileSync as readFileSync7 } from "fs";
@@ -13388,7 +13800,7 @@ function applyOverlay(cfg, file) {
13388
13800
  // cli/hooks-config.ts
13389
13801
  import { spawnSync as spawnSync4 } from "child_process";
13390
13802
  init_platform();
13391
- var log29 = forComponent("hooks");
13803
+ var log30 = forComponent("hooks");
13392
13804
  var escapeRegex = (s) => s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
13393
13805
  function ruleMatches(rule, toolName) {
13394
13806
  if (!rule.tool || rule.tool === "*") return true;
@@ -13407,7 +13819,7 @@ function runCmd(rule, env) {
13407
13819
  });
13408
13820
  return { code: r.status ?? 1, out: ((r.stdout ?? "") + (r.stderr ?? "")).trim() };
13409
13821
  } catch (e) {
13410
- log29.debug(`hook command failed: ${rule.command}`, e);
13822
+ log30.debug(`hook command failed: ${rule.command}`, e);
13411
13823
  return { code: 1, out: String(e?.message ?? e) };
13412
13824
  }
13413
13825
  }
@@ -13515,7 +13927,7 @@ import { existsSync as existsSync6, mkdirSync as mkdirSync8, readFileSync as rea
13515
13927
  import { homedir as homedir6 } from "os";
13516
13928
  import { join as join10 } from "path";
13517
13929
  init_platform();
13518
- var log30 = forComponent("session");
13930
+ var log31 = forComponent("session");
13519
13931
  var globalDir = () => join10(homedir6(), ".agent", "sessions");
13520
13932
  function indexLink(target, link2) {
13521
13933
  try {
@@ -13578,7 +13990,7 @@ var SessionStore = class {
13578
13990
  }
13579
13991
  load(id) {
13580
13992
  if (!this.safeId(id)) {
13581
- log30.debug(`rejecting unsafe session id: ${id}`);
13993
+ log31.debug(`rejecting unsafe session id: ${id}`);
13582
13994
  return void 0;
13583
13995
  }
13584
13996
  const path = join10(this.dir, `${id}.json`);
@@ -13586,7 +13998,7 @@ var SessionStore = class {
13586
13998
  try {
13587
13999
  return JSON.parse(readFileSync8(path, "utf8"));
13588
14000
  } catch (e) {
13589
- log30.debug(`unreadable session ${id} \u2014 ignoring`, e);
14001
+ log31.debug(`unreadable session ${id} \u2014 ignoring`, e);
13590
14002
  return void 0;
13591
14003
  }
13592
14004
  }
@@ -13599,7 +14011,7 @@ var SessionStore = class {
13599
14011
  try {
13600
14012
  metas.push(JSON.parse(readFileSync8(join10(this.dir, f), "utf8")).meta);
13601
14013
  } catch (e) {
13602
- log30.debug(`skipping unreadable session file ${f}`, e);
14014
+ log31.debug(`skipping unreadable session file ${f}`, e);
13603
14015
  }
13604
14016
  }
13605
14017
  return metas.sort((a, b) => b.updated - a.updated);
@@ -13838,7 +14250,7 @@ import { promisify } from "util";
13838
14250
  import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync9 } from "fs";
13839
14251
  import { homedir as homedir7 } from "os";
13840
14252
  import { join as join11, resolve as resolve4, sep as sep3, parse as parsePath } from "path";
13841
- var log31 = forComponent("checkpoints");
14253
+ var log32 = forComponent("checkpoints");
13842
14254
  var exec = promisify(execFile4);
13843
14255
  var DEFAULT_EXCLUDE = [".agent/", ".git/", "node_modules/", "dist/", "build/", ".next/", "target/", ".venv/", "__pycache__/", "*.log"];
13844
14256
  function isBroadRoot(p) {
@@ -13884,7 +14296,7 @@ var ShadowRepo = class {
13884
14296
  writeFileSync6(join11(this.gitDir, "info", "exclude"), this.exclude.join("\n") + "\n");
13885
14297
  this.ready = true;
13886
14298
  } catch (e) {
13887
- log31.debug(`git checkpoints unavailable for ${this.workTree}`, e);
14299
+ log32.debug(`git checkpoints unavailable for ${this.workTree}`, e);
13888
14300
  this.ready = false;
13889
14301
  }
13890
14302
  return this.ready;
@@ -13895,7 +14307,7 @@ var ShadowRepo = class {
13895
14307
  }
13896
14308
  async commit(label, forced = []) {
13897
14309
  await this.run("add", "-A");
13898
- for (const p of forced) await this.run("add", "-f", "--", p).catch((e) => log31.debug(`force-add failed: ${p}`, e));
14310
+ for (const p of forced) await this.run("add", "-f", "--", p).catch((e) => log32.debug(`force-add failed: ${p}`, e));
13899
14311
  await this.run("commit", "--allow-empty", "-q", "-m", label);
13900
14312
  }
13901
14313
  /** Inject the CURRENT (pre-edit) content of `paths` into the turn-open restore point by amending it.
@@ -13903,8 +14315,8 @@ var ShadowRepo = class {
13903
14315
  * turn-boundary `add -A` would never have captured it. Amend (vs a new commit) keeps one restore
13904
14316
  * point per turn, so the REPL's turn↔frame mapping stays intact. */
13905
14317
  async amendForced(paths) {
13906
- for (const p of paths) await this.run("add", "-f", "--", p).catch((e) => log31.debug(`force-capture failed: ${p}`, e));
13907
- await this.run("commit", "--amend", "--no-edit", "-q", "--allow-empty").catch((e) => log31.debug("amend failed", e));
14318
+ for (const p of paths) await this.run("add", "-f", "--", p).catch((e) => log32.debug(`force-capture failed: ${p}`, e));
14319
+ await this.run("commit", "--amend", "--no-edit", "-q", "--allow-empty").catch((e) => log32.debug("amend failed", e));
13908
14320
  }
13909
14321
  /** Commits on `ref`, oldest-first (canonical index space). */
13910
14322
  async log(ref) {
@@ -13964,7 +14376,7 @@ var ShadowRepo = class {
13964
14376
  await this.run("gc", "--auto", "-q").catch(() => {
13965
14377
  });
13966
14378
  } catch (e) {
13967
- log31.debug("checkpoint prune failed", e);
14379
+ log32.debug("checkpoint prune failed", e);
13968
14380
  }
13969
14381
  }
13970
14382
  };
@@ -14032,7 +14444,7 @@ var GitCheckpoints = class {
14032
14444
  use(sessionId) {
14033
14445
  if (sessionId === this.session) return;
14034
14446
  this.session = sessionId;
14035
- if (this.started) for (const r of this.repos) void r.point(this.ref()).catch((e) => log31.debug("re-point failed", e));
14447
+ if (this.started) for (const r of this.repos) void r.point(this.ref()).catch((e) => log32.debug("re-point failed", e));
14036
14448
  }
14037
14449
  async begin(label) {
14038
14450
  if (!await this.start()) return;
@@ -14052,7 +14464,7 @@ var GitCheckpoints = class {
14052
14464
  process.stderr.write(`\x1B[2m checkpoints: '${this.repos[i].root}' too large to snapshot in ${Math.round(this.options.opTimeoutMs / 1e3)}s \u2014 file rewind disabled here\x1B[0m
14053
14465
  `);
14054
14466
  } else {
14055
- log31.debug("checkpoint commit failed", e);
14467
+ log32.debug("checkpoint commit failed", e);
14056
14468
  survivors.push(this.repos[i]);
14057
14469
  survivorCaches.push(this.caches[i] ?? []);
14058
14470
  }
@@ -14087,7 +14499,7 @@ var GitCheckpoints = class {
14087
14499
  if (this.forced.has(abs)) continue;
14088
14500
  this.forced.add(abs);
14089
14501
  const i = this.repoIndexFor(abs);
14090
- if (i >= 0) await this.repos[i].amendForced([abs]).catch((e) => log31.debug("amendForced failed", e));
14502
+ if (i >= 0) await this.repos[i].amendForced([abs]).catch((e) => log32.debug("amendForced failed", e));
14091
14503
  }
14092
14504
  }
14093
14505
  };
@@ -15872,7 +16284,7 @@ import { spawnSync as spawnSync6 } from "child_process";
15872
16284
  import { writeFileSync as writeFileSync8, mkdirSync as mkdirSync10, readdirSync as readdirSync2, unlinkSync as unlinkSync3, chmodSync, existsSync as existsSync7 } from "fs";
15873
16285
  import { homedir as homedir8 } from "os";
15874
16286
  import { join as join13 } from "path";
15875
- var log32 = forComponent("os-sched");
16287
+ var log33 = forComponent("os-sched");
15876
16288
  var OsScheduler = class {
15877
16289
  options;
15878
16290
  constructor(options) {
@@ -15934,7 +16346,7 @@ var OsScheduler = class {
15934
16346
  }
15935
16347
  }
15936
16348
  } catch (e) {
15937
- log32.debug(`cancel ${id}`, e);
16349
+ log33.debug(`cancel ${id}`, e);
15938
16350
  }
15939
16351
  for (const f of [`${id}.json`, `${id}.sh`]) {
15940
16352
  try {
@@ -16052,7 +16464,7 @@ import { existsSync as existsSync8, mkdirSync as mkdirSync11, unlinkSync as unli
16052
16464
  import { homedir as homedir9 } from "os";
16053
16465
  import { join as join14 } from "path";
16054
16466
  init_platform();
16055
- var log33 = forComponent("remote-trigger");
16467
+ var log34 = forComponent("remote-trigger");
16056
16468
  var TRIGGER_DIR = () => join14(homedir9(), ".agent", "triggers");
16057
16469
  var sockPath = (sessionId, dir = TRIGGER_DIR()) => join14(dir, `${sessionId}.sock`);
16058
16470
  var TriggerServer = class {
@@ -16090,13 +16502,13 @@ var TriggerServer = class {
16090
16502
  conn.end(JSON.stringify({ ok: false, error: String(e) }) + "\n");
16091
16503
  }
16092
16504
  });
16093
- conn.on("error", (e) => log33.debug("trigger conn error", e));
16505
+ conn.on("error", (e) => log34.debug("trigger conn error", e));
16094
16506
  });
16095
- this.server.on("error", (e) => log33.debug("trigger server error", e));
16507
+ this.server.on("error", (e) => log34.debug("trigger server error", e));
16096
16508
  this.server.listen(p);
16097
16509
  this.path = p;
16098
16510
  } catch (e) {
16099
- log33.debug("trigger server unavailable", e);
16511
+ log34.debug("trigger server unavailable", e);
16100
16512
  }
16101
16513
  }
16102
16514
  /** Re-bind on /resume (the session id changed). */
@@ -16228,7 +16640,7 @@ var strike = C("9");
16228
16640
  var link = (text, url) => useColor ? `\x1B]8;;${url}\x1B\\${cyan(text)}\x1B]8;;\x1B\\` : `${text} (${url})`;
16229
16641
  var mdPalette = { bold, dim, cyan, italic, strike, green, link };
16230
16642
  var err = (s) => process.stderr.write(s);
16231
- var log34 = forComponent("cli");
16643
+ var log35 = forComponent("cli");
16232
16644
  var VERSION = (() => {
16233
16645
  try {
16234
16646
  return JSON.parse(readFileSync10(new URL("../package.json", import.meta.url), "utf8")).version ?? "?";
@@ -16368,7 +16780,10 @@ function parseArgs(argv) {
16368
16780
  a.hardenNet = true;
16369
16781
  } else if (x === "--subagents") a.subagents = true;
16370
16782
  else if (x === "--duplex") a.duplex = true;
16371
- else if (x === "--conversational" || x === "--convo" || x === "--voice") {
16783
+ else if (x === "--duplex-v2") {
16784
+ a.duplex = true;
16785
+ a.duplexV2 = true;
16786
+ } else if (x === "--conversational" || x === "--convo" || x === "--voice") {
16372
16787
  a.voice = true;
16373
16788
  a.duplex = true;
16374
16789
  } else if (x === "--voice-model") a.voiceModel = val(++i, x);
@@ -16425,7 +16840,7 @@ var HELP = `agentx \u2014 agent runtime CLI
16425
16840
  agentx -c "<task>" continue the most recent session
16426
16841
 
16427
16842
  Flags:
16428
- -m, --model <id> model (default auto: per-turn haiku/sonnet + opus offload when Jev is configured, else anthropic/claude-sonnet-4-6); bare -m or -m list = list all models
16843
+ -m, --model <id> model (default auto: per-turn haiku/sonnet + opus offload when Jev is configured, else the newest anthropic Sonnet); bare -m or -m list = list all models
16429
16844
  -C, --cwd <dir> working dir / project root (default .)
16430
16845
  -c, --continue resume the most recent session in this dir
16431
16846
  -r, --resume [id] resume a session \u2014 with an id, or bare for an interactive picker
@@ -16452,12 +16867,14 @@ Flags:
16452
16867
  --append-system-prompt <t> extra instructions appended to the system prompt for this run
16453
16868
  --duplex duplex mode: a fast voice model replies instantly and delegates real work
16454
16869
  to a background worker agent (-m model); results are re-voiced when ready
16870
+ --duplex-v2 duplex + a second voice channel: backchannels, fillers and bridges that never
16871
+ enter the agent's history (headless: stdin interim/pause/agent_state, stdout channel)
16455
16872
  --conversational duplex with a conversation-native register \u2014 short fast turns, fillers,
16456
16873
  impulsive reactions, human pacing (implies --duplex; aliases: --convo, --voice)
16457
16874
  with SONIOX_API_KEY + CARTESIA_API_KEY(+VOICE_ID) set: real voice I/O \u2014 mic in,
16458
16875
  spoken replies out (echo-cancelled; speak over it to interrupt)
16459
16876
  --voice-model <id> with --duplex: the fast voice model (default groq/openai/gpt-oss-120b)
16460
- --think-model <id> with --duplex: the premium deep-reasoning model (default anthropic/claude-opus-4-6)
16877
+ --think-model <id> with --duplex: the premium deep-reasoning model (default: the newest anthropic Opus)
16461
16878
  --no-think with --duplex: disable the Think tier (Act handles everything)
16462
16879
  --voice-system-prompt <text> with --duplex: appended to the voice (reflex) prompt only (--append-system-prompt \u2192 workers)
16463
16880
  --add-dir <path> mount another directory into the workspace (repeatable; disk mode only)
@@ -16508,8 +16925,8 @@ REPL shortcuts: Shift+Tab cycles permission posture (ask \u2192 accept-edits \u2
16508
16925
  REPL steer/stash: type while a turn is running \u2192 Enter STEERS it (the text reaches the model at its next step, alongside the next tool result). Alt+S (or Ctrl+S) with text stashes it for AFTER the turn instead; on an empty prompt pops the next stashed entry for editing.
16509
16926
  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 Ctrl-_ undo \xB7 Alt-D kill word fwd \xB7 Ctrl-L clear screen. Set editorMode:'vim' (or /config) for modal vim editing.
16510
16927
  REPL paste: large/multi-line pastes collapse to a [Pasted #N "\u2026" +N lines] preview (expands on send); a pasted image/file path attaches as [Image]/[File]; Ctrl-V or /paste grabs a clipboard image (macOS).`;
16511
- function newestModel() {
16512
- return listModels().slice().sort((a, b) => (getModelInfo(b)?.releaseDate ?? "").localeCompare(getModelInfo(a)?.releaseDate ?? ""))[0] ?? "";
16928
+ function newestModel(provider) {
16929
+ return listModels(provider).slice().sort((a, b) => (getModelInfo(b)?.releaseDate ?? "").localeCompare(getModelInfo(a)?.releaseDate ?? ""))[0] ?? "";
16513
16930
  }
16514
16931
  function printModels() {
16515
16932
  const byProvider = /* @__PURE__ */ new Map();
@@ -16533,7 +16950,8 @@ function resolveModelOrNewest(model) {
16533
16950
  const want = model ?? new AgentOptions().model;
16534
16951
  const resolved = resolveModel(want);
16535
16952
  if (isModelSupported(resolved)) return resolved;
16536
- const fallback = newestModel();
16953
+ const provider = getProviderFromModel(want) ?? void 0;
16954
+ const fallback = newestModel(provider && listModels(provider).length ? provider : void 0);
16537
16955
  if (!fallback) return want;
16538
16956
  err(yellow(` \u26A0 model "${want}" not available \u2014 using ${fallback}
16539
16957
  `));
@@ -16603,7 +17021,7 @@ function duplexModels(o) {
16603
17021
  let reflexModel = asked ? resolveModelOrNewest(asked) : void 0;
16604
17022
  const reflex = reflexModel ?? dflt.reflexModel;
16605
17023
  if (!modelUsable(reflex, o.apiKeys)) {
16606
- const to = anthropic ? "anthropic/claude-haiku-4-5" : cc ? "claude-code/haiku" : o.actModel.startsWith("cursor/") ? void 0 : o.actModel;
17024
+ const to = anthropic ? latestClaude("haiku") : cc ? "claude-code/haiku" : o.actModel.startsWith("cursor/") ? void 0 : o.actModel;
16607
17025
  if (to) reflexModel = to;
16608
17026
  notes.push(`voice model ${reflex} can't run here (${why(reflex)})${to ? ` \u2014 using ${to}` : ""}`);
16609
17027
  }
@@ -17053,14 +17471,14 @@ function turnCost(model, usage) {
17053
17471
  const priced = model.startsWith("claude-code/") ? `anthropic/${resolveModelAlias(model.slice("claude-code/".length))}` : model;
17054
17472
  return costOf(getModelInfo(priced)?.pricing, usage?.promptTokens ?? 0, usage?.completionTokens ?? 0, usage?.cacheCreationTokens ?? 0, usage?.cacheReadTokens ?? 0, priced);
17055
17473
  }
17056
- async function evaluateGoal(ai, condition, transcript, log35) {
17474
+ async function evaluateGoal(ai, condition, transcript, log36) {
17057
17475
  const recent = transcript.filter((m) => m.role === "assistant").slice(-8).map((m) => {
17058
17476
  const text = typeof m.content === "string" ? m.content : m.content.filter((p) => p.type === "text").map((p) => p.text).join(" ");
17059
17477
  return text.slice(0, 600);
17060
17478
  }).join("\n---\n");
17061
17479
  try {
17062
17480
  const r = await ai.chat({
17063
- model: "anthropic/claude-haiku-4-5",
17481
+ model: latestClaude("haiku"),
17064
17482
  stream: false,
17065
17483
  messages: [
17066
17484
  { role: "system", content: 'You judge whether a goal condition has been met based on conversation transcript. Respond ONLY with JSON: {"met": boolean, "reason": "one sentence"}. Be strict \u2014 only met:true if there is clear evidence in the transcript.' },
@@ -17073,7 +17491,7 @@ ${recent}` }
17073
17491
  const match = r.content.match(/\{[\s\S]*\}/);
17074
17492
  if (match) return JSON.parse(match[0]);
17075
17493
  } catch (e) {
17076
- log35(dim(` (goal evaluator error: ${e?.message ?? e})
17494
+ log36(dim(` (goal evaluator error: ${e?.message ?? e})
17077
17495
  `));
17078
17496
  }
17079
17497
  return { met: false, reason: "evaluation unclear" };
@@ -17299,7 +17717,7 @@ function mcpAgentTools(mounted, opts) {
17299
17717
  return tools;
17300
17718
  }
17301
17719
  async function closeMcp(mounted) {
17302
- await Promise.all(mounted.map((m) => m.client.close().catch((e) => log34.debug("mcp close failed", e))));
17720
+ await Promise.all(mounted.map((m) => m.client.close().catch((e) => log35.debug("mcp close failed", e))));
17303
17721
  }
17304
17722
  var IMG_EXT = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
17305
17723
  function mentionRefs(line) {
@@ -17358,7 +17776,7 @@ async function expandMentions(fs, line) {
17358
17776
  if (loaded.includes(ref) || missing.includes(ref)) continue;
17359
17777
  if (ref.includes(":") && mcpMentionResolver) {
17360
17778
  const body = await mcpMentionResolver(ref).catch((e) => {
17361
- log34.debug("mcp mention resolve failed", e);
17779
+ log35.debug("mcp mention resolve failed", e);
17362
17780
  return null;
17363
17781
  });
17364
17782
  if (body != null) {
@@ -17449,7 +17867,7 @@ async function runTurn(agent, store, session, task, cp, cwd = process.cwd(), sen
17449
17867
  try {
17450
17868
  store.save(session);
17451
17869
  } catch (ex) {
17452
- log34.debug("mid-turn session flush failed", ex);
17870
+ log35.debug("mid-turn session flush failed", ex);
17453
17871
  }
17454
17872
  }
17455
17873
  return origNotify(e);
@@ -17598,14 +18016,14 @@ var isCancelTeardown = (e) => {
17598
18016
  function installCancelGuards(mounted) {
17599
18017
  process.on("unhandledRejection", (e) => {
17600
18018
  if (isCancelTeardown(e)) {
17601
- log34.debug("suppressed unhandledRejection (cursor stream cancel)", e);
18019
+ log35.debug("suppressed unhandledRejection (cursor stream cancel)", e);
17602
18020
  return;
17603
18021
  }
17604
- log34.error("unhandledRejection", e);
18022
+ log35.error("unhandledRejection", e);
17605
18023
  });
17606
18024
  process.on("uncaughtException", (e) => {
17607
18025
  if (isCancelTeardown(e)) {
17608
- log34.debug("suppressed uncaughtException (cursor stream cancel)", e);
18026
+ log35.debug("suppressed uncaughtException (cursor stream cancel)", e);
17609
18027
  return;
17610
18028
  }
17611
18029
  console.error(e);
@@ -18000,7 +18418,7 @@ async function repl(args, ai, cfg, cwd) {
18000
18418
  err(green(" \u2713 think tier disabled\n"));
18001
18419
  return;
18002
18420
  }
18003
- const picked2 = await pickModel(dx.options.thinkModel === false ? "anthropic/claude-opus-4-6" : String(dx.options.thinkModel));
18421
+ const picked2 = await pickModel(dx.options.thinkModel === false ? latestClaude("opus") : String(dx.options.thinkModel));
18004
18422
  if (picked2) applyThinkModel(picked2);
18005
18423
  return;
18006
18424
  }
@@ -18030,7 +18448,7 @@ async function repl(args, ai, cfg, cwd) {
18030
18448
  try {
18031
18449
  mkdirSync12(dir, { recursive: true });
18032
18450
  } catch (e) {
18033
- log34.debug("paste image dir create failed", e);
18451
+ log35.debug("paste image dir create failed", e);
18034
18452
  }
18035
18453
  const img = grabClipboardImage(dir, String(Date.now()));
18036
18454
  return img ? { display: "Image", ref: "@" + img.path, path: img.path } : null;
@@ -18143,7 +18561,7 @@ Added entries are loadable now via the Skill/SlashCommand tools; removed ones ar
18143
18561
  mkdirSync12(join15(cwd, ".agent"), { recursive: true });
18144
18562
  appendFileSync(histPath, line + "\n");
18145
18563
  } catch (e) {
18146
- log34.debug("history write failed", e);
18564
+ log35.debug("history write failed", e);
18147
18565
  }
18148
18566
  };
18149
18567
  const ago = (t) => {
@@ -18215,7 +18633,7 @@ Added entries are loadable now via the Skill/SlashCommand tools; removed ones ar
18215
18633
  try {
18216
18634
  store.save(session);
18217
18635
  } catch (e) {
18218
- log34.debug("session save after rewind failed", e);
18636
+ log35.debug("session save after rewind failed", e);
18219
18637
  }
18220
18638
  err(green(" \u27F2 jumped back") + dim(` \u2014 ${face.transcript.length} message(s) kept; edit + resend
18221
18639
  `));
@@ -18250,7 +18668,7 @@ ${task}`;
18250
18668
  bangContext.length = 0;
18251
18669
  }
18252
18670
  const delta = await refreshCatalogs().catch((e) => {
18253
- log34.debug("catalog refresh failed", e);
18671
+ log35.debug("catalog refresh failed", e);
18254
18672
  return "";
18255
18673
  });
18256
18674
  if (delta) {
@@ -18513,7 +18931,7 @@ ${task}`;
18513
18931
  desc: "rescan skills/commands dirs and rebuild the system prompt (one cache miss) \u2014 picks up entries created mid-session",
18514
18932
  run: async () => {
18515
18933
  await refreshCatalogs().catch((e) => {
18516
- log34.debug("catalog refresh failed", e);
18934
+ log35.debug("catalog refresh failed", e);
18517
18935
  });
18518
18936
  face.reprepare();
18519
18937
  err(green(` \u2713 reloaded \u2014 ${skills.length} skill(s), ${cmds.length} command(s); system prompt rebuilds on next message
@@ -18641,7 +19059,7 @@ ${task}`;
18641
19059
  return;
18642
19060
  }
18643
19061
  const current = dx.options.thinkModel === false ? void 0 : dx.options.thinkModel;
18644
- const picked = await pickModel(current ?? "anthropic/claude-opus-4-6");
19062
+ const picked = await pickModel(current ?? latestClaude("opus"));
18645
19063
  if (picked) applyThinkModel(picked);
18646
19064
  else err(dim(` think ${dx.options.thinkModel === false ? "off" : dx.options.thinkModel}
18647
19065
  `));
@@ -18997,7 +19415,7 @@ ${task}`;
18997
19415
  await fs2.writeFile(p, [
18998
19416
  "---",
18999
19417
  `description: What "${name}" is for \u2014 the Task tool picks agents by this line`,
19000
- "# model: anthropic/claude-haiku-4-5 # optional per-agent model override",
19418
+ "# model: anthropic/haiku # optional per-agent model override",
19001
19419
  "# tools: Read, Grep, Glob # optional tool allowlist (omit = all tools)",
19002
19420
  "---",
19003
19421
  "",
@@ -19016,7 +19434,7 @@ ${task}`;
19016
19434
  try {
19017
19435
  for (const def of (await loadAgents(fs2, d)).agents) if (!seen.has(def.name)) seen.set(def.name, { def, from: d });
19018
19436
  } catch (e) {
19019
- log34.debug(`loadAgents(${d}) failed`, e);
19437
+ log35.debug(`loadAgents(${d}) failed`, e);
19020
19438
  }
19021
19439
  }
19022
19440
  if (!seen.size) {
@@ -19104,7 +19522,7 @@ ${task}`;
19104
19522
  }
19105
19523
  if (idx >= 0) {
19106
19524
  const old = mounted.splice(idx, 1)[0];
19107
- await old.client.close().catch((e) => log34.debug("mcp close failed", e));
19525
+ await old.client.close().catch((e) => log35.debug("mcp close failed", e));
19108
19526
  }
19109
19527
  try {
19110
19528
  const m = await mountMcpServer(name, conf);
@@ -19132,7 +19550,7 @@ ${task}`;
19132
19550
  }
19133
19551
  const m = mounted.splice(idx, 1)[0];
19134
19552
  remountMcpTools();
19135
- await m.client.close().catch((e) => log34.debug("mcp close failed", e));
19553
+ await m.client.close().catch((e) => log35.debug("mcp close failed", e));
19136
19554
  err(dim(` removed "${name}"
19137
19555
  `));
19138
19556
  return;
@@ -19335,7 +19753,7 @@ ${task}`;
19335
19753
  try {
19336
19754
  return readdirSync4(join15(cwd, absDir.replace(/^\/+/, "")), { withFileTypes: true }).map((d) => ({ name: d.name, dir: d.isDirectory() }));
19337
19755
  } catch (e) {
19338
- log34.debug("completion readdir failed", absDir, e);
19756
+ log35.debug("completion readdir failed", absDir, e);
19339
19757
  return null;
19340
19758
  }
19341
19759
  };
@@ -19472,7 +19890,7 @@ ${out}
19472
19890
  return;
19473
19891
  }
19474
19892
  await refreshCatalogs().catch((e) => {
19475
- log34.debug("catalog refresh failed", e);
19893
+ log35.debug("catalog refresh failed", e);
19476
19894
  });
19477
19895
  const sk = skills.find((x) => x.name === name);
19478
19896
  if (sk) {
@@ -19583,7 +20001,7 @@ ${out}
19583
20001
  if (delivery || cut) dx?.parkCutDelivery(delivery, !!cut, heard);
19584
20002
  const cutReply = cut && cut.full.length - cut.heard.length > 40 ? cut : null;
19585
20003
  const noteP = replaces ? replaces.note : !cutReply ? Promise.resolve("") : (instinct ? instinct.bargeIntent(text).catch((e) => {
19586
- log34.warn(`barge intent failed \u2014 model decides: ${e.message}`);
20004
+ log35.warn(`barge intent failed \u2014 model decides: ${e.message}`);
19587
20005
  return void 0;
19588
20006
  }) : Promise.resolve(void 0)).then(async (intent) => interruptionNote(cutReply, intent) + ackedNote(await instinct?.ackFor(text)));
19589
20007
  const cutIn = replaces ? replaces.cutIn : interjectNote;
@@ -19678,6 +20096,14 @@ ${out}
19678
20096
  };
19679
20097
  try {
19680
20098
  await voiceIO.start();
20099
+ if (args.duplexV2 && dx) {
20100
+ const v = voiceIO;
20101
+ bindSecondChannel(v, makeSecondChannel(ai, dx.options.reflexModel, { show: (t) => {
20102
+ err("\r\x1B[0J" + dim(` \u21B3 ${t}
20103
+ `));
20104
+ editorRef?.redrawNow();
20105
+ } }), () => voiceIO === v);
20106
+ }
19681
20107
  const inDev = voiceIO.usingAec ? detectedInputDevice() : null;
19682
20108
  const inNote = inDev ? `, in: ${inDev.name}` : "";
19683
20109
  err(dim(` \u{1F3A4} voice on (${voiceIO.usingAec ? "echo-cancelled" : "heuristic echo \u2014 headphones recommended"}${inNote}) \u2014 just talk; speak over it to interrupt; say "voice off" to stop
@@ -19928,6 +20354,16 @@ function duplexError(e, reflexModel) {
19928
20354
  return code && !msg.includes(String(code)) ? `${msg} (${code})` : msg;
19929
20355
  }
19930
20356
  var leakDecider = () => process.env.TYPESAFE_AI_KEY ? new DecideClient({ apiKey: process.env.TYPESAFE_AI_KEY }) : void 0;
20357
+ function makeSecondChannel(ai, reflexModel, opts) {
20358
+ const decider = leakDecider();
20359
+ const instinct = decider ? new Instinct({ decider }) : void 0;
20360
+ return new SecondChannel({
20361
+ decide: instinct ? (heard, state) => instinct.channel(heard, state) : void 0,
20362
+ generate: fastLineGenerator(ai, process.env.AGENTX_CHANNEL_MODEL || reflexModel),
20363
+ onEvent: (e) => log35.debug(`channel ${e.trigger}: rule=${e.rule} choice=${e.choice ?? "-"} \u2192 ${e.acted}${e.text ? ` "${e.text}"` : ""} ${e.ms}ms`),
20364
+ ...opts
20365
+ });
20366
+ }
19931
20367
  async function duplexServe(args, ai, cfg, cwd) {
19932
20368
  const out = (o) => process.stdout.write(JSON.stringify(o) + "\n");
19933
20369
  const mounted = await mountMcp(cfg, new McpOAuth({ storePath: join15(cwd, ".agent", "mcp-auth.json") }));
@@ -19937,8 +20373,10 @@ async function duplexServe(args, ai, cfg, cwd) {
19937
20373
  const host = {
19938
20374
  notify(e) {
19939
20375
  const k = String(e.kind);
19940
- if (k === "text_delta") out({ type: "text", text: e.message });
19941
- else if (k === "thinking_delta") out({ type: "thinking", text: e.message });
20376
+ if (k === "text_delta") {
20377
+ out({ type: "text", text: e.message });
20378
+ sc?.onMainTokens(String(e.message ?? ""));
20379
+ } else if (k === "thinking_delta") out({ type: "thinking", text: e.message });
19942
20380
  else if (k === "speak_utterance") out({ type: "speak", text: e.message });
19943
20381
  else if (k === "hold_filler") out({ type: "filler", text: e.message });
19944
20382
  else if (k === "turn_replace") out({ type: "replaced", ...e.data });
@@ -19970,6 +20408,8 @@ async function duplexServe(args, ai, cfg, cwd) {
19970
20408
  reflexOptions: { fs: agent.options.fs, tools: [exitSessionTool(() => out({ type: "exit" }))] }
19971
20409
  });
19972
20410
  if (args.voiceSystemPrompt) dx.voice.options.systemPrompt += "\n\n" + args.voiceSystemPrompt;
20411
+ let scTurn = 0;
20412
+ const sc = args.duplexV2 ? makeSecondChannel(ai, dx.options.reflexModel, { show: (text, kind) => out({ type: "channel", kind, text }) }) : void 0;
19973
20413
  const store = new SessionStore(cwd);
19974
20414
  const session = startSession(args, store, dx.voice, cwd);
19975
20415
  persist = () => {
@@ -19978,7 +20418,7 @@ async function duplexServe(args, ai, cfg, cwd) {
19978
20418
  try {
19979
20419
  store.save(session);
19980
20420
  } catch (e) {
19981
- log34.warn("duplex session save failed", e);
20421
+ log35.warn("duplex session save failed", e);
19982
20422
  }
19983
20423
  };
19984
20424
  out({ type: "ready", sessionId: session.meta.id, reflexModel: dx.options.reflexModel, actModel: dx.options.actModel, ...notes.length ? { notes } : {} });
@@ -19992,20 +20432,30 @@ async function duplexServe(args, ai, cfg, cwd) {
19992
20432
  return;
19993
20433
  }
19994
20434
  if (m.type === "barge") {
20435
+ sc?.onBargeIn();
19995
20436
  activeTurn?.abort();
19996
20437
  dx.parkInFlightDeliveries();
20438
+ } else if (sc && channelInput(sc, m)) {
19997
20439
  } else if (m.type === "cancel") out({ type: "task", kind: "cancel_result", id: m.task, message: dx.cancelTask(String(m.task)) });
19998
20440
  else if (m.type === "exit") rl.close();
19999
20441
  else if (m.type === "user" && typeof m.text === "string" && m.text.trim()) {
20000
- const { reply, delivery, heard } = splitCut(m.cut ?? null);
20442
+ const cut = m.cut && sc?.spoke(String(m.cut.full ?? "")) ? { ...m.cut, full: "", heard: "" } : m.cut;
20443
+ const { reply, delivery, heard } = splitCut(cut ?? null);
20001
20444
  if (delivery || reply) dx.parkCutDelivery(delivery, !!reply, heard);
20002
20445
  const note = reply && reply.full.length - reply.heard.length > 40 ? interruptionNote(reply) : "";
20003
20446
  const id = m.id ? String(m.id) : void 0;
20447
+ sc?.onUserTurn(m.text);
20448
+ const seq = ++scTurn;
20449
+ const done2 = () => {
20450
+ if (seq === scTurn) sc?.onAgentState("idle");
20451
+ };
20004
20452
  void runTurn(dx.voice, store, session, m.text + note, void 0, cwd, (c) => dx.send(c, { id, replaces: m.replaces ? String(m.replaces) : void 0, followUp: typeof m.followUp === "string" || m.followUp && typeof m.followUp === "object" ? m.followUp : void 0 })).then(({ res, error }) => {
20005
20453
  const why = (res?.finishReason ?? "error") === "error" ? duplexError(res?.error ?? error, dx.options.reflexModel) : "";
20454
+ done2();
20006
20455
  out({ type: "turn_done", id, finishReason: res?.finishReason ?? "error", ...why ? { error: why } : {} });
20007
20456
  }).catch((e) => {
20008
- log34.error("duplex turn failed", e);
20457
+ log35.error("duplex turn failed", e);
20458
+ done2();
20009
20459
  out({ type: "turn_done", id, finishReason: "error", error: String(e?.message ?? e) });
20010
20460
  });
20011
20461
  }