mixdog 0.9.35 → 0.9.37

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 (105) hide show
  1. package/package.json +3 -2
  2. package/scripts/abort-recovery-test.mjs +118 -0
  3. package/scripts/compact-smoke.mjs +7 -7
  4. package/scripts/explore-bench-tmp.mjs +19 -0
  5. package/scripts/explore-bench.mjs +36 -6
  6. package/scripts/explore-prompt-policy-test.mjs +27 -0
  7. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  8. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  9. package/scripts/output-style-smoke.mjs +17 -17
  10. package/scripts/provider-toolcall-test.mjs +333 -14
  11. package/scripts/recall-bench-cases.json +3 -3
  12. package/scripts/recall-bench.mjs +1 -1
  13. package/scripts/recall-quality-cases.json +4 -4
  14. package/scripts/recall-usecase-cases.json +2 -2
  15. package/scripts/session-bench.mjs +13 -13
  16. package/scripts/steering-drain-buckets-test.mjs +72 -11
  17. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  18. package/scripts/tool-smoke.mjs +83 -10
  19. package/src/app.mjs +2 -1
  20. package/src/defaults/cycle3-review-prompt.md +9 -0
  21. package/src/defaults/skills/setup/SKILL.md +93 -293
  22. package/src/lib/rules-builder.cjs +3 -2
  23. package/src/output-styles/default.md +2 -2
  24. package/src/output-styles/extreme-minimal.md +1 -1
  25. package/src/output-styles/minimal.md +1 -1
  26. package/src/output-styles/simple.md +2 -3
  27. package/src/rules/agent/30-explorer.md +15 -7
  28. package/src/rules/lead/01-general.md +4 -0
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  30. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/mcp/client.mjs +7 -7
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  33. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  34. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  36. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  37. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  40. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  41. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  42. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +4 -2
  43. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  44. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  45. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  46. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  47. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  48. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  49. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  50. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  51. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -17
  52. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  54. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  55. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  56. package/src/runtime/agent/orchestrator/stall-policy.mjs +18 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  58. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  59. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  63. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  64. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  65. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  66. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  68. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  69. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  70. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  71. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  72. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  73. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  74. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  75. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  76. package/src/runtime/shared/config.mjs +98 -12
  77. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  78. package/src/session-runtime/cwd-plugins.mjs +6 -3
  79. package/src/session-runtime/model-route-api.mjs +50 -2
  80. package/src/session-runtime/provider-auth-api.mjs +8 -1
  81. package/src/session-runtime/resource-api.mjs +22 -0
  82. package/src/session-runtime/runtime-core.mjs +13 -2
  83. package/src/session-runtime/settings-api.mjs +11 -2
  84. package/src/standalone/agent-tool.mjs +15 -9
  85. package/src/standalone/explore-tool.mjs +4 -1
  86. package/src/tui/App.jsx +6 -5
  87. package/src/tui/app/transcript-window.mjs +60 -10
  88. package/src/tui/app/use-transcript-window.mjs +2 -1
  89. package/src/tui/components/ContextPanel.jsx +4 -1
  90. package/src/tui/components/ToolExecution.jsx +15 -12
  91. package/src/tui/components/TranscriptItem.jsx +1 -1
  92. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  93. package/src/tui/dist/index.mjs +473 -155
  94. package/src/tui/engine/agent-job-feed.mjs +26 -6
  95. package/src/tui/engine/context-state.mjs +13 -4
  96. package/src/tui/engine/notification-plan.mjs +3 -3
  97. package/src/tui/engine/render-timing.mjs +104 -8
  98. package/src/tui/engine/session-api.mjs +58 -3
  99. package/src/tui/engine/session-flow.mjs +78 -28
  100. package/src/tui/engine/tool-card-results.mjs +28 -13
  101. package/src/tui/engine/turn.mjs +232 -156
  102. package/src/tui/engine.mjs +17 -8
  103. package/src/tui/index.jsx +10 -1
  104. package/src/workflows/default/WORKFLOW.md +8 -4
  105. package/src/workflows/solo/WORKFLOW.md +8 -4
@@ -179,22 +179,24 @@ function _bumpCounter(sessionId, field) {
179
179
  * null on miss. On hit returns the full entry
180
180
  * { content, firstToolUseId, ts }.
181
181
  */
182
- export function tryScopedToolCached({ sessionId, toolName, args, cwd }) {
182
+ export function tryScopedToolCached({ sessionId, toolName, args, cwd, countStats = true, touch = true } = {}) {
183
183
  if (!sessionId || !toolName) return null;
184
184
  const map = _scopedBySession.get(sessionId);
185
185
  if (!map) {
186
- _bumpCounter(sessionId, 'misses');
186
+ if (countStats) _bumpCounter(sessionId, 'misses');
187
187
  return null;
188
188
  }
189
189
  const key = _scopedKey(toolName, args, cwd);
190
190
  const entry = map.get(key);
191
191
  if (!entry) {
192
- _bumpCounter(sessionId, 'misses');
192
+ if (countStats) _bumpCounter(sessionId, 'misses');
193
193
  return null;
194
194
  }
195
- map.delete(key);
196
- map.set(key, entry);
197
- _bumpCounter(sessionId, 'hits');
195
+ if (touch) {
196
+ map.delete(key);
197
+ map.set(key, entry);
198
+ }
199
+ if (countStats) _bumpCounter(sessionId, 'hits');
198
200
  return { content: entry.content, firstToolUseId: entry.firstToolUseId || null, ts: entry.ts };
199
201
  }
200
202
 
@@ -6,7 +6,8 @@
6
6
  // identical to the inline closures it replaced.
7
7
  import { normalizeToolEnvelope } from './tool-envelope.mjs';
8
8
  import { isInvalidToolArgsMarker } from '../providers/openai-compat-stream.mjs';
9
- import { _intraTurnSig } from './loop/tool-classify.mjs';
9
+ import { _intraTurnSig, _isReadTool, _isScopedCacheableTool, _stripMcpPrefix } from './loop/tool-classify.mjs';
10
+ import { tryReadCached, tryScopedToolCached } from './read-dedup.mjs';
10
11
  import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
11
12
  import { executeTool } from './loop/tool-exec.mjs';
12
13
  import { crossTurnSignature } from './loop/completion-guards.mjs';
@@ -63,6 +64,23 @@ export function createEagerDispatcher({
63
64
  const _prior = crossTurnCalls.get(_ctSig);
64
65
  if (_prior && _prior.firstIteration < getIterations()) return null;
65
66
  }
67
+ // Cache short-circuit (mirrors the serial-body lookup at
68
+ // tool-batch.mjs). If this read / scoped-cacheable call would be
69
+ // served from the session cache in the serial for-body, do NOT
70
+ // execute it eagerly — the serial path returns the cached body
71
+ // (read cache is stat-validated; scoped cache is dep-root evicted).
72
+ // Returning null here skips redundant IO under concurrent agents
73
+ // and, combined with the non-barrier `continue` in startEagerRun,
74
+ // never blocks a later independent eager read behind a cache stub.
75
+ // If the entry is invalidated before the serial body re-checks,
76
+ // that call simply executes serially — correctness is preserved.
77
+ if (sessionId) {
78
+ if (_isReadTool(call.name)) {
79
+ if (tryReadCached({ sessionId, args: call.arguments, cwd }) !== null) return null;
80
+ } else if (_isScopedCacheableTool(call.name)) {
81
+ if (tryScopedToolCached({ sessionId, toolName: _stripMcpPrefix(call.name), args: call.arguments, cwd, countStats: false, touch: false }) !== null) return null;
82
+ }
83
+ }
66
84
  const toolKind = getToolKind(call.name);
67
85
  // Shared pre-dispatch deny: identical predicate runs in the
68
86
  // serial path below. If any role/permission guard would reject
@@ -137,7 +155,15 @@ export function createEagerDispatcher({
137
155
  const call = calls[j];
138
156
  if (!call?.id || !isEagerDispatchable(call.name, tools)) break;
139
157
  if (dupSet && dupSet.has(call.id)) continue;
140
- if (!startEagerTool(call) && !pending.has(call.id)) break;
158
+ // A null return here is NOT a state barrier: the loop above
159
+ // already breaks at the first non-eager (mutation/bash/unknown)
160
+ // tool, so every call reached here is read-only. A null means a
161
+ // non-barrier stub — intra-turn in-flight dup, repeat-failure /
162
+ // cross-turn dedup, pre-dispatch-deny, invalid-args, or a cache
163
+ // short-circuit. `continue` (not `break`) so a stub in the
164
+ // middle of a contiguous eager run does not stop LATER
165
+ // independent eager reads from starting early.
166
+ if (!startEagerTool(call) && !pending.has(call.id)) continue;
141
167
  }
142
168
  };
143
169
  let _streamEagerBlocked = false;
@@ -2,8 +2,6 @@
2
2
  // loop.mjs. These drive cross-turn read dedup, scoped caching, shell routing,
3
3
  // and duplicate-call detection. Strips the MCP prefix so direct calls and
4
4
  // MCP-wrapped calls share the same cache.
5
- import { createHash } from 'crypto';
6
-
7
5
  export const MCP_TOOL_PREFIX = 'mcp__plugin_mixdog_mixdog__';
8
6
 
9
7
  export function _stripMcpPrefix(name) {
@@ -73,5 +71,5 @@ export function _canonicalArgs(args) {
73
71
  } catch { return String(args); }
74
72
  }
75
73
  export function _intraTurnSig(name, args) {
76
- return createHash('sha256').update(`${name}:${_canonicalArgs(args)}`).digest('hex').slice(0, 16);
74
+ return `${name}:${_canonicalArgs(args)}`;
77
75
  }
@@ -16,10 +16,23 @@ import { isAgentOwner } from '../../agent-owner.mjs';
16
16
  // Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
17
17
  // to execute during SSE parsing so tool work overlaps with the rest of the
18
18
  // stream. Writes, bash, MCP and skills stay serial after send() returns.
19
+ // Memoized: the read-only name Set is built once per distinct `tools` array
20
+ // (keyed by identity via a module-level WeakMap) so repeated per-call lookups
21
+ // are O(1) instead of O(N) tools.find scans.
22
+ const _eagerNameSetByTools = new WeakMap();
19
23
  export function isEagerDispatchable(name, tools) {
20
24
  if (!Array.isArray(tools)) return false;
21
- const def = tools.find(t => t?.name === name);
22
- return def?.annotations?.readOnlyHint === true;
25
+ let set = _eagerNameSetByTools.get(tools);
26
+ if (set === undefined) {
27
+ set = new Set();
28
+ for (const t of tools) {
29
+ if (t?.annotations?.readOnlyHint === true && typeof t.name === 'string') {
30
+ set.add(t.name);
31
+ }
32
+ }
33
+ _eagerNameSetByTools.set(tools, set);
34
+ }
35
+ return set.has(name);
23
36
  }
24
37
  export function messagesArrayChanged(before, after) {
25
38
  if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
@@ -340,25 +340,24 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
340
340
  onToolResult: typeof askOpts?.onToolResult === 'function' ? askOpts.onToolResult : undefined,
341
341
  onToolApproval: typeof askOpts?.onToolApproval === 'function' ? askOpts.onToolApproval : undefined,
342
342
  onCompactEvent: typeof askOpts?.onCompactEvent === 'function' ? askOpts.onCompactEvent : undefined,
343
- // Mid-turn steering drain. agentLoop calls this at every
344
- // tool-batch boundary (before the next provider.send) and
345
- // injects any returned strings as user turns so input
346
- // (user typing, `agent type=send`) that arrives WHILE a
347
- // long multi-tool turn is in flight is picked up on the
348
- // model's very next iteration instead of waiting for the
349
- // whole task to finish. The post-turn _pendingTail drain
350
- // below still handles "followUp" input that lands after the
351
- // agent would otherwise stop. Same queue, two drain points.
352
- drainSteering: (sid) => {
343
+ // Claude Code parity: mid-chain queued prompt/notification
344
+ // drain is owned by agentLoop at provider-continuation
345
+ // boundaries (after a tool batch, before the next send).
346
+ // The post-loop _pendingTail drain below still handles
347
+ // items that arrive after the model would otherwise stop.
348
+ drainSteering: (sid, drainOptions = {}) => {
353
349
  const out = [];
354
350
  if (typeof askOpts?.drainSteering === 'function') {
355
351
  try {
356
- const drained = askOpts.drainSteering(sid || sessionId);
352
+ const drained = askOpts.drainSteering(sid || sessionId, drainOptions);
357
353
  if (Array.isArray(drained)) out.push(...drained);
358
354
  } catch { /* best-effort steering drain */ }
359
355
  }
360
- try { out.push(...drainPendingMessages(sid || sessionId)); }
361
- catch { /* best-effort pending drain */ }
356
+ // Do NOT drain manager/pending-messages here: those
357
+ // entries have no mode/priority/slash metadata, so
358
+ // draining them mid-chain would bypass Claude Code's
359
+ // queued_command filters. They are consumed by the
360
+ // post-loop _pendingTail drain below.
362
361
  return out;
363
362
  },
364
363
  onSteerMessage: typeof askOpts?.onSteerMessage === 'function' ? askOpts.onSteerMessage : undefined,
@@ -574,6 +573,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
574
573
  // owning background task in `running` and suppress its completion
575
574
  // notification. Cap the wait; let a slow write finish in the
576
575
  // background instead of holding askSession() open indefinitely.
576
+ let terminalSaveTimedOut = false;
577
577
  {
578
578
  const savePromise = saveSessionAsync(session, { expectedGeneration: askGeneration });
579
579
  let saveTimer = null;
@@ -587,6 +587,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
587
587
  saveTimeout,
588
588
  ]);
589
589
  if (outcome === '__save_timeout__') {
590
+ terminalSaveTimedOut = true;
590
591
  try { process.stderr.write(`[session] terminal save exceeded ${TERMINAL_SAVE_TIMEOUT_MS}ms; continuing best-effort (${sessionId})\n`); } catch {}
591
592
  // Don't drop the write — let it settle in the background.
592
593
  savePromise.catch((err) => {
@@ -674,10 +675,18 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
674
675
  const _mergedTail = _mergePendingMessageEntries(_drained);
675
676
  if (_mergedTail?.content) {
676
677
  _pendingTail.push(_mergedTail.content);
677
- const refreshed = loadSession(sessionId);
678
- if (refreshed && refreshed.closed !== true) {
679
- activeSession = refreshed;
680
- runtime.session = refreshed;
678
+ if (terminalSaveTimedOut) {
679
+ // The disk snapshot may still be stale; carry the just-
680
+ // committed in-memory session into the follow-up turn so
681
+ // the queued tail sees the preceding assistant/tool context.
682
+ activeSession = session;
683
+ runtime.session = session;
684
+ } else {
685
+ const refreshed = loadSession(sessionId);
686
+ if (refreshed && refreshed.closed !== true) {
687
+ activeSession = refreshed;
688
+ runtime.session = refreshed;
689
+ }
681
690
  }
682
691
  continue;
683
692
  }
@@ -73,13 +73,13 @@ function temporalPromptText(content) {
73
73
  function promptNeedsDateReminder(content) {
74
74
  const text = temporalPromptText(content);
75
75
  if (!text) return false;
76
- return /(?:오늘|내일|어제|모레|그저께|요즘|최근|방금|아까|현재\s*(?:날짜|시간|시각)|지금\s*(?:몇\s*시|시간|날짜|요일)|몇\s*월\s*몇\s*일|몇\s*시|무슨\s*요일|요일|날짜|이번\s*(?:주|달|월|년)|지난\s*(?:주|달|월|년)|다음\s*(?:주|달|월|년)|올해|작년|내년|today|tomorrow|yesterday|recently|current\s+(?:date|time)|what\s+(?:date|time)|which\s+day|weekday|this\s+(?:week|month|year)|last\s+(?:week|month|year)|next\s+(?:week|month|year))/i.test(text);
76
+ return /(?:\uC624\uB298|\uB0B4\uC77C|\uC5B4\uC81C|\uBAA8\uB808|\uADF8\uC800\uAED8|\uC694\uC998|\uCD5C\uADFC|\uBC29\uAE08|\uC544\uAE4C|\uD604\uC7AC\s*(?:\uB0A0\uC9DC|\uC2DC\uAC04|\uC2DC\uAC01)|\uC9C0\uAE08\s*(?:\uBA87\s*\uC2DC|\uC2DC\uAC04|\uB0A0\uC9DC|\uC694\uC77C)|\uBA87\s*\uC6D4\s*\uBA87\s*\uC77C|\uBA87\s*\uC2DC|\uBB34\uC2A8\s*\uC694\uC77C|\uC694\uC77C|\uB0A0\uC9DC|\uC774\uBC88\s*(?:\uC8FC|\uB2EC|\uC6D4|\uB144)|\uC9C0\uB09C\s*(?:\uC8FC|\uB2EC|\uC6D4|\uB144)|\uB2E4\uC74C\s*(?:\uC8FC|\uB2EC|\uC6D4|\uB144)|\uC62C\uD574|\uC791\uB144|\uB0B4\uB144|today|tomorrow|yesterday|recently|current\s+(?:date|time)|what\s+(?:date|time)|which\s+day|weekday|this\s+(?:week|month|year)|last\s+(?:week|month|year)|next\s+(?:week|month|year))/i.test(text);
77
77
  }
78
78
 
79
79
  function promptNeedsTimeReminder(content) {
80
80
  const text = temporalPromptText(content);
81
81
  if (!text) return false;
82
- return /(?:현재\s*(?:시간|시각)|지금\s*(?:몇\s*시|시간)|몇\s*시|시각|시간|current\s+time|what\s+time|time\s+is\s+it)/i.test(text);
82
+ return /(?:\uD604\uC7AC\s*(?:\uC2DC\uAC04|\uC2DC\uAC01)|\uC9C0\uAE08\s*(?:\uBA87\s*\uC2DC|\uC2DC\uAC04)|\uBA87\s*\uC2DC|\uC2DC\uAC01|\uC2DC\uAC04|current\s+time|what\s+time|time\s+is\s+it)/i.test(text);
83
83
  }
84
84
 
85
85
  export function buildCurrentTimeBlock(content) {
@@ -34,6 +34,27 @@ import { getAgentRuntimeSync, warnAgentRuntimeResolveFailureOnce } from './agent
34
34
  import { mintSessionId } from './session-id.mjs';
35
35
  import { providerCacheKey } from './provider-cache-key.mjs';
36
36
 
37
+ function buildSessionProviderCacheOpts(providerName, sessionId, agent = null) {
38
+ // Keep this in sync with createSession's provider-cache policy: only
39
+ // explicit-breakpoint providers get BP cache opts here; OpenAI/key-prefix
40
+ // providers use promptCacheKey and request-time strategy instead.
41
+ if (cacheCapabilityForProvider(providerName) !== 'explicit-breakpoint') return null;
42
+ try {
43
+ let autoClear = null;
44
+ if (!agent || agent === 'lead') {
45
+ const loadedConfig = loadConfig({ secrets: false });
46
+ const normalizedAutoClear = normalizeAutoClearConfig(loadedConfig?.autoClear);
47
+ autoClear = {
48
+ ...normalizedAutoClear,
49
+ idleMs: resolveAutoClearIdleMs(loadedConfig, providerName),
50
+ };
51
+ }
52
+ return buildProviderCacheOpts(providerName, sessionId, agent, { autoClear });
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
37
58
  // --- agent spawn (createSession) ---
38
59
  // opts can pass either a `preset` object (from config.presets) or raw provider/model.
39
60
  // Preset shape: { name, provider, model, effort?, fast?, tools? }
@@ -107,22 +128,7 @@ export function createSession(opts) {
107
128
  // cacheRetention:'24h' shape) were never exercised by createSession
108
129
  // before this change, and are left untouched to avoid altering live
109
130
  // OpenAI/other-provider request shape as a side effect of this fix.
110
- if (!providerCacheOpts && cacheCapabilityForProvider(providerName) === 'explicit-breakpoint') {
111
- try {
112
- let autoClear = null;
113
- if (!opts.agent || opts.agent === 'lead') {
114
- const loadedConfig = loadConfig({ secrets: false });
115
- const normalizedAutoClear = normalizeAutoClearConfig(loadedConfig?.autoClear);
116
- autoClear = {
117
- ...normalizedAutoClear,
118
- idleMs: resolveAutoClearIdleMs(loadedConfig, providerName),
119
- };
120
- }
121
- providerCacheOpts = buildProviderCacheOpts(providerName, id, opts.agent, { autoClear });
122
- } catch {
123
- providerCacheOpts = null;
124
- }
125
- }
131
+ if (!providerCacheOpts) providerCacheOpts = buildSessionProviderCacheOpts(providerName, id, opts.agent);
126
132
  const messages = [];
127
133
  const ownerIsAgent = isAgentOwner(opts.owner);
128
134
  const resolvedAgent = opts.agent || opts.role || profile?.taskType || null;
@@ -425,6 +431,8 @@ export function updateSessionRoute(id, route = {}) {
425
431
  || (route.model && route.model !== previousModel);
426
432
  if (routeChanged) {
427
433
  const now = Date.now();
434
+ session.promptCacheKey = providerCacheKey(session.provider);
435
+ session.providerCacheOpts = buildSessionProviderCacheOpts(session.provider, session.id, session.agent) || null;
428
436
  session.lastInputTokens = 0;
429
437
  session.lastOutputTokens = 0;
430
438
  session.lastCachedReadTokens = 0;
@@ -8,6 +8,8 @@
8
8
  *
9
9
  * Matches documented tool-return error conventions:
10
10
  * "Error: ..." — Node/MCP tool errors (grep, find_symbol, read, code_graph, etc.)
11
+ * "Error: [shell-tool-failed] ..." — shell tool/control-plane failure
12
+ * "Error: [shell-run-failed] ..." — shell command process failure
11
13
  * "Error [code N]:" — structured builtin tool errors
12
14
  * "[error ..." — bracketed error format
13
15
  * "[exit code: ..." — bash non-zero exit
@@ -96,6 +96,9 @@ export async function processToolBatch(ctx) {
96
96
  if (isBuiltinTool(call.name)) {
97
97
  call.name = canonicalizeBuiltinToolName(call.name);
98
98
  }
99
+ // Per-call cross-turn signature, computed at most once (lazily, only
100
+ // when the call is eager — both consumer sites are eager-gated).
101
+ let _ctSig = null;
99
102
  if (_duplicateCallIds.has(call.id)) {
100
103
  const _firstId = _dupFirstId.get(call.id);
101
104
  const _stub = `[intra-turn-dedup] identical read-only \`${call.name}\` call was already executed in this same assistant turn as tool_use_id=${_firstId}. The first call's tool_result is in context immediately above; skipping re-execution to save tokens. If you needed a different slice of the file, narrow the next call (different path / offset / limit / pattern) so it has a distinct signature.`;
@@ -113,7 +116,7 @@ export async function processToolBatch(ctx) {
113
116
  // escalation tail once the session has emitted 5+ dedup stubs total.
114
117
  // Never applies to write/bash/MCP/skill tools (not eager-dispatchable).
115
118
  if (isEagerDispatchable(call.name, tools)) {
116
- const _ctSig = crossTurnSignature(call.name, call.arguments);
119
+ _ctSig = crossTurnSignature(call.name, call.arguments);
117
120
  const _prior = crossTurnCalls.get(_ctSig);
118
121
  if (_prior && _prior.firstIteration < iterations) {
119
122
  _prior.count += 1;
@@ -576,7 +579,7 @@ export async function processToolBatch(ctx) {
576
579
  if (_executeOk) {
577
580
  const _isEager = isEagerDispatchable(call.name, tools);
578
581
  if (_isEager) {
579
- const _ctSig = crossTurnSignature(call.name, call.arguments);
582
+ if (_ctSig === null) _ctSig = crossTurnSignature(call.name, call.arguments);
580
583
  if (!crossTurnCalls.has(_ctSig)) {
581
584
  crossTurnCalls.set(_ctSig, { count: 1, firstIteration: iterations });
582
585
  if (crossTurnCalls.size > crossTurnCap) {
@@ -219,6 +219,24 @@ export const PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS = resolveTimeoutMs(
219
219
  { minMs: 10_000, maxMs: STALL_WARN_MS },
220
220
  );
221
221
 
222
+ // WS-specific semantic idle window. Do NOT reuse
223
+ // PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS here: that shared SSE default was raised to
224
+ // ~285s to avoid false-aborting Anthropic effort-mode streams that legitimately
225
+ // produce no deltas while thinking. The WS transport is different: a silent
226
+ // OpenAI/xAI websocket turn can sit with only connection/metadata activity while
227
+ // the TUI lead-turn watchdog is also 300s. If the provider waits ~285-300s
228
+ // before surfacing the stall, the UI watchdog often wins and the user only sees
229
+ // "Turn timed out after 300s" instead of a provider retry/fallback.
230
+ //
231
+ // Keep WS hangs bounded well below the TUI watchdog while still allowing normal
232
+ // long reasoning streams that emit reasoning/text/tool deltas (those reset this
233
+ // timer). Env-overridable for experiments.
234
+ export const PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS = resolveTimeoutMs(
235
+ ['MIXDOG_PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS', 'MIXDOG_PROVIDER_WS_OUTPUT_IDLE_TIMEOUT_MS'],
236
+ 120_000,
237
+ { minMs: 10_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
238
+ );
239
+
222
240
  // First retry has a small floor (250ms) instead of 0ms: an immediate reissue on
223
241
  // a transient 5xx burst lets parallel workers thundering-herd the backend in
224
242
  // lockstep (jitter alone can't decluster a 0ms base). Subsequent steps keep the
@@ -168,10 +168,15 @@ function _prependDestructiveWarning(command, text) {
168
168
  return `${warnings.map((w) => `⚠️ ${w}`).join('\n')}\n${text}`;
169
169
  }
170
170
 
171
+ export function formatShellToolFailure(message) {
172
+ const text = String(message ?? '').replace(/^Error:\s*/i, '').trim() || 'shell tool failed';
173
+ return `Error: [shell-tool-failed] ${text}`;
174
+ }
175
+
171
176
  export async function executeBashTool(args, workDir, options = {}) {
172
177
  const requestedCwd = args.cwd ?? args.workdir;
173
178
  const cwdResult = resolveOptionalCwd(requestedCwd, workDir);
174
- if (cwdResult.error) return cwdResult.error;
179
+ if (cwdResult.error) return formatShellToolFailure(cwdResult.error);
175
180
  // Session cwd carry-over (no live shell): when the model
176
181
  // passes an explicit cwd it wins and updates the store on the next probe;
177
182
  // otherwise reuse the last stored cwd for this session if it still exists.
@@ -199,7 +204,7 @@ export async function executeBashTool(args, workDir, options = {}) {
199
204
  // persistent:true. Run the full sweep here so both paths share the
200
205
  // same blocklist before dispatch.
201
206
  const _policyBlock = checkExecPolicyMessage(_rawCmd);
202
- if (_policyBlock) return _policyBlock;
207
+ if (_policyBlock) return formatShellToolFailure(_policyBlock);
203
208
  }
204
209
 
205
210
  // An empty-string session_id is NOT a persistent-session request: `typeof
@@ -208,7 +213,7 @@ export async function executeBashTool(args, workDir, options = {}) {
208
213
  // error, which models then retry in a loop. Require a non-blank id.
209
214
  if (args.persistent === true || (typeof args.session_id === 'string' && args.session_id.trim().length > 0)) {
210
215
  if (process.platform === 'win32') {
211
- return 'Error: persistent shell sessions are disabled on Windows native-shell mode; run one-shot PowerShell commands without persistent/session_id.';
216
+ return formatShellToolFailure('persistent shell sessions are disabled on Windows native-shell mode; run one-shot PowerShell commands without persistent/session_id.');
212
217
  }
213
218
  const { executeBashSessionTool } = await import('../bash-session.mjs');
214
219
  let persistAbort = null;
@@ -227,7 +232,7 @@ export async function executeBashTool(args, workDir, options = {}) {
227
232
  }
228
233
 
229
234
  let command = args.command;
230
- if (!command) return 'Error: command is required';
235
+ if (!command) return formatShellToolFailure('command is required');
231
236
 
232
237
  // Resolve the shell up front so shell-type-specific handling (PS-only wmic
233
238
  // rewrite, PS UTF-8 prefix) can gate on it. kind 'default' is byte-identical
@@ -238,9 +243,9 @@ export async function executeBashTool(args, workDir, options = {}) {
238
243
  const resolvedSpec = resolveShellFor(shellKind);
239
244
  if (!resolvedSpec) {
240
245
  if (shellKind === 'bash') {
241
- return "Error: Git Bash not found — install Git for Windows or omit shell:'bash'.";
246
+ return formatShellToolFailure("Git Bash not found — install Git for Windows or omit shell:'bash'.");
242
247
  }
243
- return "Error: pwsh (PowerShell) not found — install PowerShell or omit shell:'powershell'.";
248
+ return formatShellToolFailure("pwsh (PowerShell) not found — install PowerShell or omit shell:'powershell'.");
244
249
  }
245
250
 
246
251
  // wmic→PowerShell rewrite is PowerShell-only; never mangle a command bound
@@ -251,7 +256,7 @@ export async function executeBashTool(args, workDir, options = {}) {
251
256
  const wmicRewrite = resolvedSpec.shellType === 'powershell'
252
257
  ? maybeRewriteWmicProcessCommand(command)
253
258
  : null;
254
- if (wmicRewrite?.error) return `Error: ${wmicRewrite.error}`;
259
+ if (wmicRewrite?.error) return formatShellToolFailure(wmicRewrite.error);
255
260
  if (wmicRewrite?.command) command = wmicRewrite.command;
256
261
 
257
262
  // PowerShell hygiene preflight (Windows PS-only; POSIX no-op): losslessly
@@ -262,19 +267,19 @@ export async function executeBashTool(args, workDir, options = {}) {
262
267
  shellType: resolvedSpec.shellType,
263
268
  shellName: resolvedSpec.shell,
264
269
  });
265
- if (psHygiene.block) return psHygiene.block;
270
+ if (psHygiene.block) return formatShellToolFailure(psHygiene.block);
266
271
  command = psHygiene.command;
267
272
 
268
273
  const _execPolicyBlock = checkExecPolicyMessage(command);
269
274
  if (_execPolicyBlock) {
270
- return _execPolicyBlock;
275
+ return formatShellToolFailure(_execPolicyBlock);
271
276
  }
272
277
 
273
278
  let shellEffects;
274
279
  try {
275
280
  shellEffects = await analyzeShellCommandEffects(command, bashWorkDir);
276
281
  } catch (err) {
277
- return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
282
+ return formatShellToolFailure(normalizeErrorMessage(err instanceof Error ? err.message : String(err)));
278
283
  }
279
284
  // Keep foreground commands on a long tool-owned timeout. The MCP dispatch
280
285
  // layer must not add a shorter fallback ceiling when timeout is omitted.
@@ -314,7 +319,7 @@ export async function executeBashTool(args, workDir, options = {}) {
314
319
  : Math.min(timeoutMs, wmicRewrite?.timeoutMs || MAX_BASH_TIMEOUT_MS));
315
320
  const mergeStderr = args.merge_stderr === true;
316
321
  const longForegroundHint = foregroundLongCommandHint(command, timeout, { ...args, run_in_background: runInBackground });
317
- if (longForegroundHint) return longForegroundHint;
322
+ if (longForegroundHint) return formatShellToolFailure(longForegroundHint);
318
323
  // Auto-background threshold. Reference-CLI parity: sync commands run to
319
324
  // their timeout without any default auto-promotion, so the default is 0
320
325
  // (disabled) for ALL callers. It is an explicit opt-in only: set
@@ -347,7 +352,7 @@ export async function executeBashTool(args, workDir, options = {}) {
347
352
  try {
348
353
  wrappedCommand = await wrapCommandWithSnapshot(shell, command);
349
354
  } catch (wrapErr) {
350
- return `Error: shell snapshot wrapper failed — ${normalizeErrorMessage(wrapErr instanceof Error ? wrapErr.message : String(wrapErr))}`;
355
+ return formatShellToolFailure(`shell snapshot wrapper failed — ${normalizeErrorMessage(wrapErr instanceof Error ? wrapErr.message : String(wrapErr))}`);
351
356
  }
352
357
  } else {
353
358
  wrappedCommand = command;
@@ -367,7 +372,7 @@ export async function executeBashTool(args, workDir, options = {}) {
367
372
  // claude.exe pid (server-main threads callerSession.clientHostPid).
368
373
  clientHostPid: options?.clientHostPid,
369
374
  });
370
- if (job && job.error) return `Error: ${job.error}`;
375
+ if (job && job.error) return formatShellToolFailure(job.error);
371
376
  let task;
372
377
  try {
373
378
  task = registerBackgroundTask({
@@ -395,7 +400,7 @@ export async function executeBashTool(args, workDir, options = {}) {
395
400
  });
396
401
  } catch (err) {
397
402
  try { killShellJob(job.jobId); } catch { /* best effort cleanup */ }
398
- return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
403
+ return formatShellToolFailure(normalizeErrorMessage(err instanceof Error ? err.message : String(err)));
399
404
  }
400
405
  // Wire a one-shot completion push so the dispatching session learns
401
406
  // the background task finished (no polling tool is auto-driven). The
@@ -525,7 +530,9 @@ export async function executeBashTool(args, workDir, options = {}) {
525
530
  : (result.killed ? 'SIGKILL' : (result.signal || null));
526
531
  const exitCode = signal ? null : result.exitCode;
527
532
  const benignExitOne = _isBenignSearchExitOne(command, exitCode, signal, stderr);
528
- const isReallyErrored = !!signal || (exitCode !== 0 && exitCode !== null && !benignExitOne);
533
+ const shellToolFailed = result.failurePhase === 'tool' || !!result.outputCaptureError;
534
+ const shellRunFailed = !shellToolFailed && (!!signal || (exitCode !== 0 && exitCode !== null && !benignExitOne));
535
+ const isReallyErrored = shellToolFailed || shellRunFailed;
529
536
  const _driftNote = '';
530
537
  // Distinct timeout marker so callers see "killed by timeout after Nms"
531
538
  // vs an external signal (e.g. user Ctrl-C, OOM kill). result.timedOut
@@ -538,11 +545,16 @@ export async function executeBashTool(args, workDir, options = {}) {
538
545
  const timeoutHint = result.timedOut
539
546
  ? ` — command killed after ${timeout} ms; if it legitimately needs longer, retry with a larger timeout`
540
547
  : '';
541
- const statusMarker = result.timedOut
542
- ? `[timeout: ${timeout}ms signal: ${signal || 'SIGTERM'}]${timeoutHint}`
543
- : (signal
544
- ? `[signal: ${signal}]`
545
- : (isReallyErrored ? `[exit code: ${exitCode}]` : ''));
548
+ const statusDetail = shellToolFailed
549
+ ? `[${result.outputCaptureError ? 'output capture failed' : (result.failureReason || 'tool failed')}]`
550
+ : (result.timedOut
551
+ ? `[timeout: ${timeout}ms signal: ${signal || 'SIGTERM'}]${timeoutHint}`
552
+ : (signal
553
+ ? `[signal: ${signal}]`
554
+ : (shellRunFailed ? `[exit code: ${exitCode}]` : '')));
555
+ const statusMarker = shellToolFailed
556
+ ? `[shell-tool-failed] ${statusDetail}`
557
+ : (shellRunFailed ? `[shell-run-failed] ${statusDetail}` : '');
546
558
  const errorPrefix = isReallyErrored ? 'Error: ' : '';
547
559
  if (mergeStderr) {
548
560
  // Post-exit concatenation. True chunk-level interleaving would
@@ -569,6 +581,9 @@ export async function executeBashTool(args, workDir, options = {}) {
569
581
  const sizeKb = Math.round((result.stderrFileSize || 0) / 1024);
570
582
  spillBlock += `\n[stderr: ${normalizeOutputPath(result.stderrPath)} (${sizeKb} KB)]`;
571
583
  }
584
+ if (result.outputCaptureError) {
585
+ spillBlock += `\n[tool capture error: ${normalizeErrorMessage(result.outputCaptureError?.message || String(result.outputCaptureError))}]`;
586
+ }
572
587
  const warningBlock = [
573
588
  wmicRewrite?.note || '',
574
589
  ].filter(Boolean).join('\n');
@@ -31,7 +31,7 @@ function _shellMaxTimeoutMs() {
31
31
  // the process lifetime, so this is evaluated once at module load.
32
32
  const _shellSyntaxCheat =
33
33
  process.platform === 'win32'
34
- ? ' PowerShell: grep→Select-String, tail→Get-Content -Tail, head→Get-Content -TotalCount, /c/→C:\\, && 미지원 ; 사용, $PID 예약.'
34
+ ? ' PowerShell: grep→Select-String, tail→Get-Content -Tail, head→Get-Content -TotalCount, /c/→C:\\, if && is unsupported use ;, $PID is reserved.'
35
35
  : '';
36
36
 
37
37
  export const BUILTIN_TOOLS = [
@@ -109,7 +109,7 @@ export const BUILTIN_TOOLS = [
109
109
  name: 'grep',
110
110
  title: 'Mixdog Grep',
111
111
  annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
112
- description: 'Exact text/regex in a verified file/dir scope. Unknown scope → find/glob first. files_with_matches/count for broad anchors, content_with_context for narrow answers.',
112
+ description: 'Exact text/regex in verified scope. Unknown path/name → find first; no path "." + guessed src/**. Broad: files_with_matches/count; narrow: content_with_context.',
113
113
  inputSchema: {
114
114
  type: 'object',
115
115
  properties: {
@@ -125,14 +125,14 @@ export const BUILTIN_TOOLS = [
125
125
  { type: 'string' },
126
126
  { type: 'array', items: { type: 'string' }, minItems: 1 },
127
127
  ],
128
- description: 'Verified file or dir. Array = several scopes.',
128
+ description: 'Verified file/dir only; unknown find first.',
129
129
  },
130
130
  glob: {
131
131
  anyOf: [
132
132
  { type: 'string' },
133
133
  { type: 'array', items: { type: 'string' }, minItems: 1 },
134
134
  ],
135
- description: 'Glob filter to narrow scope.',
135
+ description: 'Glob filter; no guessed src/** under path ".".',
136
136
  },
137
137
  output_mode: { type: 'string', enum: ['content_with_context', 'content', 'files_with_matches', 'count'], description: 'Broad: files_with_matches/count; narrow: content_with_context.' },
138
138
  head_limit: { type: 'number', minimum: 0, description: 'Max results.' },
@@ -148,7 +148,7 @@ export const BUILTIN_TOOLS = [
148
148
  name: 'glob',
149
149
  title: 'Mixdog Glob',
150
150
  annotations: { title: 'Mixdog Glob', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
151
- description: 'Find files by exact glob from verified roots. Batch patterns and roots as arrays in one call.',
151
+ description: 'Exact glob from verified roots. Unknown root/name find first; no path "." + guessed src/**. Batch arrays.',
152
152
  inputSchema: {
153
153
  type: 'object',
154
154
  properties: {
@@ -164,7 +164,7 @@ export const BUILTIN_TOOLS = [
164
164
  { type: 'string' },
165
165
  { type: 'array', items: { type: 'string' }, minItems: 1 },
166
166
  ],
167
- description: 'Base directory/directories. Batch multiple roots as path[] in one call.',
167
+ description: 'Verified base dir(s); unknown find first. Batch path[].',
168
168
  },
169
169
  head_limit: { type: 'number', description: 'Max entries.' },
170
170
  offset: { type: 'number', description: 'Skip entries.' },
@@ -176,7 +176,7 @@ export const BUILTIN_TOOLS = [
176
176
  name: 'find',
177
177
  title: 'Mixdog Find Files',
178
178
  annotations: { title: 'Mixdog Find Files', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
179
- description: 'Find files by partial path/name, including dot-directories. Use for unverified path/name guesses; returns verified paths. Batch query[].',
179
+ description: 'Partial path/name lookup incl dot dirs; verify roots before grep/glob. Batch query[].',
180
180
  inputSchema: {
181
181
  type: 'object',
182
182
  properties: {