agent.libx.js 0.93.17 → 0.93.18

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
@@ -3575,6 +3575,17 @@ var DuplexAgent = class {
3575
3575
  seq = 0;
3576
3576
  pendingEvents = [];
3577
3577
  flushQueued = false;
3578
+ /** Per-voice-turn guards (reset by resetTurn at each turn's start). The reflex is a weak model:
3579
+ * left unguarded it polls TaskStatus after a dispatch and/or dispatches silently (dead air).
3580
+ * Like CC's Task tool, a dispatch is "said my piece, now wait for the push" — these enforce that. */
3581
+ turnDispatched = false;
3582
+ // an Act/Think fired this turn
3583
+ turnBriefs = /* @__PURE__ */ new Set();
3584
+ // briefs dispatched this turn (detect identical re-dispatch)
3585
+ spokeThisTurn = false;
3586
+ // any non-empty text_delta streamed this turn
3587
+ nudging = false;
3588
+ // re-ack pass in flight: block ALL tools, prevent recursion
3578
3589
  /** Parked worker questions awaiting a (voice-relayed) user answer, keyed by ask id. */
3579
3590
  pendingAsks = /* @__PURE__ */ new Map();
3580
3591
  /** Lazily resolved memory tools (async loadMemory runs in initMemory). */
@@ -3599,12 +3610,21 @@ Today's date: ${(/* @__PURE__ */ new Date()).toDateString()}.`;
3599
3610
  this.answerTaskTool(),
3600
3611
  this.holdTool()
3601
3612
  ];
3613
+ const host = o.host;
3614
+ const voiceHost = host && {
3615
+ ask: host.ask ? (q2) => host.ask(q2) : void 0,
3616
+ confirm: host.confirm ? (p, m) => host.confirm(p, m) : void 0,
3617
+ notify: (ev) => {
3618
+ if (ev?.kind === "text_delta" && typeof ev.message === "string" && ev.message.trim()) this.spokeThisTurn = true;
3619
+ host.notify?.(ev);
3620
+ }
3621
+ };
3602
3622
  this.voice = new Agent({
3603
3623
  ai: o.ai,
3604
3624
  fs: new MemFilesystem2(),
3605
3625
  model: o.reflexModel,
3606
3626
  stream: true,
3607
- host: o.host,
3627
+ host: voiceHost,
3608
3628
  // The reflex IS the conversational channel — it confirms ambiguity inline ("did you mean…?"),
3609
3629
  // never via the blocking AskUserQuestion tool (Agent auto-adds it whenever a host is set). Left in,
3610
3630
  // it stalls a voice turn until the kill-switch. Worker questions still reach the user via parkQuestion.
@@ -3614,7 +3634,9 @@ Today's date: ${(/* @__PURE__ */ new Date()).toDateString()}.`;
3614
3634
  maxSteps: 8,
3615
3635
  timeoutMs: 3e4,
3616
3636
  ...o.reflexOptions,
3617
- tools
3637
+ tools,
3638
+ // Composed AFTER the spread so the dispatch guard can't be dropped by reflexOptions.
3639
+ hooks: composeHooks(this.dispatchGuard(), o.reflexOptions?.hooks)
3618
3640
  });
3619
3641
  }
3620
3642
  /** Resolve memory tools + inject index into voice system prompt (once). */
@@ -3625,11 +3647,54 @@ Today's date: ${(/* @__PURE__ */ new Date()).toDateString()}.`;
3625
3647
  this.voice.options.tools.push(...mem.tools);
3626
3648
  if (mem.index) this.voice.options.systemPrompt += "\n\n" + mem.index;
3627
3649
  }
3650
+ /** Clear the per-turn guards. Called at the head of every voice turn (user send + re-voice flush). */
3651
+ resetTurn() {
3652
+ this.turnDispatched = false;
3653
+ this.turnBriefs.clear();
3654
+ this.spokeThisTurn = false;
3655
+ }
3656
+ /** preToolUse guard on the reflex: once it has dispatched this turn, a dispatch is "said my piece,
3657
+ * now wait for the push" (CC's Task model). Block the temptations — TaskStatus polling and identical
3658
+ * re-dispatch — so the only remaining move is to voice a short ack and end. A genuinely NEW Act is
3659
+ * still allowed (parallel independent work). During a re-ack pass, block every tool. */
3660
+ dispatchGuard() {
3661
+ return {
3662
+ preToolUse: (call) => {
3663
+ if (this.nudging) return { block: true, reason: "Just say one short spoken acknowledgement \u2014 no tools this turn." };
3664
+ if (!this.turnDispatched) return;
3665
+ if (call.name === "TaskStatus")
3666
+ return { block: true, reason: "You just dispatched a task this turn \u2014 do NOT poll. Give one short spoken acknowledgement and end your turn; the result arrives later as a [task \u2026] event." };
3667
+ if ((call.name === "Act" || call.name === "Think") && this.turnBriefs.has(String(call.args?.brief ?? "")))
3668
+ return { block: true, reason: "You already dispatched this exact task \u2014 acknowledge briefly and end your turn." };
3669
+ }
3670
+ };
3671
+ }
3672
+ /** True when the just-finished turn dispatched a task but voiced nothing — dead air to repair.
3673
+ * Requires a host: without one there's no stream to detect speech on (and no one to speak to). */
3674
+ get silentDispatch() {
3675
+ return !!this.options.host && this.turnDispatched && !this.spokeThisTurn;
3676
+ }
3677
+ /** A dispatch with no spoken text is dead air. Re-prompt the reflex ONCE so the LLM itself voices a
3678
+ * short ack (no template). If it STILL says nothing, fall back to a minimal line so silence never ships. */
3679
+ async ackIfSilent() {
3680
+ this.nudging = true;
3681
+ try {
3682
+ await this.voice.send("[reminder] You dispatched a task but said nothing to the user. Say ONE short spoken acknowledgement now \u2014 no tools.");
3683
+ } catch (e) {
3684
+ log6.warn(`ack nudge failed: ${e instanceof Error ? e.message : e}`);
3685
+ } finally {
3686
+ this.nudging = false;
3687
+ }
3688
+ if (!this.spokeThisTurn) this.options.host?.notify?.({ kind: "text_delta", message: "Okay, on it." });
3689
+ }
3628
3690
  /** One user turn: the voice agent streams the reply (and may Act/Think). Serialized with re-voice turns. */
3629
3691
  send(content) {
3630
3692
  return this.enqueue(async () => {
3631
3693
  await this.initMemory();
3632
- return this.voice.send(content);
3694
+ this.resetTurn();
3695
+ const res = await this.voice.send(content);
3696
+ if (this.silentDispatch) await this.ackIfSilent();
3697
+ return res;
3633
3698
  });
3634
3699
  }
3635
3700
  /** Resolve when all queued voice turns AND all in-flight worker tasks have settled (tests, graceful shutdown). */
@@ -3662,7 +3727,9 @@ Today's date: ${(/* @__PURE__ */ new Date()).toDateString()}.`;
3662
3727
  this.flushQueued = false;
3663
3728
  const events = this.pendingEvents.splice(0);
3664
3729
  if (!events.length) return;
3730
+ this.resetTurn();
3665
3731
  await this.voice.send(events.join("\n"));
3732
+ if (this.silentDispatch) await this.ackIfSilent();
3666
3733
  this.notify("revoice_done", "");
3667
3734
  });
3668
3735
  }
@@ -3895,6 +3962,8 @@ Another agent just implemented the above. Independently check the CURRENT state
3895
3962
  }
3896
3963
  },
3897
3964
  run: async ({ brief, label }) => {
3965
+ this.turnDispatched = true;
3966
+ this.turnBriefs.add(String(brief ?? ""));
3898
3967
  const id = await this.dispatch(String(brief ?? ""), "act", label ? String(label) : void 0);
3899
3968
  return `Acting on task ${id}. Acknowledge briefly; the result will arrive as a [task ${id} completed] event.`;
3900
3969
  }
@@ -3913,6 +3982,8 @@ Another agent just implemented the above. Independently check the CURRENT state
3913
3982
  }
3914
3983
  },
3915
3984
  run: async ({ brief, label }) => {
3985
+ this.turnDispatched = true;
3986
+ this.turnBriefs.add(String(brief ?? ""));
3916
3987
  const id = await this.dispatch(String(brief ?? ""), "think", label ? String(label) : void 0);
3917
3988
  return `Thinking on task ${id}. Acknowledge briefly; the result will arrive as a [task ${id} completed] event.`;
3918
3989
  }
@@ -4781,6 +4852,7 @@ import { MemFilesystem as MemFilesystem3, IndexedDbFilesystem, CommandExecutor a
4781
4852
  // src/mcp.client.ts
4782
4853
  init_logging();
4783
4854
  import { spawn } from "child_process";
4855
+ import { createHash } from "crypto";
4784
4856
  var log10 = forComponent("mcp");
4785
4857
  var PROTOCOL_VERSION = "2025-06-18";
4786
4858
  var DEFAULT_TIMEOUT_MS = 3e4;
@@ -4973,37 +5045,136 @@ var McpClient = class {
4973
5045
  await this.transport.close();
4974
5046
  }
4975
5047
  };
4976
- async function mountMcpServer(name, cfg) {
4977
- const transport = cfg.url ? new HttpTransport({ url: cfg.url, headers: cfg.headers, bearerToken: cfg.bearerToken, timeoutMs: cfg.timeoutMs }) : new StdioTransport({ command: cfg.command, args: cfg.args, env: cfg.env, cwd: cfg.cwd, timeoutMs: cfg.timeoutMs });
4978
- const client = new McpClient(transport);
4979
- const init = await client.connect();
4980
- const specs = await client.listTools();
4981
- const tools = mcpToolsToAgentTools(specs, (tool, a) => client.callTool(tool, a), `mcp__${name}__`);
4982
- return { name, client, tools, specs, serverInfo: init?.serverInfo };
4983
- }
4984
- async function mountMcpServers(servers = {}) {
4985
- const out = [];
4986
- for (const [name, cfg] of Object.entries(servers)) {
4987
- if (!cfg || cfg.disabled) continue;
5048
+ function buildTransport(cfg) {
5049
+ return cfg.url ? new HttpTransport({ url: cfg.url, headers: cfg.headers, bearerToken: cfg.bearerToken, timeoutMs: cfg.timeoutMs }) : new StdioTransport({ command: cfg.command, args: cfg.args, env: cfg.env, cwd: cfg.cwd, timeoutMs: cfg.timeoutMs });
5050
+ }
5051
+ function withTimeout(p, ms, label) {
5052
+ if (!ms || ms <= 0) return p;
5053
+ return new Promise((resolve4, reject) => {
5054
+ const timer = setTimeout(() => reject(new Error(`MCP "${label}" mount exceeded ${ms}ms`)), ms);
5055
+ timer.unref?.();
5056
+ p.then((v) => {
5057
+ clearTimeout(timer);
5058
+ resolve4(v);
5059
+ }, (e) => {
5060
+ clearTimeout(timer);
5061
+ reject(e);
5062
+ });
5063
+ });
5064
+ }
5065
+ async function mountWithDeadline(name, cfg, mountTimeoutMs) {
5066
+ const client = new McpClient(buildTransport(cfg));
5067
+ try {
5068
+ return await withTimeout((async () => {
5069
+ const init = await client.connect();
5070
+ const specs = await client.listTools();
5071
+ const tools = mcpToolsToAgentTools(specs, (tool, a) => client.callTool(tool, a), `mcp__${name}__`);
5072
+ return { name, client, tools, specs, serverInfo: init?.serverInfo };
5073
+ })(), mountTimeoutMs, name);
5074
+ } catch (e) {
5075
+ await client.close().catch((err2) => log10.debug(`close after failed mount of "${name}": ${err2}`));
5076
+ throw e;
5077
+ }
5078
+ }
5079
+ function mountMcpServer(name, cfg) {
5080
+ return mountWithDeadline(name, cfg);
5081
+ }
5082
+ function validEntries(servers) {
5083
+ return Object.entries(servers).filter(([name, cfg]) => {
5084
+ if (!cfg || cfg.disabled) return false;
4988
5085
  if (!cfg.command && !cfg.url) {
4989
5086
  log10.warn(`MCP server "${name}" needs a command (stdio) or url (http) \u2014 skipping`);
4990
- continue;
4991
- }
4992
- try {
4993
- const m = await mountMcpServer(name, cfg);
4994
- out.push(m);
4995
- log10.info(`MCP "${name}" mounted \u2014 ${m.tools.length} tool(s)${m.serverInfo?.name ? ` from ${m.serverInfo.name}` : ""}`);
4996
- } catch (e) {
4997
- if (e instanceof McpAuthError) log10.warn(`MCP "${name}" needs-auth: HTTP ${e.status} \u2014 set bearerToken or headers in its config; skipping`);
4998
- else log10.error(`MCP server "${name}" failed to mount: ${e?.message ?? e}`);
5087
+ return false;
4999
5088
  }
5000
- }
5089
+ return true;
5090
+ });
5091
+ }
5092
+ function logMountFailure(name, e) {
5093
+ if (e instanceof McpAuthError) log10.warn(`MCP "${name}" needs-auth: HTTP ${e.status} \u2014 set bearerToken or headers in its config; skipping`);
5094
+ else log10.error(`MCP server "${name}" failed to mount: ${e?.message ?? e}`);
5095
+ }
5096
+ async function mountMcpServers(servers = {}, opts = {}) {
5097
+ const entries = validEntries(servers);
5098
+ const settled = await Promise.allSettled(entries.map(([name, cfg]) => mountWithDeadline(name, cfg, opts.mountTimeoutMs)));
5099
+ const out = [];
5100
+ settled.forEach((r, i) => {
5101
+ const name = entries[i][0];
5102
+ if (r.status === "fulfilled") {
5103
+ out.push(r.value);
5104
+ log10.info(`MCP "${name}" mounted \u2014 ${r.value.tools.length} tool(s)${r.value.serverInfo?.name ? ` from ${r.value.serverInfo.name}` : ""}`);
5105
+ } else logMountFailure(name, r.reason);
5106
+ });
5001
5107
  return out;
5002
5108
  }
5109
+ var MemMcpCatalog = class {
5110
+ constructor(ttlMs = 5 * 6e4) {
5111
+ this.ttlMs = ttlMs;
5112
+ }
5113
+ ttlMs;
5114
+ m = /* @__PURE__ */ new Map();
5115
+ get(key) {
5116
+ const e = this.m.get(key);
5117
+ if (!e) return null;
5118
+ if (Date.now() > e.exp) {
5119
+ this.m.delete(key);
5120
+ return null;
5121
+ }
5122
+ return e.specs;
5123
+ }
5124
+ set(key, specs) {
5125
+ this.m.set(key, { specs, exp: Date.now() + this.ttlMs });
5126
+ }
5127
+ };
5128
+ var McpPool = class {
5129
+ constructor(ttlMs = 5 * 6e4) {
5130
+ this.ttlMs = ttlMs;
5131
+ }
5132
+ ttlMs;
5133
+ warm = /* @__PURE__ */ new Map();
5134
+ get(key) {
5135
+ const e = this.warm.get(key);
5136
+ if (!e) return null;
5137
+ this.arm(key, e);
5138
+ return e.client;
5139
+ }
5140
+ put(key, client) {
5141
+ const prev = this.warm.get(key);
5142
+ if (prev) {
5143
+ clearTimeout(prev.timer);
5144
+ if (prev.client !== client) void prev.client.close().catch((err2) => log10.debug(`warm-pool replace close failed: ${err2}`));
5145
+ }
5146
+ const e = { client, timer: void 0 };
5147
+ this.warm.set(key, e);
5148
+ this.arm(key, e);
5149
+ }
5150
+ arm(key, e) {
5151
+ clearTimeout(e.timer);
5152
+ e.timer = setTimeout(() => {
5153
+ void this.evict(key);
5154
+ }, this.ttlMs);
5155
+ e.timer.unref?.();
5156
+ }
5157
+ async evict(key) {
5158
+ const e = this.warm.get(key);
5159
+ if (!e) return;
5160
+ this.warm.delete(key);
5161
+ await e.client.close().catch((err2) => log10.debug(`warm-pool evict close failed: ${err2}`));
5162
+ }
5163
+ async closeAll() {
5164
+ for (const e of this.warm.values()) {
5165
+ clearTimeout(e.timer);
5166
+ await e.client.close().catch(() => {
5167
+ });
5168
+ }
5169
+ this.warm.clear();
5170
+ }
5171
+ };
5172
+ var defaultCatalog = new MemMcpCatalog();
5173
+ var defaultPool = new McpPool();
5003
5174
 
5004
5175
  // cli/mcpOAuth.ts
5005
5176
  import { createServer } from "http";
5006
- import { randomBytes, createHash } from "crypto";
5177
+ import { randomBytes, createHash as createHash2 } from "crypto";
5007
5178
  import { readFileSync, writeFileSync as writeFileSync2, mkdirSync, existsSync } from "fs";
5008
5179
  import { dirname as dirname2 } from "path";
5009
5180
  var McpOAuthOptions = class {
@@ -5047,7 +5218,7 @@ var McpOAuth = class {
5047
5218
  const meta = await this.discover(serverUrl);
5048
5219
  const clientId = this.options.clientId ?? await this.registerClient(meta, serverUrl);
5049
5220
  const verifier = b64url(randomBytes(32));
5050
- const challenge = b64url(createHash("sha256").update(verifier).digest());
5221
+ const challenge = b64url(createHash2("sha256").update(verifier).digest());
5051
5222
  const state = b64url(randomBytes(16));
5052
5223
  const { code, redirectUri } = await this.captureCode(meta.authorization_endpoint, clientId, challenge, state);
5053
5224
  const tok = await this.exchange(meta.token_endpoint, {