mixdog 0.9.36 → 0.9.38

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 (115) 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/dispatch-persist-recovery-test.mjs +141 -0
  5. package/scripts/explore-bench-tmp.mjs +19 -0
  6. package/scripts/explore-bench.mjs +101 -14
  7. package/scripts/explore-prompt-policy-test.mjs +31 -0
  8. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  9. package/scripts/notify-completion-mirror-test.mjs +73 -0
  10. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  11. package/scripts/output-style-smoke.mjs +17 -17
  12. package/scripts/provider-toolcall-test.mjs +333 -14
  13. package/scripts/recall-bench-cases.json +3 -3
  14. package/scripts/recall-bench.mjs +1 -1
  15. package/scripts/recall-quality-cases.json +4 -4
  16. package/scripts/recall-usecase-cases.json +2 -2
  17. package/scripts/session-bench.mjs +13 -13
  18. package/scripts/session-sweep.mjs +266 -0
  19. package/scripts/steering-drain-buckets-test.mjs +72 -11
  20. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  21. package/scripts/tool-smoke.mjs +83 -10
  22. package/src/app.mjs +2 -1
  23. package/src/defaults/cycle3-review-prompt.md +9 -0
  24. package/src/defaults/skills/setup/SKILL.md +93 -293
  25. package/src/lib/rules-builder.cjs +3 -2
  26. package/src/output-styles/default.md +2 -2
  27. package/src/output-styles/extreme-minimal.md +1 -1
  28. package/src/output-styles/minimal.md +1 -1
  29. package/src/output-styles/simple.md +2 -3
  30. package/src/rules/agent/30-explorer.md +40 -14
  31. package/src/rules/lead/01-general.md +4 -0
  32. package/src/rules/shared/01-tool.md +7 -3
  33. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  34. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  38. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  39. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  42. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  43. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  44. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  46. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  47. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -2
  48. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  49. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  50. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  51. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  52. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  53. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  54. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  55. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  56. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  57. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +30 -55
  58. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  59. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  61. package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
  62. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  63. package/src/runtime/agent/orchestrator/stall-policy.mjs +55 -0
  64. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  65. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
  66. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  68. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  70. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  73. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  75. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  76. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  77. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  78. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  79. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  80. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  81. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  82. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  83. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  84. package/src/runtime/shared/config.mjs +98 -12
  85. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  86. package/src/session-runtime/cwd-plugins.mjs +6 -3
  87. package/src/session-runtime/model-route-api.mjs +50 -2
  88. package/src/session-runtime/provider-auth-api.mjs +8 -1
  89. package/src/session-runtime/provider-models.mjs +3 -3
  90. package/src/session-runtime/resource-api.mjs +22 -0
  91. package/src/session-runtime/runtime-core.mjs +56 -10
  92. package/src/session-runtime/settings-api.mjs +11 -2
  93. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  94. package/src/standalone/agent-tool.mjs +15 -9
  95. package/src/standalone/explore-tool.mjs +4 -1
  96. package/src/tui/App.jsx +6 -5
  97. package/src/tui/app/transcript-window.mjs +60 -10
  98. package/src/tui/app/use-transcript-window.mjs +2 -1
  99. package/src/tui/components/ContextPanel.jsx +4 -1
  100. package/src/tui/components/ToolExecution.jsx +15 -12
  101. package/src/tui/components/TranscriptItem.jsx +1 -1
  102. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  103. package/src/tui/dist/index.mjs +482 -151
  104. package/src/tui/engine/agent-job-feed.mjs +36 -6
  105. package/src/tui/engine/notification-plan.mjs +3 -3
  106. package/src/tui/engine/queue-helpers.mjs +8 -0
  107. package/src/tui/engine/render-timing.mjs +104 -8
  108. package/src/tui/engine/session-api.mjs +58 -3
  109. package/src/tui/engine/session-flow.mjs +93 -29
  110. package/src/tui/engine/tool-card-results.mjs +28 -13
  111. package/src/tui/engine/turn.mjs +238 -157
  112. package/src/tui/engine.mjs +17 -8
  113. package/src/tui/index.jsx +10 -1
  114. package/src/workflows/default/WORKFLOW.md +8 -4
  115. package/src/workflows/solo/WORKFLOW.md +8 -4
@@ -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;
@@ -44,14 +44,6 @@ import { _tryBridgeExplicitPrefetch } from './prefetch-bridge.mjs';
44
44
  import { sanitizeSessionMessagesForModel, persistCompactedOutgoingAfterAskFailure } from './message-sanitize.mjs';
45
45
  import { _getAgentLoop } from './runtime-loaders.mjs';
46
46
  import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
47
- import { nonNegativeIntEnv } from './env-utils.mjs';
48
-
49
- // Cap how long the terminal unwind blocks on the post-result session save.
50
- // The result is already produced (and relayed for agent surfaces) before this
51
- // save, so a stalled disk write must not hold askSession() open — otherwise the
52
- // owning background task is stranded in `running` and its completion
53
- // notification never fires. A slow write finishes in the background.
54
- const TERMINAL_SAVE_TIMEOUT_MS = nonNegativeIntEnv('MIXDOG_TERMINAL_SAVE_TIMEOUT_MS', 5_000);
55
47
 
56
48
  /**
57
49
  * Wrap an async call so that if the session's controller aborts mid-flight,
@@ -340,25 +332,24 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
340
332
  onToolResult: typeof askOpts?.onToolResult === 'function' ? askOpts.onToolResult : undefined,
341
333
  onToolApproval: typeof askOpts?.onToolApproval === 'function' ? askOpts.onToolApproval : undefined,
342
334
  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) => {
335
+ // Claude Code parity: mid-chain queued prompt/notification
336
+ // drain is owned by agentLoop at provider-continuation
337
+ // boundaries (after a tool batch, before the next send).
338
+ // The post-loop _pendingTail drain below still handles
339
+ // items that arrive after the model would otherwise stop.
340
+ drainSteering: (sid, drainOptions = {}) => {
353
341
  const out = [];
354
342
  if (typeof askOpts?.drainSteering === 'function') {
355
343
  try {
356
- const drained = askOpts.drainSteering(sid || sessionId);
344
+ const drained = askOpts.drainSteering(sid || sessionId, drainOptions);
357
345
  if (Array.isArray(drained)) out.push(...drained);
358
346
  } catch { /* best-effort steering drain */ }
359
347
  }
360
- try { out.push(...drainPendingMessages(sid || sessionId)); }
361
- catch { /* best-effort pending drain */ }
348
+ // Do NOT drain manager/pending-messages here: those
349
+ // entries have no mode/priority/slash metadata, so
350
+ // draining them mid-chain would bypass Claude Code's
351
+ // queued_command filters. They are consumed by the
352
+ // post-loop _pendingTail drain below.
362
353
  return out;
363
354
  },
364
355
  onSteerMessage: typeof askOpts?.onSteerMessage === 'function' ? askOpts.onSteerMessage : undefined,
@@ -568,35 +559,17 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
568
559
  // query/provider send (agentLoop pre-send), not after the previous
569
560
  // answer. This lets queued follow-up prompts resume immediately;
570
561
  // if they need compaction, their own spinner shows compacting first.
571
- // Bounded, best-effort terminal save. The result is already produced
572
- // and (for agent surfaces) relayed via onTerminalResult above. If the
573
- // disk write stalls, blocking the terminal unwind here would strand the
574
- // owning background task in `running` and suppress its completion
575
- // notification. Cap the wait; let a slow write finish in the
576
- // background instead of holding askSession() open indefinitely.
577
- {
578
- const savePromise = saveSessionAsync(session, { expectedGeneration: askGeneration });
579
- let saveTimer = null;
580
- const saveTimeout = new Promise((resolveTimeout) => {
581
- saveTimer = setTimeout(() => resolveTimeout('__save_timeout__'), TERMINAL_SAVE_TIMEOUT_MS);
582
- saveTimer.unref?.();
583
- });
584
- try {
585
- const outcome = await Promise.race([
586
- savePromise.then(() => '__save_ok__', (err) => { throw err; }),
587
- saveTimeout,
588
- ]);
589
- if (outcome === '__save_timeout__') {
590
- try { process.stderr.write(`[session] terminal save exceeded ${TERMINAL_SAVE_TIMEOUT_MS}ms; continuing best-effort (${sessionId})\n`); } catch {}
591
- // Don't drop the write — let it settle in the background.
592
- savePromise.catch((err) => {
593
- try { process.stderr.write(`[session] deferred terminal save failed: ${err?.message || err}\n`); } catch {}
594
- });
595
- }
596
- } finally {
597
- if (saveTimer) { try { clearTimeout(saveTimer); } catch {} }
598
- }
599
- }
562
+ // Fire-and-forget terminal save. The result is already produced and
563
+ // (for agent surfaces) relayed via onTerminalResult above, and
564
+ // saveSessionAsync() has already published the in-memory snapshot via
565
+ // setLiveSession(), so read-your-writes holds in-process without
566
+ // awaiting disk. Never block the terminal unwind on the write that
567
+ // would strand the owning background task in `running` and suppress
568
+ // its completion notification. A slow write finishes in the
569
+ // background.
570
+ saveSessionAsync(session, { expectedGeneration: askGeneration }).catch((err) => {
571
+ try { process.stderr.write(`[session] terminal save failed: ${err?.message || err}\n`); } catch {}
572
+ });
600
573
  activeSession = session;
601
574
  runtime.session = session;
602
575
  // Tag empty-synthesis BEFORE markSessionDone so the watchdog
@@ -674,11 +647,13 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
674
647
  const _mergedTail = _mergePendingMessageEntries(_drained);
675
648
  if (_mergedTail?.content) {
676
649
  _pendingTail.push(_mergedTail.content);
677
- const refreshed = loadSession(sessionId);
678
- if (refreshed && refreshed.closed !== true) {
679
- activeSession = refreshed;
680
- runtime.session = refreshed;
681
- }
650
+ // Carry the just-committed in-memory session into the follow-up
651
+ // turn so the queued tail sees the preceding assistant/tool
652
+ // context. loadSession() would return this same live snapshot
653
+ // (setLiveSession published it), so skip the disk round-trip.
654
+ // NOTE: `session` (try-block const, :179) is out of scope here —
655
+ // `activeSession` already holds the committed session.
656
+ runtime.session = activeSession;
682
657
  continue;
683
658
  }
684
659
  }
@@ -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
@@ -256,7 +256,16 @@ function _flushScheduled(id) {
256
256
  // Single long-lived Worker serializes all saveSessionAsync calls.
257
257
  // The worker's message queue preserves generation-race ordering.
258
258
  let _saveWorker = null;
259
- let _saveWorkerPending = new Map(); // reqId { resolve, reject, session, opts }
259
+ // In-flight writes, keyed by reqId. Value: { id, session, opts, waiters:[{resolve,reject}] }.
260
+ // At most ONE entry per session id at a time (single-in-flight-per-id).
261
+ let _saveWorkerPending = new Map();
262
+ // Latest-wins queued payload per session, keyed by id. Value: { session, opts, waiters:[] }.
263
+ // At most ONE queued write per id: a newer saveSessionAsync while a write is in
264
+ // flight overwrites session/opts here and appends its resolver to waiters, so
265
+ // every superseded caller resolves when this single queued write finally lands.
266
+ let _saveAsyncQueued = new Map();
267
+ // id → reqId of the in-flight write for that id (enforces one-in-flight-per-id).
268
+ let _saveAsyncInflight = new Map();
260
269
  let _saveWorkerReqId = 0;
261
270
  let _saveWorkerRefCount = 0;
262
271
 
@@ -273,12 +282,37 @@ function _getOrSpawnWorker() {
273
282
  // becomes unref'd again once all in-flight writes settle. _saveWorker
274
283
  // null-check covers the error/exit race where the worker died first.
275
284
  if (--_saveWorkerRefCount === 0 && _saveWorker) _saveWorker.unref();
276
- if (ok) p.resolve();
277
- else p.reject(new Error(`[session-store] worker save failed: ${error}`));
285
+ const { id, waiters } = p;
286
+ _saveAsyncInflight.delete(id);
287
+ // Resolve/reject every caller whose payload this write represents
288
+ // (the originating call plus any that coalesced onto it before it was
289
+ // posted). A supersede never lands here as a rejection — only a real
290
+ // worker failure does.
291
+ if (ok) { for (const w of waiters) w.resolve(); }
292
+ else {
293
+ const e = new Error(`[session-store] worker save failed: ${error}`);
294
+ for (const w of waiters) w.reject(e);
295
+ }
296
+ // Promote the latest-wins queued payload (if any) into the now-free
297
+ // in-flight slot for this id. Runs regardless of ok: the queued write
298
+ // is a newer, independent payload and must still be attempted so its
299
+ // (possibly superseded) waiters resolve when it lands.
300
+ const q = _saveAsyncQueued.get(id);
301
+ if (q) {
302
+ _saveAsyncQueued.delete(id);
303
+ try {
304
+ _postAsyncWrite(id, q.session, q.opts, q.waiters);
305
+ } catch (err) {
306
+ for (const w of q.waiters) w.reject(err);
307
+ }
308
+ }
278
309
  });
279
310
  _saveWorker.on('error', (err) => {
280
- for (const [, p] of _saveWorkerPending) p.reject(err);
311
+ for (const [, p] of _saveWorkerPending) for (const w of p.waiters) w.reject(err);
281
312
  _saveWorkerPending.clear();
313
+ for (const [, q] of _saveAsyncQueued) for (const w of q.waiters) w.reject(err);
314
+ _saveAsyncQueued.clear();
315
+ _saveAsyncInflight.clear();
282
316
  _saveWorkerRefCount = 0;
283
317
  _saveWorker = null;
284
318
  });
@@ -290,8 +324,11 @@ function _getOrSpawnWorker() {
290
324
  // saveSessionAsync registered a resolver but before the worker
291
325
  // received the message.
292
326
  const err = new Error(`[session-store] save worker exited with code ${code}`);
293
- for (const [, p] of _saveWorkerPending) p.reject(err);
327
+ for (const [, p] of _saveWorkerPending) for (const w of p.waiters) w.reject(err);
294
328
  _saveWorkerPending.clear();
329
+ for (const [, q] of _saveAsyncQueued) for (const w of q.waiters) w.reject(err);
330
+ _saveAsyncQueued.clear();
331
+ _saveAsyncInflight.clear();
295
332
  _saveWorkerRefCount = 0;
296
333
  _saveWorker = null;
297
334
  });
@@ -299,14 +336,45 @@ function _getOrSpawnWorker() {
299
336
  return _saveWorker;
300
337
  }
301
338
 
339
+ /**
340
+ * Post one in-flight write for `id` to the worker and register it as the
341
+ * single in-flight entry for that id. Callers guarantee no write is already
342
+ * in flight for `id`. Throws (after cleaning its own map entries) if the
343
+ * worker postMessage fails so the caller can reject the affected waiters.
344
+ */
345
+ function _postAsyncWrite(id, session, opts, waiters) {
346
+ const reqId = ++_saveWorkerReqId;
347
+ _saveWorkerPending.set(reqId, { id, session, opts, waiters });
348
+ _saveAsyncInflight.set(id, reqId);
349
+ try {
350
+ const w = _getOrSpawnWorker();
351
+ w.postMessage({ session, opts, reqId });
352
+ // Ref AFTER successful postMessage so a queue/throw failure path does
353
+ // not leave the worker held alive with no pending message. Paired with
354
+ // the unref in the message handler when count hits 0.
355
+ if (++_saveWorkerRefCount === 1) w.ref();
356
+ } catch (err) {
357
+ _saveWorkerPending.delete(reqId);
358
+ _saveAsyncInflight.delete(id);
359
+ throw err;
360
+ }
361
+ }
362
+
302
363
  /**
303
364
  * Async save via a dedicated Worker thread.
304
365
  * Errors surface as thrown Errors — callers must not silently swallow them.
366
+ *
367
+ * Per-session latest-wins coalescing: for a given id there is at most one
368
+ * write in flight plus one queued follow-up. N rapid saves for the same id in
369
+ * a turn collapse to (in-flight + one queued-latest), keeping the single
370
+ * worker's backlog bounded. Per-id write ORDERING is preserved (a queued write
371
+ * is only posted once the prior in-flight write for that id settles); different
372
+ * ids interleave freely as before.
305
373
  */
306
374
  export function saveSessionAsync(session, opts) {
307
375
  _ensureLifecycleFields(session);
308
376
  setLiveSession(session);
309
- const reqId = ++_saveWorkerReqId;
377
+ const id = session.id;
310
378
  const safeOpts = opts || null;
311
379
  // The Worker `postMessage` below structured-clones the whole session on the
312
380
  // main thread. `session.liveTurnMessages` (live working transcript) and
@@ -322,18 +390,28 @@ export function saveSessionAsync(session, opts) {
322
390
  ? (() => { const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session; return rest; })()
323
391
  : session;
324
392
  return new Promise((resolve, reject) => {
325
- // Persist {session, opts} so drainSessionStore can sync-flush
326
- // outstanding writes if process exit interrupts the worker queue.
327
- _saveWorkerPending.set(reqId, { resolve, reject, session: clonePayload, opts: safeOpts });
393
+ const waiter = { resolve, reject };
394
+ if (_saveAsyncInflight.has(id)) {
395
+ // A write is already on disk for this id — coalesce into the single
396
+ // latest-wins queued slot. Existing queued waiters carry over so a
397
+ // superseded caller resolves when THIS newer write lands (never
398
+ // hang, never reject on supersede).
399
+ const q = _saveAsyncQueued.get(id);
400
+ if (q) {
401
+ q.session = clonePayload;
402
+ q.opts = safeOpts;
403
+ q.waiters.push(waiter);
404
+ } else {
405
+ _saveAsyncQueued.set(id, { session: clonePayload, opts: safeOpts, waiters: [waiter] });
406
+ }
407
+ return;
408
+ }
409
+ // Idle for this id — post immediately as the in-flight write. The
410
+ // in-flight entry persists {session, opts} so drainSessionStore can
411
+ // sync-flush outstanding writes if process exit interrupts the queue.
328
412
  try {
329
- const w = _getOrSpawnWorker();
330
- w.postMessage({ session: clonePayload, opts: safeOpts, reqId });
331
- // Ref AFTER successful postMessage so a queue/throw failure path
332
- // does not leave the worker held alive with no pending message.
333
- // Paired with the unref in the message handler when count hits 0.
334
- if (++_saveWorkerRefCount === 1) w.ref();
413
+ _postAsyncWrite(id, clonePayload, safeOpts, [waiter]);
335
414
  } catch (err) {
336
- _saveWorkerPending.delete(reqId);
337
415
  reject(err);
338
416
  }
339
417
  });
@@ -431,18 +509,35 @@ export function drainSessionStore() {
431
509
  // is sync-flushed directly here. The Promise is then rejected so the
432
510
  // caller's await site does not leak unresolved (caller is at process
433
511
  // exit so the rejection is informational, not actionable).
512
+ const _drainErr = new Error('[session-store] drain: worker-queue interrupted by process exit');
513
+ // Flush in-flight writes FIRST, then the latest-wins queued payloads, so
514
+ // for any id with both an in-flight and a queued write the queued (newest)
515
+ // state is written LAST and wins on disk — no lost last write.
434
516
  for (const [, pending] of _saveWorkerPending) {
435
- if (!pending.session) continue;
517
+ if (pending.session) {
518
+ try {
519
+ _saveSessionSync(pending.session, pending.opts);
520
+ } catch (err) {
521
+ process.stderr.write(`[session-store] drain worker-queue save failed: ${err?.message}\n`);
522
+ }
523
+ }
524
+ for (const w of pending.waiters) {
525
+ try { w.reject(_drainErr); } catch { /* best-effort */ }
526
+ }
527
+ }
528
+ for (const [, q] of _saveAsyncQueued) {
436
529
  try {
437
- _saveSessionSync(pending.session, pending.opts);
530
+ _saveSessionSync(q.session, q.opts);
438
531
  } catch (err) {
439
532
  process.stderr.write(`[session-store] drain worker-queue save failed: ${err?.message}\n`);
440
533
  }
441
- try {
442
- pending.reject(new Error('[session-store] drain: worker-queue interrupted by process exit'));
443
- } catch { /* best-effort */ }
534
+ for (const w of q.waiters) {
535
+ try { w.reject(_drainErr); } catch { /* best-effort */ }
536
+ }
444
537
  }
445
538
  _saveWorkerPending.clear();
539
+ _saveAsyncQueued.clear();
540
+ _saveAsyncInflight.clear();
446
541
  _saveWorkerRefCount = 0;
447
542
  }
448
543
 
@@ -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) {
@@ -191,6 +191,43 @@ export const PROVIDER_WS_ACQUIRE_TIMEOUT_MS = resolveTimeoutMs(
191
191
  { minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
192
192
  );
193
193
 
194
+ // WS pool liveness (ping/pong) — closes the gap between a socket going
195
+ // half-open (peer gone / TLS wedge) and the semantic-idle watchdog noticing
196
+ // (120s). Node `ws` send() is fire-and-forget, so a dead pooled socket
197
+ // silently blackholes response.create frames until a downstream timeout. An
198
+ // idle pooled socket is pinged on this cadence; a reused socket that has been
199
+ // quiet longer than the stale window is ping-probed before hand-out.
200
+ // Gated OFF by default for codex/opencode parity — neither pings model
201
+ // sockets. Enable with MIXDOG_PROVIDER_WS_PING_ENABLED=1. When disabled, no
202
+ // liveness interval is armed and the acquire-reuse ping probe is skipped (the
203
+ // non-OPEN eviction scan still runs); lastAliveAt stamping is harmless.
204
+ const _wsPingRaw = process.env.MIXDOG_PROVIDER_WS_PING_ENABLED;
205
+ export const PROVIDER_WS_PING_ENABLED = _wsPingRaw === '1' || _wsPingRaw === 'true' || _wsPingRaw === 'yes';
206
+
207
+ export const PROVIDER_WS_PING_INTERVAL_MS = resolveTimeoutMs(
208
+ 'MIXDOG_PROVIDER_WS_PING_INTERVAL_MS',
209
+ 30_000,
210
+ { minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
211
+ );
212
+
213
+ // Missed-pong grace: after a ping, the pong must land within this bound or the
214
+ // socket is treated as dead (closed + evicted). Doubles as the short probe
215
+ // bound on the acquire-reuse path so a busy caller is never handed a wedged
216
+ // socket.
217
+ export const PROVIDER_WS_PONG_TIMEOUT_MS = resolveTimeoutMs(
218
+ 'MIXDOG_PROVIDER_WS_PONG_TIMEOUT_MS',
219
+ 5_000,
220
+ { minMs: 1_000, maxMs: 60_000 },
221
+ );
222
+
223
+ // Activity freshness window: a pooled socket with observed activity (pong /
224
+ // release) newer than this is assumed live and skips the acquire-reuse probe.
225
+ export const PROVIDER_WS_LIVENESS_STALE_MS = resolveTimeoutMs(
226
+ 'MIXDOG_PROVIDER_WS_LIVENESS_STALE_MS',
227
+ 30_000,
228
+ { minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
229
+ );
230
+
194
231
  // Single inter-chunk idle timer (300s), matching the upstream WS provider's
195
232
  // default stream idle timeout. Mixdog resets one idle timer on every received
196
233
  // WS frame (openai-oauth-ws messageHandler resets on every parsed event). There
@@ -219,6 +256,24 @@ export const PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS = resolveTimeoutMs(
219
256
  { minMs: 10_000, maxMs: STALL_WARN_MS },
220
257
  );
221
258
 
259
+ // WS-specific semantic idle window. Do NOT reuse
260
+ // PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS here: that shared SSE default was raised to
261
+ // ~285s to avoid false-aborting Anthropic effort-mode streams that legitimately
262
+ // produce no deltas while thinking. The WS transport is different: a silent
263
+ // OpenAI/xAI websocket turn can sit with only connection/metadata activity while
264
+ // the TUI lead-turn watchdog is also 300s. If the provider waits ~285-300s
265
+ // before surfacing the stall, the UI watchdog often wins and the user only sees
266
+ // "Turn timed out after 300s" instead of a provider retry/fallback.
267
+ //
268
+ // Keep WS hangs bounded well below the TUI watchdog while still allowing normal
269
+ // long reasoning streams that emit reasoning/text/tool deltas (those reset this
270
+ // timer). Env-overridable for experiments.
271
+ export const PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS = resolveTimeoutMs(
272
+ ['MIXDOG_PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS', 'MIXDOG_PROVIDER_WS_OUTPUT_IDLE_TIMEOUT_MS'],
273
+ 120_000,
274
+ { minMs: 10_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
275
+ );
276
+
222
277
  // First retry has a small floor (250ms) instead of 0ms: an immediate reissue on
223
278
  // a transient 5xx burst lets parallel workers thundering-herd the backend in
224
279
  // lockstep (jitter alone can't decluster a 0ms base). Subsequent steps keep the