mixdog 0.9.23 → 0.9.25

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 (89) hide show
  1. package/package.json +2 -1
  2. package/scripts/boot-smoke.mjs +1 -1
  3. package/scripts/channel-daemon-smoke.mjs +327 -9
  4. package/scripts/channel-daemon-stub.mjs +12 -1
  5. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  6. package/scripts/explore-bench-tmp.mjs +17 -0
  7. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  8. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  9. package/scripts/tool-smoke.mjs +38 -30
  10. package/src/defaults/cycle3-review-prompt.md +11 -4
  11. package/src/defaults/memory-promote-prompt.md +9 -0
  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 +39 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
  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/session/agent-loop.mjs +24 -7
  22. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  23. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
  24. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  25. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  26. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  27. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  28. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  29. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  31. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  32. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
  34. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  35. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  36. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  37. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  38. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
  39. package/src/runtime/channels/backends/discord.mjs +10 -3
  40. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  41. package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
  42. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  43. package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
  44. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
  45. package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
  46. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
  47. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  48. package/src/runtime/channels/lib/worker-main.mjs +9 -28
  49. package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -1
  50. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
  51. package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
  52. package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
  53. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  54. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  55. package/src/runtime/memory/tool-defs.mjs +1 -1
  56. package/src/runtime/shared/atomic-file.mjs +130 -2
  57. package/src/runtime/shared/background-tasks.mjs +1 -1
  58. package/src/runtime/shared/config.mjs +53 -1
  59. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  60. package/src/runtime/shared/tool-surface.mjs +19 -0
  61. package/src/runtime/shared/update-checker.mjs +3 -0
  62. package/src/runtime/shared/user-data-guard.mjs +66 -0
  63. package/src/session-runtime/config-lifecycle.mjs +175 -15
  64. package/src/session-runtime/mcp-glue.mjs +30 -0
  65. package/src/session-runtime/runtime-core.mjs +91 -7
  66. package/src/session-runtime/session-turn-api.mjs +42 -16
  67. package/src/session-runtime/tool-catalog.mjs +44 -0
  68. package/src/standalone/channel-admin.mjs +32 -3
  69. package/src/standalone/channel-daemon-client.mjs +3 -1
  70. package/src/standalone/channel-daemon-transport.mjs +202 -8
  71. package/src/standalone/channel-daemon.mjs +54 -17
  72. package/src/standalone/channel-worker.mjs +18 -7
  73. package/src/standalone/explore-tool.mjs +87 -15
  74. package/src/tui/App.jsx +2 -2
  75. package/src/tui/components/StatusLine.jsx +3 -3
  76. package/src/tui/components/ToolExecution.jsx +14 -2
  77. package/src/tui/components/TranscriptItem.jsx +1 -1
  78. package/src/tui/dist/index.mjs +246 -47
  79. package/src/tui/engine/agent-job-feed.mjs +47 -3
  80. package/src/tui/engine/notification-plan.mjs +5 -0
  81. package/src/tui/engine/session-api.mjs +6 -1
  82. package/src/tui/engine/tool-card-results.mjs +14 -5
  83. package/src/tui/engine/turn.mjs +9 -2
  84. package/src/tui/engine.mjs +31 -12
  85. package/src/ui/statusline-agents.mjs +36 -0
  86. package/src/ui/statusline.mjs +15 -5
  87. package/src/workflows/default/WORKFLOW.md +29 -38
  88. package/src/workflows/solo/WORKFLOW.md +15 -20
  89. package/src/runtime/channels/lib/seat-lock.mjs +0 -196
@@ -86,6 +86,45 @@ export async function collectDescendants(rootPid) {
86
86
  return descendantsOf(childrenOf, rootPid);
87
87
  }
88
88
 
89
+ /**
90
+ * Immediate, non-blocking tree kill for a transport whose MCP handshake never
91
+ * completed (shutdown-during-connect). Unlike shutdownStdioChild there is no
92
+ * grace wait and no identity-checked enumeration on Windows — the ChildProcess
93
+ * handle is live, so the pid is current, and taskkill /T reaps the still-
94
+ * parented tree. Everything is unref'd/fire-and-forget so the kill can never
95
+ * hold the caller's event loop open (the caller is a process about to exit).
96
+ */
97
+ export function killStdioChildTreeFast(transport) {
98
+ const proc = transport?._process;
99
+ const pid = (typeof transport?.pid === 'number' ? transport.pid : null) ?? proc?.pid ?? null;
100
+ if (!pid) return false;
101
+ if (proc && (proc.exitCode !== null || proc.signalCode !== null)) return false;
102
+ try { proc?.unref?.(); } catch { /* ignore */ }
103
+ for (const s of [proc?.stdin, proc?.stdout, proc?.stderr]) {
104
+ try { s?.unref?.(); } catch { /* ignore */ }
105
+ try { s?.destroy?.(); } catch { /* ignore */ }
106
+ }
107
+ if (isWin) {
108
+ try {
109
+ const cp = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], {
110
+ windowsHide: true,
111
+ detached: true,
112
+ stdio: 'ignore',
113
+ });
114
+ cp.unref();
115
+ } catch { /* ignore */ }
116
+ return true;
117
+ }
118
+ // POSIX: cheap ps enumerate, then SIGKILL descendants + root. Best-effort
119
+ // and un-awaited; a straggler orphan is reparented to init, not leaked by us.
120
+ void collectDescendants(pid).then((pids) => {
121
+ for (const p of [...pids, pid]) {
122
+ try { process.kill(p, 'SIGKILL'); } catch { /* ignore */ }
123
+ }
124
+ }).catch(() => { /* ignore */ });
125
+ return true;
126
+ }
127
+
89
128
  function isAlive(pid) {
90
129
  try {
91
130
  process.kill(pid, 0);
@@ -4,7 +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 } from './child-tree.mjs';
7
+ import { shutdownStdioChild, killStdioChildTreeFast } from './child-tree.mjs';
8
8
  // --- Types ---
9
9
  /** Known auto-detect targets: port file path relative to tmpdir.
10
10
  * Note: `mixdog` used to self-loopback via active-instance.json's
@@ -16,6 +16,8 @@ const AUTO_DETECT_PORTS = {
16
16
  'mixdog-memory': { dir: 'mixdog', file: 'active-instance.json', portField: 'memory_port', endpoint: '/mcp' },
17
17
  };
18
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;
19
21
  // --- State ---
20
22
  const servers = new Map();
21
23
  let mcpSdkPromise = null;
@@ -97,21 +99,31 @@ export function resolveMcpTransportKind(cfg) {
97
99
  * Supports stdio (child process) and http (Streamable HTTP) transports.
98
100
  */
99
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;
100
107
  const failures = [];
101
- for (const [name, cfg] of Object.entries(config)) {
108
+ const entries = Object.entries(config).filter(([name, cfg]) => {
102
109
  if (cfg?.enabled === false) {
103
110
  mcpLog(`[mcp-client] Skipping disabled server "${name}"\n`);
104
- continue;
111
+ return false;
105
112
  }
106
- try {
107
- await connectServer(name, cfg);
108
- }
109
- catch (err) {
110
- const msg = err instanceof Error ? err.message : String(err);
111
- mcpLog(`[mcp-client] Failed to connect "${name}": ${msg}\n`);
112
- failures.push({ name, msg });
113
- }
114
- }
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
+ });
115
127
  if (failures.length > 0) {
116
128
  const detail = failures.map(f => `${f.name}: ${f.msg}`).join('; ');
117
129
  const err = new Error(`[mcp-client] ${failures.length} MCP server(s) failed to connect — ${detail}`);
@@ -234,6 +246,26 @@ export function isMcpToolCallTimeoutError(err) {
234
246
  return err?.code === 'EMCPTOOLTIMEOUT';
235
247
  }
236
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
+
237
269
  async function _callToolWithTimeout(server, toolName, args) {
238
270
  let timer;
239
271
  const timeoutMs = resolveMcpCallTimeoutMs(server?.cfg);
@@ -339,6 +371,22 @@ export function mcpToolHasField(name, field) {
339
371
  * Disconnect all MCP servers.
340
372
  */
341
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
+ }
342
390
  for (const [name, server] of servers) {
343
391
  try {
344
392
  await _closeServer(server);
@@ -382,7 +430,13 @@ async function _closeServer(server) {
382
430
  try { await server.client.close(); }
383
431
  catch { /* ignore */ }
384
432
  }
385
- async function connectServer(name, cfg) {
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) {
386
440
  const {
387
441
  Client,
388
442
  StdioClientTransport,
@@ -390,6 +444,11 @@ async function connectServer(name, cfg) {
390
444
  SSEClientTransport,
391
445
  WebSocketClientTransport,
392
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
+ }
393
452
  const client = new Client({ name: `mixdog-agent/${name}`, version: '1.0.0' });
394
453
  let transport;
395
454
  const kind = resolveMcpTransportKind(cfg);
@@ -472,23 +531,78 @@ async function connectServer(name, cfg) {
472
531
  else {
473
532
  throw new Error(`Invalid config for "${name}": need autoDetect, type (stdio/http/sse/ws), url (http), or command (stdio)`);
474
533
  }
475
- await client.connect(transport);
476
- const instructionsRaw = typeof client.getInstructions === 'function'
477
- ? client.getInstructions()
478
- : undefined;
479
- const instructions = typeof instructionsRaw === 'string' ? instructionsRaw.trim() : '';
480
- const toolsResult = await client.listTools();
481
- if (!toolsResult || !Array.isArray(toolsResult.tools)) {
482
- 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);
483
607
  }
484
- const tools = toolsResult.tools.map((t) => ({
485
- name: `mcp__${name}__${t.name}`,
486
- description: t.description || '',
487
- inputSchema: (t.inputSchema || { type: 'object', properties: {} }),
488
- ...(t.annotations && typeof t.annotations === 'object' ? { annotations: t.annotations } : {}),
489
- }));
490
- const toolNames = tools.map(t => t.name);
491
- servers.set(name, { name, client, transport, tools, cfg, instructions });
492
- _invalidateMcpToolFieldMemo();
493
- mcpLog(`[mcp] connected: ${tools.length} tools — ${toolNames.join(', ')}\n`);
494
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.
@@ -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);
@@ -108,6 +108,7 @@ export function markSessionAskStart(id) {
108
108
  entry.lastStreamDeltaAt = null;
109
109
  entry.lastToolCall = null;
110
110
  entry.toolStartedAt = null;
111
+ entry.toolSelfDeadlineMs = null;
111
112
  entry.lastError = null;
112
113
  // A new ask starts a fresh turn lifecycle — clear any stale empty-final
113
114
  // classification from the prior turn so inspectBridgeEntry doesn't keep
@@ -166,11 +167,18 @@ export async function markSessionStreamDelta(id) {
166
167
  }
167
168
  entry.updatedAt = now;
168
169
  }
169
- export function markSessionToolCall(id, toolName) {
170
+ export function markSessionToolCall(id, toolName, selfDeadlineMs) {
170
171
  if (!id) return;
171
172
  const entry = _touchRuntime(id);
172
173
  entry.stage = 'tool_running';
173
174
  entry.lastToolCall = toolName || null;
175
+ // Self-enforced deadline (ms) for tools that kill themselves at a known
176
+ // budget (shell timeout / task wait). The watchdog raises the tool-running
177
+ // ceiling to this + grace instead of aborting at toolRunningMs. Null/<=0
178
+ // means unknown -> plain toolRunningMs behavior.
179
+ entry.toolSelfDeadlineMs = (typeof selfDeadlineMs === 'number' && selfDeadlineMs > 0)
180
+ ? selfDeadlineMs
181
+ : null;
174
182
  entry.toolStartedAt = Date.now();
175
183
  entry.lastProgressAt = entry.toolStartedAt;
176
184
  entry.updatedAt = entry.toolStartedAt;
@@ -289,6 +297,8 @@ export function getSessionProgressSnapshot(sessionId) {
289
297
  firstActivityAt,
290
298
  lastStreamDeltaAt: entry.lastStreamDeltaAt || 0,
291
299
  toolStartedAt: entry.toolStartedAt || 0,
300
+ currentTool: entry.lastToolCall || null,
301
+ toolSelfDeadlineMs: entry.toolSelfDeadlineMs || 0,
292
302
  lastProgressAt: entry.lastProgressAt || 0,
293
303
  updatedAt: entry.updatedAt || 0,
294
304
  hasFirstActivity: Boolean(firstActivityAt && (!askStartedAt || firstActivityAt >= askStartedAt)),
@@ -167,8 +167,9 @@ export function createSession(opts) {
167
167
  // - read-only roles (reviewer / debugger / hidden retrieval, i.e. any
168
168
  // session resolving to permission 'read') -> 'readonly' bundle:
169
169
  // read builtins (code_graph/find/glob/list/grep/read) + retrieval
170
- // (explore/search/web_fetch/Skill), no apply_patch/shell/task, no
171
- // MCP-write. applyToolPermissionNarrowing('read') below trims the
170
+ // (explore/search/web_fetch/Skill) + shell/task for self-verification
171
+ // (agent-owned readonly bundle only), no apply_patch, no MCP-write.
172
+ // applyToolPermissionNarrowing('read') below trims the
172
173
  // bundle to AGENT_STRING_PERMISSION_READ_ALLOW so the final surface is
173
174
  // bit-identical across these roles regardless of MCP registry state.
174
175
  // - write roles (worker / heavy-worker / maintainer / …) -> 'full'
@@ -188,7 +189,10 @@ export function createSession(opts) {
188
189
  // fail closed (zero tools) rather than silently falling back to the full
189
190
  // preset, which would grant the role more surface than declared.
190
191
  if (ownerIsAgent) {
191
- toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, toolPermission, opts.agent || null);
192
+ // Pass the RESOLVED agent (opts.agent || opts.role): narrowing keys
193
+ // retrieval/locator roles (explore etc.) off this name to strip
194
+ // shell/task; verifying read roles (reviewer/debugger) keep them.
195
+ toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, toolPermission, resolvedAgent);
192
196
  }
193
197
 
194
198
  const { baseRules, stableSystemContext, sessionMarker, volatileTail } = composeSystemPrompt({
@@ -74,14 +74,39 @@ const AGENT_STRING_PERMISSION_READ_ALLOW = Object.freeze([
74
74
  'list',
75
75
  'grep',
76
76
  'read',
77
+ // shell/task: read-role agents (reviewer/debugger) must run their own
78
+ // verification (tests/repro). task is required because shell's
79
+ // auto-background transition settles with a bare jobId — agent sessions
80
+ // get no completion notification, so `task wait/read` is the only way to
81
+ // collect a long-running result. apply_patch stays excluded: read roles
82
+ // keep the no-edit contract.
83
+ 'shell',
84
+ 'task',
77
85
  'explore',
78
86
  'search',
79
87
  'web_fetch',
80
88
  'Skill',
81
89
  ]);
82
90
 
83
- function stringToolPermissionAllowList(toolPermission) {
84
- if (toolPermission === 'read') return AGENT_STRING_PERMISSION_READ_ALLOW;
91
+ // Retrieval/locator roles never self-verify, so they keep the historical
92
+ // no-shell read surface. Covers hidden retrieval agents (kind 'retrieval')
93
+ // AND public wrapper roles (explore + any invokedBy wrapper) — the same set
94
+ // pre-dispatch-deny treats as recursive wrappers. Only verifying read roles
95
+ // (reviewer/debugger) get shell/task.
96
+ const AGENT_STRING_PERMISSION_READ_RETRIEVAL_ALLOW = Object.freeze(
97
+ AGENT_STRING_PERMISSION_READ_ALLOW.filter((n) => n !== 'shell' && n !== 'task'),
98
+ );
99
+
100
+ function isRetrievalToolRole(agent) {
101
+ if (!agent) return false;
102
+ if (getHiddenAgent(agent)?.kind === 'retrieval') return true;
103
+ return Boolean(recursiveWrapperToolNameForPublicAgent(agent));
104
+ }
105
+
106
+ function stringToolPermissionAllowList(toolPermission, { retrieval = false } = {}) {
107
+ if (toolPermission === 'read') {
108
+ return retrieval ? AGENT_STRING_PERMISSION_READ_RETRIEVAL_ALLOW : AGENT_STRING_PERMISSION_READ_ALLOW;
109
+ }
85
110
  if (toolPermission === 'read-write') return AGENT_STRING_PERMISSION_READ_WRITE_ALLOW;
86
111
  if (toolPermission === 'none') return [];
87
112
  return null;
@@ -109,7 +134,7 @@ const AGENT_STRING_PERMISSION_READ_WRITE_ALLOW = Object.freeze([
109
134
 
110
135
  export function applyToolPermissionNarrowing(tools, toolPermission, warnRole = null) {
111
136
  if (toolPermission === 'none') return [];
112
- const allowList = stringToolPermissionAllowList(toolPermission);
137
+ const allowList = stringToolPermissionAllowList(toolPermission, { retrieval: isRetrievalToolRole(warnRole) });
113
138
  if (allowList) {
114
139
  const allowSet = new Set(allowList.map((n) => String(n).toLowerCase()));
115
140
  return tools.filter((t) => allowSet.has(String(t?.name || '').toLowerCase()));
@@ -203,7 +228,7 @@ export function resolveSessionTools(toolSpec, skills, { ownerIsAgentSession = fa
203
228
  // time (loop.mjs), so the schema bytes stay bit-identical across roles /
204
229
  // cwds and the provider cache shard does not fragment.
205
230
  const skillTools = buildSkillToolDefs(skills, { ownerIsAgentSession });
206
- return _computeBaseTools(toolSpec, mcp, skillTools);
231
+ return _computeBaseTools(toolSpec, mcp, skillTools, { ownerIsAgentSession });
207
232
  }
208
233
 
209
234
  export function previewSessionTools(toolSpec, skills = [], options = {}) {
@@ -230,7 +255,7 @@ function _dedupByName(tools) {
230
255
  // Tools with agentHidden:true are stripped from agent sessions at schema
231
256
  // build time (see deny filtering below). No code-level name list needed.
232
257
 
233
- function _computeBaseTools(toolSpec, mcp, skillTools) {
258
+ function _computeBaseTools(toolSpec, mcp, skillTools, { ownerIsAgentSession = false } = {}) {
234
259
  if (Array.isArray(toolSpec)) {
235
260
  if (toolSpec.length === 0) {
236
261
  // Explicit "no tools" — skill meta tools still travel so the model
@@ -285,7 +310,16 @@ function _computeBaseTools(toolSpec, mcp, skillTools) {
285
310
  return _dedupByName([...mcp, ...skillTools]);
286
311
  case 'readonly': {
287
312
  const readTools = ALL_BUILTIN_SESSION_TOOLS.filter(t => READONLY_TOOL_NAMES.has(t.name));
288
- return _dedupByName([...readTools, ...mcp, ...skillTools]);
313
+ // Read-ROLE agent sessions (reviewer/debugger) must self-verify, so
314
+ // their base bundle carries shell/task defs. The 'read' permission
315
+ // allowlist (AGENT_STRING_PERMISSION_READ_ALLOW) is a pure FILTER —
316
+ // it can only keep tools already assembled here, so without this the
317
+ // allowlist's shell/task entries were dead letters. Non-agent
318
+ // 'readonly' profiles stay strictly read-only.
319
+ const verifyTools = ownerIsAgentSession
320
+ ? ALL_BUILTIN_SESSION_TOOLS.filter(t => t.name === 'shell' || t.name === 'task')
321
+ : [];
322
+ return _dedupByName([...readTools, ...verifyTools, ...mcp, ...skillTools]);
289
323
  }
290
324
  case 'full':
291
325
  default:
@@ -11,6 +11,7 @@ import { takeApplyPatchUiDiff } from '../tools/patch.mjs';
11
11
  import { compressToolResult } from '../tools/result-compression.mjs';
12
12
  import { appendAgentTrace, traceAgentTool, traceAgentToolFailure } from '../agent-trace.mjs';
13
13
  import { markSessionToolCall, updateSessionStage } from './manager.mjs';
14
+ import { resolveToolSelfDeadlineMs } from '../agent-runtime/agent-progress-watchdog.mjs';
14
15
  import { classifyResultKind } from './result-classification.mjs';
15
16
  import { normalizeToolEnvelope } from './tool-envelope.mjs';
16
17
  import { maybeOffloadToolResult } from './tool-result-offload.mjs';
@@ -150,7 +151,7 @@ export async function processToolBatch(ctx) {
150
151
  continue;
151
152
  }
152
153
  }
153
- if (sessionId) markSessionToolCall(sessionId, call.name);
154
+ if (sessionId) markSessionToolCall(sessionId, call.name, resolveToolSelfDeadlineMs(call.name, call.arguments));
154
155
  let result;
155
156
  let toolStartedAt;
156
157
  let toolEndedAt;