mixdog 0.9.22 → 0.9.24

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.
Files changed (132) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/boot-smoke.mjs +1 -1
  4. package/scripts/channel-daemon-smoke.mjs +483 -0
  5. package/scripts/channel-daemon-stub.mjs +80 -0
  6. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  7. package/scripts/explore-bench-tmp.mjs +17 -0
  8. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  9. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  10. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  11. package/scripts/tool-smoke.mjs +68 -47
  12. package/src/rules/agent/30-explorer.md +6 -0
  13. package/src/rules/shared/01-tool.md +11 -4
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  15. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  17. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +226 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
  19. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  20. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  21. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  24. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  27. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  28. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  29. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  31. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  32. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
  33. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  34. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  35. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  36. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  37. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  38. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  39. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  40. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  41. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  42. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  43. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
  45. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  46. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  47. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  48. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  49. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  50. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +126 -22
  51. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  52. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  53. package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
  54. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
  55. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  56. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  57. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  58. package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
  59. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  60. package/src/runtime/channels/lib/worker-main.mjs +42 -30
  61. package/src/runtime/memory/index.mjs +54 -7
  62. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  63. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  64. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  65. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  66. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  67. package/src/runtime/memory/tool-defs.mjs +1 -1
  68. package/src/runtime/shared/atomic-file.mjs +150 -6
  69. package/src/runtime/shared/background-tasks.mjs +1 -1
  70. package/src/runtime/shared/config.mjs +53 -1
  71. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  72. package/src/runtime/shared/tool-primitives.mjs +31 -1
  73. package/src/runtime/shared/tool-surface.mjs +42 -13
  74. package/src/runtime/shared/update-checker.mjs +3 -0
  75. package/src/runtime/shared/user-data-guard.mjs +66 -0
  76. package/src/session-runtime/config-lifecycle.mjs +221 -20
  77. package/src/session-runtime/lifecycle-api.mjs +9 -0
  78. package/src/session-runtime/mcp-glue.mjs +93 -1
  79. package/src/session-runtime/resource-api.mjs +62 -8
  80. package/src/session-runtime/runtime-core.mjs +118 -4
  81. package/src/session-runtime/session-text.mjs +41 -0
  82. package/src/session-runtime/session-turn-api.mjs +50 -0
  83. package/src/session-runtime/settings-api.mjs +8 -1
  84. package/src/session-runtime/tool-catalog.mjs +350 -38
  85. package/src/session-runtime/tool-defs.mjs +7 -7
  86. package/src/session-runtime/workflow.mjs +2 -1
  87. package/src/standalone/channel-admin.mjs +32 -3
  88. package/src/standalone/channel-daemon-client.mjs +226 -0
  89. package/src/standalone/channel-daemon-transport.mjs +545 -0
  90. package/src/standalone/channel-daemon.mjs +176 -0
  91. package/src/standalone/channel-worker.mjs +224 -4
  92. package/src/standalone/explore-tool.mjs +87 -15
  93. package/src/standalone/hook-bus.mjs +71 -3
  94. package/src/tui/App.jsx +107 -19
  95. package/src/tui/app/clipboard.mjs +39 -19
  96. package/src/tui/app/doctor.mjs +57 -0
  97. package/src/tui/app/extension-pickers.mjs +53 -9
  98. package/src/tui/app/maintenance-pickers.mjs +0 -5
  99. package/src/tui/app/slash-dispatch.mjs +4 -4
  100. package/src/tui/app/text-layout.mjs +11 -0
  101. package/src/tui/app/use-mouse-input.mjs +235 -51
  102. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  103. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  104. package/src/tui/app/use-transcript-window.mjs +55 -1
  105. package/src/tui/components/Message.jsx +1 -1
  106. package/src/tui/components/PromptInput.jsx +3 -1
  107. package/src/tui/components/QueuedCommands.jsx +21 -10
  108. package/src/tui/components/StatusLine.jsx +3 -3
  109. package/src/tui/components/ToolExecution.jsx +16 -4
  110. package/src/tui/components/TranscriptItem.jsx +1 -1
  111. package/src/tui/dist/index.mjs +820 -326
  112. package/src/tui/engine/agent-job-feed.mjs +5 -0
  113. package/src/tui/engine/notification-plan.mjs +5 -0
  114. package/src/tui/engine/session-api.mjs +23 -8
  115. package/src/tui/engine/session-flow.mjs +6 -0
  116. package/src/tui/engine/tool-card-results.mjs +14 -5
  117. package/src/tui/engine/turn.mjs +9 -2
  118. package/src/tui/engine.mjs +32 -5
  119. package/src/tui/index.jsx +62 -18
  120. package/src/tui/paste-attachments.mjs +26 -0
  121. package/src/ui/statusline-agents.mjs +36 -0
  122. package/src/ui/statusline.mjs +60 -10
  123. package/src/ui/tool-card.mjs +8 -1
  124. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  125. package/src/workflows/bench/WORKFLOW.md +46 -0
  126. package/src/workflows/default/WORKFLOW.md +6 -0
  127. package/src/workflows/solo/WORKFLOW.md +5 -0
  128. package/vendor/ink/build/ink.js +23 -1
  129. package/vendor/ink/build/output.js +154 -71
  130. package/vendor/ink/build/render-node-to-output.js +44 -2
  131. package/vendor/ink/build/render.js +4 -0
  132. package/vendor/ink/build/renderer.js +4 -1
@@ -4,6 +4,7 @@ import { join } from 'path';
4
4
  import { tmpdir } from 'os';
5
5
  import { randomUUID } from 'crypto';
6
6
  import { smartReadTruncate } from '../tools/builtin/read-formatting.mjs';
7
+ import { shutdownStdioChild, killStdioChildTreeFast } from './child-tree.mjs';
7
8
  // --- Types ---
8
9
  /** Known auto-detect targets: port file path relative to tmpdir.
9
10
  * Note: `mixdog` used to self-loopback via active-instance.json's
@@ -15,6 +16,8 @@ const AUTO_DETECT_PORTS = {
15
16
  'mixdog-memory': { dir: 'mixdog', file: 'active-instance.json', portField: 'memory_port', endpoint: '/mcp' },
16
17
  };
17
18
  const DEFAULT_MCP_CALL_TIMEOUT_MS = 0;
19
+ // Per-server STARTUP handshake budget (connect + listTools). Codex parity: 10s.
20
+ export const DEFAULT_MCP_STARTUP_TIMEOUT_MS = 10000;
18
21
  // --- State ---
19
22
  const servers = new Map();
20
23
  let mcpSdkPromise = null;
@@ -96,21 +99,31 @@ export function resolveMcpTransportKind(cfg) {
96
99
  * Supports stdio (child process) and http (Streamable HTTP) transports.
97
100
  */
98
101
  export async function connectMcpServers(config) {
102
+ // Capture the abort generation SYNCHRONOUSLY at entry: the boot path fires
103
+ // this un-awaited, so a runtime close can land while connectServer is still
104
+ // loading the SDK. A capture taken any later would already see the bumped
105
+ // generation and register the server anyway (leaking its stdio child).
106
+ const genAtStart = _connectAbortGeneration;
99
107
  const failures = [];
100
- for (const [name, cfg] of Object.entries(config)) {
108
+ const entries = Object.entries(config).filter(([name, cfg]) => {
101
109
  if (cfg?.enabled === false) {
102
110
  mcpLog(`[mcp-client] Skipping disabled server "${name}"\n`);
103
- continue;
111
+ return false;
104
112
  }
105
- try {
106
- await connectServer(name, cfg);
107
- }
108
- catch (err) {
109
- const msg = err instanceof Error ? err.message : String(err);
110
- mcpLog(`[mcp-client] Failed to connect "${name}": ${msg}\n`);
111
- failures.push({ name, msg });
112
- }
113
- }
113
+ return true;
114
+ });
115
+ // Connect all servers in PARALLEL: a slow/hung server (bounded by its
116
+ // per-server startup timeout) must never delay the others' handshakes.
117
+ const settled = await Promise.allSettled(
118
+ entries.map(([name, cfg]) => connectServer(name, cfg, genAtStart)),
119
+ );
120
+ settled.forEach((res, i) => {
121
+ if (res.status !== 'rejected') return;
122
+ const [name] = entries[i];
123
+ const msg = res.reason instanceof Error ? res.reason.message : String(res.reason);
124
+ mcpLog(`[mcp-client] Failed to connect "${name}": ${msg}\n`);
125
+ failures.push({ name, msg });
126
+ });
114
127
  if (failures.length > 0) {
115
128
  const detail = failures.map(f => `${f.name}: ${f.msg}`).join('; ');
116
129
  const err = new Error(`[mcp-client] ${failures.length} MCP server(s) failed to connect — ${detail}`);
@@ -182,7 +195,7 @@ export async function executeMcpTool(name, args) {
182
195
  mcpLog(`[mcp-client] Tool call failed, attempting reconnect...\n`);
183
196
  await new Promise(r => setTimeout(r, 500));
184
197
  try {
185
- await server.client.close();
198
+ await _closeServer(server);
186
199
  } catch { /* ignore close error */ }
187
200
  try {
188
201
  await connectServer(serverName, server.cfg);
@@ -233,6 +246,26 @@ export function isMcpToolCallTimeoutError(err) {
233
246
  return err?.code === 'EMCPTOOLTIMEOUT';
234
247
  }
235
248
 
249
+ // MCP per-server STARTUP timeout: bounds the connect + listTools handshake so a
250
+ // slow or hung server can't stall boot or the first turn. Default 10s (codex
251
+ // parity). Per-server override: startupTimeoutMs / startupTimeoutSec. Global
252
+ // env: MIXDOG_MCP_STARTUP_TIMEOUT_MS. A value of 0/off/none/false disables it.
253
+ export function resolveMcpStartupTimeoutMs(cfg = {}, env = process.env) {
254
+ const rawMs = cfg?.startupTimeoutMs ?? cfg?.startup_timeout_ms;
255
+ const rawSec = cfg?.startupTimeoutSec ?? cfg?.startup_timeout_sec;
256
+ const rawEnv = env?.MIXDOG_MCP_STARTUP_TIMEOUT_MS;
257
+ let raw;
258
+ let scale = 1;
259
+ if (rawMs != null && rawMs !== '') raw = rawMs;
260
+ else if (rawSec != null && rawSec !== '') { raw = rawSec; scale = 1000; }
261
+ else if (rawEnv != null && rawEnv !== '') raw = rawEnv;
262
+ else return DEFAULT_MCP_STARTUP_TIMEOUT_MS;
263
+ if (raw === 0 || (typeof raw === 'string' && /^(0|off|none|false)$/i.test(raw.trim()))) return 0;
264
+ const parsed = Number(raw) * scale;
265
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MCP_STARTUP_TIMEOUT_MS;
266
+ return Math.round(parsed);
267
+ }
268
+
236
269
  async function _callToolWithTimeout(server, toolName, args) {
237
270
  let timer;
238
271
  const timeoutMs = resolveMcpCallTimeoutMs(server?.cfg);
@@ -241,7 +274,9 @@ async function _callToolWithTimeout(server, toolName, args) {
241
274
  }
242
275
  const timeout = new Promise((_, rej) => {
243
276
  timer = setTimeout(() => {
244
- try { server.client.close().catch(() => {}); } catch { /* ignore */ }
277
+ // Route through the full tree-shutdown path so a timed-out stdio
278
+ // server never orphans grandchildren. Fire-and-forget.
279
+ try { _closeServer(server).catch(() => {}); } catch { /* ignore */ }
245
280
  const err = new Error(`MCP tool call timed out after ${timeoutMs}ms (server="${server.name}", tool="${toolName}")`);
246
281
  err.code = 'EMCPTOOLTIMEOUT';
247
282
  err.serverName = server.name;
@@ -336,16 +371,72 @@ export function mcpToolHasField(name, field) {
336
371
  * Disconnect all MCP servers.
337
372
  */
338
373
  export async function disconnectAll() {
374
+ // Abort handshakes still in flight: bump the generation so a connect that
375
+ // completes after this point tears itself down instead of registering, and
376
+ // reap any already-spawned stdio child now so its ref'd ChildProcess handle
377
+ // can't keep the event loop alive (close-during-connect previously leaked
378
+ // the uvx/npx wrapper tree and hung process exit).
379
+ _connectAbortGeneration++;
380
+ for (const entry of [..._pendingConnects]) {
381
+ _pendingConnects.delete(entry);
382
+ // Mid-handshake child: nothing to shut down gracefully — hard-kill the
383
+ // tree without holding the event loop (this path runs during process
384
+ // exit; the spec-order grace dance would delay exit by seconds).
385
+ try { killStdioChildTreeFast(entry.transport); }
386
+ catch { /* ignore */ }
387
+ try { void entry.client.close().catch(() => { /* ignore */ }); }
388
+ catch { /* ignore */ }
389
+ }
339
390
  for (const [name, server] of servers) {
340
391
  try {
341
- await server.client.close();
392
+ await _closeServer(server);
342
393
  }
343
394
  catch { /* ignore */ }
344
395
  servers.delete(name);
345
396
  }
346
397
  _invalidateMcpToolFieldMemo();
347
398
  }
348
- async function connectServer(name, cfg) {
399
+ /**
400
+ * Disconnect a single MCP server by name. No-op (returns false) when the
401
+ * server is not in the live registry; otherwise closes its transport, removes
402
+ * it, and invalidates the tool-field memo. Lets callers toggle one server
403
+ * without a full disconnectAll()/reconnect cycle.
404
+ */
405
+ export async function disconnectMcpServer(name) {
406
+ const server = servers.get(name);
407
+ if (!server) return false;
408
+ try {
409
+ await _closeServer(server);
410
+ }
411
+ catch { /* ignore */ }
412
+ servers.delete(name);
413
+ _invalidateMcpToolFieldMemo();
414
+ return true;
415
+ }
416
+ /**
417
+ * Close a single server: for stdio transports first shut down the full child
418
+ * process tree (close stdin -> grace -> tree kill) so wrapper chains such as
419
+ * uvx/npx/uv never orphan grandchildren, then release the SDK client. The
420
+ * tree teardown runs before client.close() because the SDK's own close()
421
+ * only kills the direct child and discards the pid we need to walk the tree.
422
+ */
423
+ async function _closeServer(server) {
424
+ const transport = server?.transport;
425
+ // Live stdio transports expose the spawned ChildProcess on _process.
426
+ if (transport && transport._process) {
427
+ try { await shutdownStdioChild(transport); }
428
+ catch { /* ignore */ }
429
+ }
430
+ try { await server.client.close(); }
431
+ catch { /* ignore */ }
432
+ }
433
+ // Connects whose handshake has not finished yet: disconnectAll() must be able
434
+ // to see (and tear down) their transports, because `servers` only lists fully
435
+ // handshaken entries. Generation token aborts a connect that outlives a
436
+ // disconnectAll() issued mid-handshake (runtime close during boot connect).
437
+ let _connectAbortGeneration = 0;
438
+ const _pendingConnects = new Set();
439
+ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
349
440
  const {
350
441
  Client,
351
442
  StdioClientTransport,
@@ -353,6 +444,11 @@ async function connectServer(name, cfg) {
353
444
  SSEClientTransport,
354
445
  WebSocketClientTransport,
355
446
  } = await loadMcpSdk();
447
+ if (genAtStart !== _connectAbortGeneration) {
448
+ // disconnectAll() ran while the SDK was loading: nothing spawned yet —
449
+ // abort before creating a transport/child at all.
450
+ throw new Error(`MCP server "${name}" connect aborted by shutdown`);
451
+ }
356
452
  const client = new Client({ name: `mixdog-agent/${name}`, version: '1.0.0' });
357
453
  let transport;
358
454
  const kind = resolveMcpTransportKind(cfg);
@@ -435,23 +531,78 @@ async function connectServer(name, cfg) {
435
531
  else {
436
532
  throw new Error(`Invalid config for "${name}": need autoDetect, type (stdio/http/sse/ws), url (http), or command (stdio)`);
437
533
  }
438
- await client.connect(transport);
439
- const instructionsRaw = typeof client.getInstructions === 'function'
440
- ? client.getInstructions()
441
- : undefined;
442
- const instructions = typeof instructionsRaw === 'string' ? instructionsRaw.trim() : '';
443
- const toolsResult = await client.listTools();
444
- if (!toolsResult || !Array.isArray(toolsResult.tools)) {
445
- throw new Error(`[mcp-client] ListTools returned invalid shape for "${name}": missing or non-array tools field`);
534
+ const pending = { name, client, transport };
535
+ _pendingConnects.add(pending);
536
+ try {
537
+ // Bound the connect + listTools handshake so a slow/hung server can't
538
+ // stall boot or the first turn. On expiry we tear down the pending
539
+ // transport/child (nothing leaks) and fail this server like any other
540
+ // connect failure — the parallel Promise.allSettled means other servers
541
+ // are unaffected.
542
+ const startupTimeoutMs = resolveMcpStartupTimeoutMs(cfg);
543
+ let startupTimer = null;
544
+ let startupTimedOut = false;
545
+ const handshake = (async () => {
546
+ await client.connect(transport);
547
+ const instructionsRaw = typeof client.getInstructions === 'function'
548
+ ? client.getInstructions()
549
+ : undefined;
550
+ const instr = typeof instructionsRaw === 'string' ? instructionsRaw.trim() : '';
551
+ const result = await client.listTools();
552
+ return { instructions: instr, toolsResult: result };
553
+ })();
554
+ let instructions;
555
+ let toolsResult;
556
+ try {
557
+ if (startupTimeoutMs > 0) {
558
+ const guard = new Promise((_, rej) => {
559
+ startupTimer = setTimeout(() => {
560
+ startupTimedOut = true;
561
+ const err = new Error(`MCP server "${name}" startup exceeded ${startupTimeoutMs}ms budget — raise per-server "startupTimeoutSec"/"startupTimeoutMs" or env MIXDOG_MCP_STARTUP_TIMEOUT_MS`);
562
+ err.code = 'EMCPSTARTUPTIMEOUT';
563
+ rej(err);
564
+ }, startupTimeoutMs);
565
+ });
566
+ ({ instructions, toolsResult } = await Promise.race([handshake, guard]));
567
+ } else {
568
+ ({ instructions, toolsResult } = await handshake);
569
+ }
570
+ } catch (err) {
571
+ if (startupTimedOut) {
572
+ // Tear down the pending transport/child so a hung handshake never
573
+ // leaks a stdio process or socket — fire-and-forget (like the
574
+ // tool-call timeout path) so a slow tree-kill never delays this
575
+ // server's failure or the parallel batch's resolution.
576
+ try { _closeServer({ client, transport }).catch(() => {}); }
577
+ catch { /* ignore */ }
578
+ // Never let the late handshake settle into an unhandled rejection.
579
+ handshake.catch(() => {});
580
+ }
581
+ throw err;
582
+ } finally {
583
+ if (startupTimer) clearTimeout(startupTimer);
584
+ }
585
+ if (!toolsResult || !Array.isArray(toolsResult.tools)) {
586
+ throw new Error(`[mcp-client] ListTools returned invalid shape for "${name}": missing or non-array tools field`);
587
+ }
588
+ if (genAtStart !== _connectAbortGeneration) {
589
+ // disconnectAll() ran mid-handshake: never register — tear down.
590
+ try { await _closeServer({ client, transport }); }
591
+ catch { /* ignore */ }
592
+ throw new Error(`MCP server "${name}" connect aborted by shutdown`);
593
+ }
594
+ const tools = toolsResult.tools.map((t) => ({
595
+ name: `mcp__${name}__${t.name}`,
596
+ description: t.description || '',
597
+ inputSchema: (t.inputSchema || { type: 'object', properties: {} }),
598
+ ...(t.annotations && typeof t.annotations === 'object' ? { annotations: t.annotations } : {}),
599
+ }));
600
+ const toolNames = tools.map(t => t.name);
601
+ servers.set(name, { name, client, transport, tools, cfg, instructions });
602
+ _invalidateMcpToolFieldMemo();
603
+ mcpLog(`[mcp] connected: ${tools.length} tools — ${toolNames.join(', ')}\n`);
604
+ }
605
+ finally {
606
+ _pendingConnects.delete(pending);
446
607
  }
447
- const tools = toolsResult.tools.map((t) => ({
448
- name: `mcp__${name}__${t.name}`,
449
- description: t.description || '',
450
- inputSchema: (t.inputSchema || { type: 'object', properties: {} }),
451
- ...(t.annotations && typeof t.annotations === 'object' ? { annotations: t.annotations } : {}),
452
- }));
453
- const toolNames = tools.map(t => t.name);
454
- servers.set(name, { name, client, transport, tools, cfg, instructions });
455
- _invalidateMcpToolFieldMemo();
456
- mcpLog(`[mcp] connected: ${tools.length} tools — ${toolNames.join(', ')}\n`);
457
608
  }
@@ -301,6 +301,21 @@ function nativeAnthropicTools(opts) {
301
301
  ? opts.nativeTools.filter(t => t && typeof t === 'object')
302
302
  : [];
303
303
  }
304
+ // Map the orchestrator-level opts.toolChoice into Anthropic's tool_choice.
305
+ // Only 'none' is activated: it lets the hard-cap final turn keep the tool
306
+ // DEFINITIONS in-request (so the tools->system->messages prefix — and its
307
+ // prompt-cache prefix — stay byte-identical to prior turns) while forbidding
308
+ // tool USE, so the model can only emit text. Forced values
309
+ // ('required'->{type:'any'}, {name}->{type:'tool'}) are deliberately NOT
310
+ // mapped: Anthropic returns a 400 for any forced tool_choice while
311
+ // extended/adaptive thinking is enabled, and the only caller that sets
312
+ // opts.toolChoice='required' (the forced-first-tool turn) runs with
313
+ // effort/thinking active on reasoning models — activating it would convert a
314
+ // previously-harmless no-op into a hard 400 on exactly that turn. Attached
315
+ // only when the request actually carries tools (see buildRequestBody).
316
+ function toAnthropicToolChoice(toolChoice) {
317
+ return toolChoice === 'none' ? { type: 'none' } : undefined;
318
+ }
304
319
  function deferredAnthropicTools(activeTools, opts) {
305
320
  if (opts?.session?.deferredNativeTools !== true) return [];
306
321
  // A request whose ONLY tools are deferred is rejected by the API with
@@ -464,10 +479,25 @@ function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_
464
479
  return -1;
465
480
  };
466
481
 
482
+ const firstRequestUserPromptIdx = () => {
483
+ // Iteration-1 fallback: on the very first request a session has only the
484
+ // current user prompt — no tool_result tail and no earlier user turn, so
485
+ // both anchors above return -1 and NO message breakpoint is placed. That
486
+ // left the whole tools+system prefix (~4.2k) uncached on iter1: nothing
487
+ // was written, so iter2 re-sent it as a fresh full write instead of a
488
+ // read hit. Anchor the current prompt's tail so the stable prefix is
489
+ // cache-written on the first ask and read back on the next. Only used
490
+ // when neither real anchor exists, so later turns still prefer the
491
+ // tool_result / previous-user-text anchors (never the volatile new
492
+ // prompt). Synthetic <system-reminder> turns are excluded by hasUserText.
493
+ if (latestToolResultTailIdx() !== -1 || previousUserTextAnchorIdx() !== -1) return -1;
494
+ const tailIdx = sanitizedMessages.length - 1;
495
+ return hasUserText(sanitizedMessages[tailIdx]) ? tailIdx : -1;
496
+ };
467
497
  if (messageTtl !== null) {
468
498
  const slots = Math.max(0, Math.min(4, Number(messageSlots) || 0));
469
499
  const marked = new Set();
470
- const candidates = [latestToolResultTailIdx(), previousUserTextAnchorIdx()];
500
+ const candidates = [latestToolResultTailIdx(), previousUserTextAnchorIdx(), firstRequestUserPromptIdx()];
471
501
  for (const idx of candidates) {
472
502
  if (slots <= 0) break;
473
503
  if (idx < 0 || marked.has(idx) || !canMarkMessageIdx(idx)) continue;
@@ -597,6 +627,13 @@ function buildRequestBody(messages, model, tools, sendOpts) {
597
627
  // a slot that's better spent on messages tail.
598
628
  body.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredTools])];
599
629
  }
630
+ // tool_choice only when tools are actually present (Anthropic rejects
631
+ // tool_choice without tools). 'none' rides the hard-cap final turn to
632
+ // forbid tool USE while keeping the tools prefix stable for cache reuse.
633
+ if (body.tools) {
634
+ const toolChoice = toAnthropicToolChoice(opts.toolChoice);
635
+ if (toolChoice) body.tool_choice = toolChoice;
636
+ }
600
637
 
601
638
  applyAnthropicEffortToBody(body, {
602
639
  model,
@@ -308,6 +308,22 @@ function nativeAnthropicTools(opts) {
308
308
  ? opts.nativeTools.filter(t => t && typeof t === 'object')
309
309
  : [];
310
310
  }
311
+ // Map the orchestrator-level opts.toolChoice into Anthropic's tool_choice.
312
+ // Only 'none' is activated: it lets the hard-cap final turn keep the tool
313
+ // DEFINITIONS in-request (so the tools->system->messages prefix — and its
314
+ // prompt-cache prefix — stay byte-identical to prior turns) while forbidding
315
+ // tool USE, so the model can only emit text. Forced values
316
+ // ('required'->{type:'any'}, {name}->{type:'tool'}) are deliberately NOT
317
+ // mapped: Anthropic returns a 400 for any forced tool_choice while
318
+ // extended/adaptive thinking is enabled, and the only caller that sets
319
+ // opts.toolChoice='required' (the forced-first-tool turn) runs with
320
+ // effort/thinking active on reasoning models — activating it would convert a
321
+ // previously-harmless no-op into a hard 400 on exactly that turn. Attached
322
+ // only when the request actually carries tools (see _doSend). Mirrors
323
+ // anthropic-oauth.mjs.
324
+ function toAnthropicToolChoice(toolChoice) {
325
+ return toolChoice === 'none' ? { type: 'none' } : undefined;
326
+ }
311
327
  function deferredAnthropicTools(activeTools, opts) {
312
328
  if (opts?.session?.deferredNativeTools !== true) return [];
313
329
  // Guard against an all-deferred tools array — the API rejects it with
@@ -458,10 +474,25 @@ function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_
458
474
  return -1;
459
475
  };
460
476
 
477
+ const firstRequestUserPromptIdx = () => {
478
+ // Iteration-1 fallback: on the very first request a session has only the
479
+ // current user prompt — no tool_result tail and no earlier user turn, so
480
+ // both anchors above return -1 and NO message breakpoint is placed. That
481
+ // left the whole tools+system prefix (~4.2k) uncached on iter1: nothing
482
+ // was written, so iter2 re-sent it as a fresh full write instead of a
483
+ // read hit. Anchor the current prompt's tail so the stable prefix is
484
+ // cache-written on the first ask and read back on the next. Only used
485
+ // when neither real anchor exists, so later turns still prefer the
486
+ // tool_result / previous-user-text anchors (never the volatile new
487
+ // prompt). Synthetic <system-reminder> turns are excluded by hasUserText.
488
+ if (latestToolResultTailIdx() !== -1 || previousUserTextAnchorIdx() !== -1) return -1;
489
+ const tailIdx = sanitizedMessages.length - 1;
490
+ return hasUserText(sanitizedMessages[tailIdx]) ? tailIdx : -1;
491
+ };
461
492
  if (messageTtl !== null) {
462
493
  const slots = Math.max(0, Math.min(4, Number(messageSlots) || 0));
463
494
  const marked = new Set();
464
- const candidates = [latestToolResultTailIdx(), previousUserTextAnchorIdx()];
495
+ const candidates = [latestToolResultTailIdx(), previousUserTextAnchorIdx(), firstRequestUserPromptIdx()];
465
496
  for (const idx of candidates) {
466
497
  if (slots <= 0) break;
467
498
  if (idx < 0 || marked.has(idx) || !canMarkMessageIdx(idx)) continue;
@@ -578,6 +609,13 @@ export class AnthropicProvider {
578
609
  // Anthropic prefix semantics (order: tools → system → messages).
579
610
  params.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredAnthropicTools(tools || [], opts)])];
580
611
  }
612
+ // tool_choice only when tools are actually present (Anthropic rejects
613
+ // tool_choice without tools). 'none' rides the hard-cap final turn to
614
+ // forbid tool USE while keeping the tools prefix stable for cache reuse.
615
+ if (params.tools) {
616
+ const toolChoice = toAnthropicToolChoice(opts.toolChoice);
617
+ if (toolChoice) params.tool_choice = toolChoice;
618
+ }
581
619
  // Known tool names for the shared parseSSEStream leaked-tool-call guard
582
620
  // (same guard fixes both Anthropic providers). Recovered leaked calls
583
621
  // are only synthesized when they name a tool actually offered here.
@@ -514,7 +514,7 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
514
514
  : parseCompletedToolCallArgumentsJson(item.arguments || '{}', label, { id: callId, name: 'tool_search', finishReason: 'done' });
515
515
  const call = {
516
516
  id: callId,
517
- name: 'tool_search',
517
+ name: 'load_tool',
518
518
  // Schema is a plain object ({query,select,limit}); an array must
519
519
  // never pass through as args.
520
520
  arguments: (_tsArgs && typeof _tsArgs === 'object' && !Array.isArray(_tsArgs)) ? _tsArgs : {},
@@ -107,7 +107,9 @@ export function toOpenAITools(tools) {
107
107
 
108
108
  export function toResponsesTools(tools) {
109
109
  return tools.map((t) => {
110
- if (t?.name === 'tool_search') {
110
+ // load_tool advertises as the OpenAI-native `tool_search` wire type
111
+ // (legacy 'tool_search' name still accepted for back-compat).
112
+ if (t?.name === 'load_tool' || t?.name === 'tool_search') {
111
113
  return {
112
114
  type: 'tool_search',
113
115
  execution: 'client',
@@ -193,7 +195,7 @@ export function parseResponsesToolCalls(response, label) {
193
195
  : parseCompletedToolCallArgumentsJson(item.arguments || '{}', label, { id: item.call_id || item.id, name: 'tool_search', finishReason });
194
196
  out.push({
195
197
  id: item.call_id || item.id,
196
- name: 'tool_search',
198
+ name: 'load_tool',
197
199
  // Schema is a plain object ({query,select,limit}); an array
198
200
  // (parsed JSON or passthrough) must never pass through as args.
199
201
  arguments: (_tsArgs && typeof _tsArgs === 'object' && !Array.isArray(_tsArgs)) ? _tsArgs : {},
@@ -294,7 +296,7 @@ export function toResponsesInputMessage(m, pendingToolMedia = null, customToolCa
294
296
  const items = [];
295
297
  if (m.content) items.push({ role: 'assistant', content: normalizeContentForOpenAIResponses(m.content, { role: 'assistant' }) });
296
298
  for (const tc of m.toolCalls) {
297
- if (tc.nativeType === 'tool_search_call' || tc.name === 'tool_search') {
299
+ if (tc.nativeType === 'tool_search_call' || tc.name === 'load_tool' || tc.name === 'tool_search') {
298
300
  items.push({
299
301
  type: 'tool_search_call',
300
302
  call_id: tc.id,
@@ -451,7 +451,7 @@ export async function sendViaHttpSse({
451
451
  }
452
452
  const call = {
453
453
  id: callId,
454
- name: 'tool_search',
454
+ name: 'load_tool',
455
455
  arguments: args,
456
456
  nativeType: 'tool_search_call',
457
457
  };
@@ -501,7 +501,7 @@ export async function sendViaHttpSse({
501
501
  // mistakes it for a function call by id collision/empty id.
502
502
  if (event.item.id) {
503
503
  pendingCalls.set(event.item.id, {
504
- name: 'tool_search',
504
+ name: 'load_tool',
505
505
  callId: event.item.call_id || '',
506
506
  kind: 'tool_search',
507
507
  });
@@ -444,7 +444,7 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
444
444
  // reasoning in `input` triggers "Duplicate item".
445
445
  if (m.content) out.push({ role: 'assistant', content: normalizeContentForOpenAIResponses(m.content, { role: 'assistant' }) });
446
446
  for (const tc of m.toolCalls) {
447
- if (tc.nativeType === 'tool_search_call' || tc.name === 'tool_search') {
447
+ if (tc.nativeType === 'tool_search_call' || tc.name === 'load_tool' || tc.name === 'tool_search') {
448
448
  out.push({
449
449
  type: 'tool_search_call',
450
450
  call_id: tc.id,
@@ -480,7 +480,7 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
480
480
  }
481
481
 
482
482
  function toOpenAIResponsesTool(t) {
483
- if (t?.name === 'tool_search') {
483
+ if (t?.name === 'load_tool' || t?.name === 'tool_search') {
484
484
  return {
485
485
  type: 'tool_search',
486
486
  execution: 'client',
@@ -355,7 +355,7 @@ export async function _streamResponse({
355
355
  if (!callId || toolCalls.some((call) => call.id === callId)) return;
356
356
  const call = {
357
357
  id: callId,
358
- name: 'tool_search',
358
+ name: 'load_tool',
359
359
  arguments: parseToolSearchArgs(item.arguments),
360
360
  nativeType: 'tool_search_call',
361
361
  };
@@ -291,8 +291,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
291
291
  if (iterations >= maxLoopIterations) {
292
292
  // Final-answer turn: instead of breaking mid-transcript (which
293
293
  // yields an empty final for locator-style agents that never got to
294
- // answer), give the model ONE tool-less text turn to wrap up, then
295
- // stop (empty sendTools + refusal stubs if tools are requested).
294
+ // answer), give the model ONE text-only turn to wrap up, then stop.
295
+ // Tool DEFINITIONS stay in-request (stable cache prefix) but tool
296
+ // USE is forbidden via tool_choice:'none'; any tool call a
297
+ // toolChoice-ignoring provider still emits gets a refusal stub.
296
298
  if (_capFinalTurnUsed) {
297
299
  process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
298
300
  terminatedByCap = true;
@@ -362,15 +364,22 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
362
364
  const nextIteration = iterations + 1;
363
365
  opts.iteration = nextIteration;
364
366
  opts.providerState = providerState;
365
- if (forcedFirstTool && toolCallsTotal === 0) {
367
+ if (_capFinalToolsDisabled) {
368
+ // Hard-cap final turn: forbid tool USE (tool_choice:'none') instead
369
+ // of stripping tool DEFINITIONS. Sending tools:[] changed the
370
+ // tools→system→messages prefix chain, so Anthropic could no longer
371
+ // prefix-match and re-prefilled the whole prompt (~10k, cache
372
+ // read=0) on the final capped turn. Keeping the tools in-request
373
+ // holds the prefix byte-stable; 'none' makes the model emit text
374
+ // only. Overrides the forced-first-tool path below.
375
+ opts.toolChoice = 'none';
376
+ } else if (forcedFirstTool && toolCallsTotal === 0) {
366
377
  opts.toolChoice = 'required';
367
378
  } else {
368
379
  delete opts.toolChoice;
369
380
  }
370
- // Hard-cap final turn: send NO tool definitions so the provider can
371
- // only emit text. Overrides the forced-first-tool path.
372
381
  const sendTools = _capFinalToolsDisabled
373
- ? []
382
+ ? tools
374
383
  : (forcedFirstToolDef && toolCallsTotal === 0 ? [forcedFirstToolDef] : tools);
375
384
  // Eager-dispatch queue: when the provider streams a tool-call event,
376
385
  // start read-only tools immediately so execution overlaps with the
@@ -387,7 +396,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
387
396
  getNextIteration: () => nextIteration,
388
397
  repeatFailLimit: REPEAT_FAIL_LIMIT,
389
398
  });
390
- opts.onToolCall = eager.onToolCall;
399
+ // Hard-cap final turn: forbid eager dispatch. Tools are still sent (to
400
+ // hold the cache prefix) but tool_choice:'none' means Anthropic emits
401
+ // no calls; a toolChoice-IGNORING provider could still stream calls,
402
+ // and an attached onToolCall would eager-run read-only tools mid-stream
403
+ // (real cost/UI/network side effects) whose results are then discarded
404
+ // by the refusal-stub path. Leave it unset so nothing dispatches; opts
405
+ // is reused across iterations but onToolCall is cleared to undefined
406
+ // after send() below, so non-cap iterations are unaffected.
407
+ opts.onToolCall = _capFinalToolsDisabled ? undefined : eager.onToolCall;
391
408
  // Reattach separated tool results, then drop only truly dangling
392
409
  // assistant/orphan pairs before the provider sees the transcript.
393
410
  repairTranscriptBeforeProviderSend(messages, sessionId);
@@ -24,8 +24,9 @@ export function crossTurnSignature(name, args) {
24
24
 
25
25
  // Tool names that are non-eager (no readOnlyHint) but are NOT edits/progress —
26
26
  // they must not reset the escalation ladder's "zero edit" condition. Skill /
27
- // recall / agent / task / cwd / tool_search are exploration/meta plumbing.
28
- const NON_PROGRESS_TOOLS = new Set(['Skill', 'recall', 'agent', 'task', 'cwd', 'tool_search']);
27
+ // recall / agent / task / cwd / load_tool are exploration/meta plumbing.
28
+ // (tool_search kept as legacy alias for old transcripts.)
29
+ const NON_PROGRESS_TOOLS = new Set(['Skill', 'recall', 'agent', 'task', 'cwd', 'load_tool', 'tool_search']);
29
30
 
30
31
  // True when a successfully-executed tool represents real edit/progress. A tool
31
32
  // counts as progress only if its def lacks readOnlyHint (not eager) AND it is
@@ -1,7 +1,7 @@
1
1
  // Deferred catalog call-through: inactive catalog tool_use → discovery bookkeeping
2
2
  // then normal executeTool routing (runtime errors only; no pre-dispatch schema).
3
3
  import { clean } from '../../../../../session-runtime/session-text.mjs';
4
- import { isReadonlySelectable, selectDeferredTools } from '../../../../../session-runtime/tool-catalog.mjs';
4
+ import { deferredCatalogUnion, isReadonlySelectable, selectDeferredTools } from '../../../../../session-runtime/tool-catalog.mjs';
5
5
 
6
6
  /** Skill-list plumbing only; mutation/MCP/builtins must use the catalog+mode gate. */
7
7
  const INACTIVE_INFRA_BYPASS = new Set(['skills_list', 'skill_view']);
@@ -13,7 +13,10 @@ function isActiveSessionTool(session, name) {
13
13
  }
14
14
 
15
15
  function lookupDeferredCatalogTool(session, name) {
16
- const catalog = Array.isArray(session?.deferredToolCatalog) ? session.deferredToolCatalog : [];
16
+ // Union of the boot-frozen catalog and the late-connected MCP catalog so a
17
+ // direct call to a tool whose server connected after boot resolves and
18
+ // auto-loads (selectDeferredTools promotes it onto session.tools).
19
+ const catalog = deferredCatalogUnion(session);
17
20
  const key = clean(name);
18
21
  if (!key) return null;
19
22
  for (const tool of catalog) {
@@ -66,7 +69,7 @@ export function prepareDeferredToolCallThrough(sessionRef, name, _args) {
66
69
  if (resolvedMode === null) {
67
70
  if (!isReadonlySelectable(tool)) {
68
71
  return denyDeferredCallThrough(
69
- `Error: tool "${toolLabel}" is deferred and cannot be auto-loaded without a resolved tool mode; use tool_search or load it explicitly.`,
72
+ `Error: tool "${toolLabel}" is deferred and cannot be auto-loaded without a resolved tool mode; load it with load_tool names:["${toolLabel}"].`,
70
73
  );
71
74
  }
72
75
  } else if (resolvedMode === 'readonly' && !isReadonlySelectable(tool)) {
@@ -81,7 +81,7 @@ export function resolveToolResultAfterHook(originalResult, hookResult) {
81
81
  }
82
82
 
83
83
  export function parseNativeToolSearchPayload(toolName, result) {
84
- if (toolName !== 'tool_search' || typeof result !== 'string') return null;
84
+ if ((toolName !== 'load_tool' && toolName !== 'tool_search') || typeof result !== 'string') return null;
85
85
  try {
86
86
  const parsed = JSON.parse(result);
87
87
  const native = parsed?.nativeToolSearch;