mixdog 0.9.151 → 0.9.152

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 (175) hide show
  1. package/README.md +110 -68
  2. package/package.json +3 -2
  3. package/scripts/tool-stress.mjs +0 -2
  4. package/src/defaults/skills/setup/SKILL.md +18 -27
  5. package/src/headless-exec.mjs +3 -10
  6. package/src/headless-exec.test.mjs +2 -7
  7. package/src/runtime/agent/orchestrator/config.mjs +6 -66
  8. package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
  9. package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
  11. package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
  12. package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
  13. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
  14. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  16. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
  17. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +10 -2
  20. package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
  21. package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
  25. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
  26. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -0
  28. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -2
  29. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
  30. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +4 -7
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +9 -0
  32. package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +235 -0
  33. package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
  34. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
  35. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +14 -4
  37. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
  38. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +24 -1
  39. package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +161 -0
  40. package/src/runtime/agent/orchestrator/session/context-utils.mjs +14 -4
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -1
  43. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +31 -18
  44. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -0
  45. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +6 -1
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -3
  48. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +556 -0
  49. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +457 -0
  50. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +105 -186
  51. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
  52. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +2 -0
  53. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -0
  54. package/src/runtime/agent/orchestrator/session/store.mjs +23 -1
  55. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
  57. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
  58. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
  59. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
  60. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
  62. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
  63. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
  64. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
  69. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
  71. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
  73. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
  74. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  75. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  76. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +8 -0
  77. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  78. package/src/runtime/browser-bridge/client.mjs +103 -0
  79. package/src/runtime/browser-bridge/tool-defs.mjs +42 -0
  80. package/src/runtime/computer-bridge/client.mjs +101 -0
  81. package/src/runtime/computer-bridge/tool-defs.mjs +53 -0
  82. package/src/runtime/media/store.mjs +18 -8
  83. package/src/runtime/media/store.test.mjs +14 -0
  84. package/src/runtime/memory/index.mjs +1 -1
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
  86. package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
  87. package/src/runtime/shared/child-spawn-gate.mjs +17 -0
  88. package/src/runtime/shared/child-spawn-remote.mjs +69 -7
  89. package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
  90. package/src/runtime/shared/pristine-execution-contract.json +2 -1
  91. package/src/runtime/shared/provider-api-key.mjs +1 -0
  92. package/src/runtime/shared/session-runtime-health.mjs +91 -0
  93. package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
  94. package/src/runtime/shared/skill-document.mjs +86 -0
  95. package/src/runtime/shared/skill-document.test.mjs +81 -0
  96. package/src/runtime/shared/tool-surface.mjs +18 -0
  97. package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
  98. package/src/runtime/shared/turn-snapshot.mjs +32 -9
  99. package/src/runtime/shared/user-cwd.mjs +33 -14
  100. package/src/runtime/shared/user-cwd.test.mjs +43 -0
  101. package/src/session-runtime/cwd-plugins.mjs +26 -26
  102. package/src/session-runtime/cwd-plugins.test.mjs +75 -0
  103. package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
  104. package/src/session-runtime/global-extensions.mjs +26 -0
  105. package/src/session-runtime/global-extensions.test.mjs +28 -0
  106. package/src/session-runtime/goal-runtime.mjs +721 -0
  107. package/src/session-runtime/goal-runtime.test.mjs +108 -0
  108. package/src/session-runtime/lifecycle-api.mjs +18 -4
  109. package/src/session-runtime/lifecycle-api.test.mjs +21 -0
  110. package/src/session-runtime/mcp-glue.mjs +49 -47
  111. package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
  112. package/src/session-runtime/plugin-mcp.mjs +96 -22
  113. package/src/session-runtime/prewarm.mjs +1 -10
  114. package/src/session-runtime/resource-api-global.test.mjs +69 -0
  115. package/src/session-runtime/resource-api.mjs +173 -58
  116. package/src/session-runtime/runtime-core.mjs +148 -20
  117. package/src/session-runtime/runtime-tunables.mjs +6 -8
  118. package/src/session-runtime/session-turn-api.mjs +22 -11
  119. package/src/session-runtime/skills-api.mjs +79 -25
  120. package/src/session-runtime/skills-api.test.mjs +99 -0
  121. package/src/session-runtime/tool-catalog-schema.mjs +0 -5
  122. package/src/session-runtime/tool-catalog.mjs +4 -13
  123. package/src/session-runtime/tool-policy-surface.test.mjs +33 -11
  124. package/src/session-runtime/tool-surface.mjs +12 -0
  125. package/src/standalone/agent-dispatch-broker.mjs +5 -9
  126. package/src/standalone/agent-tool/notify.mjs +6 -8
  127. package/src/standalone/agent-tool/notify.test.mjs +37 -0
  128. package/src/standalone/daemon.mjs +31 -15
  129. package/src/standalone/hook-bus/config.mjs +1 -0
  130. package/src/standalone/plugin-admin.mjs +17 -0
  131. package/src/standalone/plugin-admin.test.mjs +7 -0
  132. package/src/standalone/provider-admin.mjs +1 -0
  133. package/src/standalone/session-protocol.mjs +5 -0
  134. package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
  135. package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
  136. package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
  137. package/src/standalone/session-runtime-host-factory.mjs +17 -0
  138. package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
  139. package/src/standalone/session-runtime-host-health.test.mjs +5 -0
  140. package/src/standalone/session-runtime-host.mjs +696 -58
  141. package/src/standalone/session-runtime-inline-host.mjs +351 -0
  142. package/src/standalone/session-runtime-inline-host.test.mjs +83 -0
  143. package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
  144. package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
  145. package/src/standalone/session-runtime-shard-host.test.mjs +569 -0
  146. package/src/standalone/session-runtime-shard-router.mjs +149 -0
  147. package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
  148. package/src/standalone/session-runtime-worker.mjs +476 -18
  149. package/src/standalone/session-service.mjs +81 -6
  150. package/src/tui/app/app-view.jsx +1 -0
  151. package/src/tui/app/core-memory-picker.mjs +2 -0
  152. package/src/tui/app/extension-pickers.mjs +1 -1
  153. package/src/tui/app/maintenance-pickers.mjs +3 -3
  154. package/src/tui/app/model-options.mjs +2 -0
  155. package/src/tui/app/model-picker.mjs +14 -4
  156. package/src/tui/app/panel-handoff.test.mjs +311 -0
  157. package/src/tui/app/project-picker.mjs +1 -0
  158. package/src/tui/app/route-pickers.mjs +21 -6
  159. package/src/tui/app/settings-picker.mjs +26 -3
  160. package/src/tui/app/slash-commands.mjs +1 -0
  161. package/src/tui/app/slash-dispatch.mjs +73 -22
  162. package/src/tui/app/theme-effort-pickers.mjs +7 -2
  163. package/src/tui/components/Picker.jsx +10 -1
  164. package/src/tui/dist/index.mjs +163 -44
  165. package/src/tui/session/context-state.mjs +1 -0
  166. package/src/tui/session/goal-continuation.mjs +110 -0
  167. package/src/tui/session/goal-continuation.test.mjs +84 -0
  168. package/src/tui/session/queue-helpers.mjs +5 -2
  169. package/src/tui/session/session-action-surface.test.mjs +108 -0
  170. package/src/tui/session/session-api-ext.mjs +39 -8
  171. package/src/tui/session/session-api.mjs +58 -0
  172. package/src/tui/session/session-flow.mjs +6 -0
  173. package/src/tui/session/turn.mjs +1 -0
  174. package/src/tui/session-local.mjs +15 -1
  175. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
@@ -23,8 +23,14 @@ import {
23
23
  NATIVE_SERVER_TOOL_CALL_BLOCK_TYPES,
24
24
  NATIVE_SERVER_TOOL_RESULT_BLOCK_TYPES,
25
25
  } from './lib/anthropic-native-blocks.mjs';
26
- import { parseProviderJsonBatch } from './stream-json-pool.mjs';
26
+ import {
27
+ frameProviderSseChunk,
28
+ releaseProviderSseStream,
29
+ retainProviderSseStream,
30
+ } from './stream-json-pool.mjs';
31
+ import { splitSseRegion } from './lib/sse-framing.mjs';
27
32
  import { stampStreamOutcome, STREAM_TRANSPORTS, STREAM_OUTCOME_VERSION } from './lib/stream-outcome.mjs';
33
+ import { createProviderReplay } from './lib/provider-replay.mjs';
28
34
  import {
29
35
  anthropicFallbackProviderMetadata,
30
36
  parseAnthropicFallbackBlock,
@@ -46,6 +52,11 @@ const SSE_MIDSTREAM_POLICY = {
46
52
 
47
53
  // --- SSE parser ---
48
54
 
55
+ // Per-stream identity for the shared framing pool: every chunk of one stream
56
+ // settles in submission order (and prefers one worker) regardless of whether
57
+ // it was framed inline or offloaded.
58
+ let _sseStreamSequence = 0;
59
+
49
60
  function _captureMidstreamAbort(state, reason) {
50
61
  if (!state) return;
51
62
  const reasonName = reason?.name || '';
@@ -142,6 +153,7 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
142
153
  // do not mistake transport heartbeats for model output.
143
154
  const reader = response.body.getReader();
144
155
  const decoder = new TextDecoder();
156
+ const streamKey = `anthropic-sse#${++_sseStreamSequence}`;
145
157
  // Transport-idle window. The legacy semanticIdleTimeoutMs seam remains as
146
158
  // a fallback for existing tests/callers, but production uses the shared
147
159
  // byte/event inactivity policy.
@@ -199,15 +211,20 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
199
211
  .filter(([index]) => !pendingNativeToolInputs.has(index));
200
212
  const _orderedAssistantBlocks = () => {
201
213
  const nativeBlocks = _completedNativeBlocks();
202
- if (!nativeBlocks.length) return undefined;
203
- return [
214
+ const entries = [
204
215
  ...[...thinkingBlocks.entries()],
205
216
  ...[...textBlocks.entries()]
206
217
  .filter(([, text]) => typeof text === 'string' && text.length > 0)
207
218
  .map(([index, text]) => [index, { type: 'text', text }]),
208
219
  ...nativeBlocks,
209
220
  ...[...clientToolUseBlocks.entries()],
210
- ]
221
+ ];
222
+ if (!entries.length) return undefined;
223
+ // A leaked plain-text tool call has no provider content_block index.
224
+ // Falling back to the legacy flattened projection is safer than
225
+ // claiming an exact replay while silently omitting that synthetic call.
226
+ if (toolCalls.length > 0 && clientToolUseBlocks.size !== toolCalls.length) return undefined;
227
+ return entries
211
228
  .sort((a, b) => a[0] - b[0])
212
229
  .map(([, block]) => block);
213
230
  };
@@ -430,6 +447,7 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
430
447
  if (_c && typeof _c.catch === 'function') _c.catch(() => {});
431
448
  } catch {}
432
449
  };
450
+ retainProviderSseStream(streamKey);
433
451
  try {
434
452
  // Reader ownership begins at getReader() above, so even a signal that
435
453
  // was already aborted must pass through this try/finally cleanup path.
@@ -478,36 +496,35 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
478
496
  if (state?.sawMessageStart) resetIdleTimer();
479
497
  try { onStreamDelta?.('transport'); } catch {}
480
498
  buffer += decoder.decode(value, { stream: true });
481
- const lines = buffer.split('\n');
482
- buffer = lines.pop() || '';
483
-
484
- for (const line of lines) {
485
- if (line.startsWith(':')) {
486
- // SSE comment frame (Anthropic `:ping` keepalive). The raw
487
- // chunk already refreshed transport activity above; it is
488
- // intentionally not counted as visible model progress.
489
- continue;
490
- }
491
- // Blank lines are SSE record separators — emitted after EVERY
492
- // frame, including `:ping` keepalives — so they are NOT semantic
493
- // progress and must not reset the idle timer (else a ping frame's
494
- // trailing blank would keep a wedge alive forever).
495
- if (line === '') continue;
496
- if (line.startsWith('event: ')) {
497
- currentEvent = line.slice(7).trim();
498
- continue;
499
- }
500
- if (!line.startsWith('data: ')) continue;
501
- const data = line.slice(6).trim();
502
- if (!data) continue;
499
+ // ONE unit of work per network chunk. The previous loop split the
500
+ // chunk into a line array and awaited a JSON parse per SSE event,
501
+ // so a single readable chunk cost O(events) allocations and
502
+ // O(events) microtask hops on the shared daemon event loop. The
503
+ // pool now frames + parses the whole chunk (in a worker once it is
504
+ // worth the round-trip, otherwise inline and synchronously).
505
+ // Framing rules — `:` keepalive comments, blank record separators,
506
+ // sticky `event:` names, trimmed `data:` payloads — are unchanged;
507
+ // `region` holds only complete records and the trailing partial
508
+ // line stays in `buffer`, exactly as split()/pop() left it.
509
+ const { region, rest } = splitSseRegion(buffer);
510
+ buffer = rest;
511
+ if (!region) continue;
512
+ // A completed reader.read() owns these frames even if abort races
513
+ // immediately afterward, so framing is deliberately NOT signal
514
+ // canceled: the next read observes the cancellation after the
515
+ // frames of this chunk are relayed.
516
+ const framed = frameProviderSseChunk(region, { currentEvent, streamKey });
517
+ const framedChunk = typeof framed?.then === 'function' ? await framed : framed;
518
+ currentEvent = framedChunk.currentEvent;
503
519
 
520
+ for (const framedEvent of framedChunk.events) {
521
+ // A malformed record is skipped exactly like the former
522
+ // per-event JSON.parse throw — and, unlike a whole-batch
523
+ // rejection, the well-formed records beside it still run.
524
+ if (framedEvent.error) continue;
525
+ const event = framedEvent.value;
504
526
  try {
505
- // A completed reader.read() owns this frame even if abort
506
- // races immediately afterward; the next read observes the
507
- // cancellation after this frame is relayed.
508
- const [event] = await parseProviderJsonBatch([data]);
509
-
510
- if (currentEvent === 'error' || event?.type === 'error' || event?.error) {
527
+ if (framedEvent.name === 'error' || event?.type === 'error' || event?.error) {
511
528
  throw _anthropicSseError(event);
512
529
  }
513
530
 
@@ -897,6 +914,7 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
897
914
  err.partialHasThinking = hasThinkingContent;
898
915
  err.partialThinkingBlocks = _orderedThinkingBlocks();
899
916
  err.partialAssistantBlocks = _orderedAssistantBlocks();
917
+ err.partialProviderReplay = createProviderReplay('anthropic', err.partialAssistantBlocks);
900
918
  err.partialStopReason = stopReason || undefined;
901
919
  } catch { /* best-effort enrichment */ }
902
920
  // Truncation is a continuation, never a terminal turn. A pending
@@ -921,6 +939,7 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
921
939
  throw err;
922
940
  }
923
941
 
942
+ const assistantBlocks = _orderedAssistantBlocks();
924
943
  return {
925
944
  content,
926
945
  model,
@@ -941,12 +960,14 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
941
960
  // replays this list verbatim (see toAnthropicMessages'
942
961
  // assistantBlocks branch). Absent for every ordinary turn, which
943
962
  // keeps the existing text/thinking/tool_use lowering untouched.
944
- assistantBlocks: _orderedAssistantBlocks(),
963
+ assistantBlocks,
964
+ providerReplay: createProviderReplay('anthropic', assistantBlocks),
945
965
  providerMetadata: anthropicFallbackProviderMetadata(fallbackEvents),
946
966
  };
947
967
  } finally {
948
968
  if (idleTimer) clearTimeout(idleTimer);
949
969
  clearFirstMessageTimer();
970
+ try { releaseProviderSseStream(streamKey); } catch {}
950
971
  if (signal) signal.removeEventListener('abort', onAbort);
951
972
  // message_stop deliberately exits before EOF because Anthropic may keep
952
973
  // sending pings. Cancel the reader so the successful response body and
@@ -318,6 +318,7 @@ export class AnthropicProvider {
318
318
  assistantBlocks: Array.isArray(parseResult.assistantBlocks) && parseResult.assistantBlocks.length
319
319
  ? parseResult.assistantBlocks
320
320
  : undefined,
321
+ ...(parseResult.providerReplay ? { providerReplay: parseResult.providerReplay } : {}),
321
322
  ...(parseResult.providerMetadata && typeof parseResult.providerMetadata === 'object'
322
323
  ? { providerMetadata: parseResult.providerMetadata }
323
324
  : {}),
@@ -14,6 +14,7 @@
14
14
  import { randomUUID, createHash } from 'crypto';
15
15
  import { withRetry } from './retry-classifier.mjs';
16
16
  import { traceAgentUsage } from '../agent-trace.mjs';
17
+ import { createProviderReplay } from './lib/provider-replay.mjs';
17
18
  import {
18
19
  createPassthroughSignal,
19
20
  createTimeoutSignal,
@@ -353,6 +354,10 @@ export class AntigravityOAuthProvider {
353
354
  ? textLeakGuard.scrubAssistantText(rawContent)
354
355
  : rawContent;
355
356
  const leakedToolCalls = textLeakGuard?.getLeakedToolCalls() ?? [];
357
+ const providerReplay = createProviderReplay(
358
+ 'antigravity',
359
+ leakedToolCalls.length ? [] : responseParts,
360
+ );
356
361
  let nativeToolCalls = parseToolCalls(responseParts);
357
362
  if (textLeakGuard?.enabled) nativeToolCalls = textLeakGuard.filterNativeToolCalls(nativeToolCalls);
358
363
  let toolCalls = nativeToolCalls;
@@ -373,6 +378,7 @@ export class AntigravityOAuthProvider {
373
378
  finishReason,
374
379
  partialContent: content,
375
380
  partialToolCalls: toolCalls,
381
+ partialProviderReplay: providerReplay,
376
382
  providerMetadata,
377
383
  model: useModel,
378
384
  rawUsage: response.usageMetadata || null,
@@ -407,6 +413,7 @@ export class AntigravityOAuthProvider {
407
413
  model: useModel,
408
414
  toolCalls,
409
415
  citations: citations.length ? citations : undefined,
416
+ providerReplay,
410
417
  providerMetadata,
411
418
  providerState: opts.providerState,
412
419
  usage,
@@ -11,6 +11,7 @@
11
11
  import { SchemaType } from '@google/generative-ai';
12
12
  import { traceHash, stableTraceStringify } from './trace-utils.mjs';
13
13
  import { normalizeContentForGeminiParts, splitToolContentForGemini } from './media-normalization.mjs';
14
+ import { providerReplayItems } from './lib/provider-replay.mjs';
14
15
 
15
16
  function explicitGeminiMediaPart(part) {
16
17
  if (!part || typeof part !== 'object') return null;
@@ -617,6 +618,12 @@ function toGeminiToolContent(message, toolNameByCallId, capabilities) {
617
618
 
618
619
  function toGeminiContent(message, toolNameByCallId, capabilities) {
619
620
  if (!message || message.role === 'system') return null;
621
+ const orderedReplay = message.role === 'assistant'
622
+ ? providerReplayItems(message, ['gemini', 'antigravity'])
623
+ : undefined;
624
+ if (orderedReplay?.length) {
625
+ return { role: 'model', parts: orderedReplay };
626
+ }
620
627
  if (message.role === 'assistant' && message.toolCalls?.length) {
621
628
  const parts = geminiThoughtPartsFromMetadata(message);
622
629
  if (message.content) {
@@ -198,14 +198,9 @@ export function aggregateGeminiStreamChunks(responses) {
198
198
  };
199
199
  }
200
200
  for (const part of candidate.content.parts) {
201
- const newPart = {};
202
- if (part.text) newPart.text = part.text;
203
- if (part.thought === true) newPart.thought = true;
204
- if (part.functionCall) newPart.functionCall = part.functionCall;
205
- if (part.thoughtSignature) newPart.thoughtSignature = part.thoughtSignature;
206
- if (part.thought_signature) newPart.thought_signature = part.thought_signature;
207
- if (part.executableCode) newPart.executableCode = part.executableCode;
208
- if (part.codeExecutionResult) newPart.codeExecutionResult = part.codeExecutionResult;
201
+ let newPart;
202
+ try { newPart = structuredClone(part); }
203
+ catch { newPart = { ...part }; }
209
204
  if (Object.keys(newPart).length === 0) newPart.text = '';
210
205
  aggCand.content.parts.push(newPart);
211
206
  }
@@ -2,6 +2,7 @@ import { GoogleGenerativeAI } from '@google/generative-ai';
2
2
  import { getAgentApiKey } from '../../../shared/provider-api-key.mjs';
3
3
  import { canFallbackNonStreaming, withRetry } from './retry-classifier.mjs';
4
4
  import { traceAgentUsage, appendAgentTrace } from '../agent-trace.mjs';
5
+ import { createProviderReplay } from './lib/provider-replay.mjs';
5
6
  import {
6
7
  PROVIDER_FIRST_BYTE_TIMEOUT_MS,
7
8
  PROVIDER_MAX_BEFORE_WARN_MS,
@@ -907,6 +908,10 @@ export class GeminiProvider {
907
908
  ? textLeakGuard.scrubAssistantText(rawContent)
908
909
  : rawContent;
909
910
  const leakedToolCalls = textLeakGuard?.getLeakedToolCalls() ?? [];
911
+ const providerReplay = createProviderReplay(
912
+ 'gemini',
913
+ leakedToolCalls.length ? [] : responseParts,
914
+ );
910
915
  let nativeToolCalls = parseToolCalls(candidate?.content?.parts ?? []);
911
916
  if (textLeakGuard?.enabled) {
912
917
  nativeToolCalls = textLeakGuard.filterNativeToolCalls(nativeToolCalls);
@@ -943,6 +948,7 @@ export class GeminiProvider {
943
948
  finishReason,
944
949
  partialContent: content,
945
950
  partialToolCalls: toolCalls,
951
+ partialProviderReplay: providerReplay,
946
952
  providerMetadata,
947
953
  model: useModel,
948
954
  rawUsage: response.usageMetadata || null,
@@ -1015,6 +1021,7 @@ export class GeminiProvider {
1015
1021
  model: useModel,
1016
1022
  toolCalls,
1017
1023
  citations: citations.length ? citations : undefined,
1024
+ providerReplay,
1018
1025
  providerMetadata,
1019
1026
  providerState: opts.providerState,
1020
1027
  // Use the same normalized usage object traceAgentUsage recorded,
@@ -2,6 +2,7 @@ import { providerNativeToolPrefixCount } from '../../../../../session-runtime/pr
2
2
  import { isNativeServerToolBlockType } from './anthropic-native-blocks.mjs';
3
3
  import { sanitizeAnthropicContentPairs, foldUserTextIntoToolResultTail } from '../../session/context-utils.mjs';
4
4
  import { normalizeContentForAnthropic } from '../media-normalization.mjs';
5
+ import { createProviderReplay, providerReplayItems } from './provider-replay.mjs';
5
6
  import {
6
7
  anthropicFallbackProviderMetadata,
7
8
  parseAnthropicFallbackBlock,
@@ -93,9 +94,14 @@ export function toAnthropicMessages(messages) {
93
94
  for (let idx = 0; idx < messages.length; idx++) {
94
95
  const m = messages[idx];
95
96
  if (m.role === 'system') continue;
96
- if (m.role === 'assistant' && (m.toolCalls?.length || m.assistantBlocks?.length || m.thinkingBlocks?.length)) {
97
+ const orderedReplay = m.role === 'assistant'
98
+ ? providerReplayItems(m, 'anthropic')
99
+ : undefined;
100
+ if (m.role === 'assistant' && (orderedReplay?.length || m.toolCalls?.length || m.assistantBlocks?.length || m.thinkingBlocks?.length)) {
97
101
  let content;
98
- if (m.assistantBlocks?.length) {
102
+ if (orderedReplay?.length) {
103
+ content = orderedReplay;
104
+ } else if (m.assistantBlocks?.length) {
99
105
  content = m.assistantBlocks.slice();
100
106
  } else {
101
107
  content = [];
@@ -211,6 +217,7 @@ export function normalizeAnthropicNonStreamingResponse(message, fallbackModel =
211
217
  || (block.type === 'text' && typeof block.text === 'string' && block.text.length > 0)
212
218
  ))
213
219
  : [];
220
+ const replayableBlocks = blocks.filter((block) => block && typeof block === 'object');
214
221
  const usage = message?.usage || {};
215
222
  const input = Number(usage.input_tokens) || 0;
216
223
  const cacheRead = Number(usage.cache_read_input_tokens) || 0;
@@ -224,6 +231,7 @@ export function normalizeAnthropicNonStreamingResponse(message, fallbackModel =
224
231
  contentBlockTypes: blocks.map((block) => block?.type).filter(Boolean),
225
232
  thinkingBlocks: thinkingBlocks.length ? thinkingBlocks : undefined,
226
233
  assistantBlocks: nativeAssistantBlocks.length ? nativeAssistantBlocks : undefined,
234
+ providerReplay: createProviderReplay('anthropic', replayableBlocks),
227
235
  providerMetadata: anthropicFallbackProviderMetadata(fallbackEvents),
228
236
  usage: {
229
237
  inputTokens: input,
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Provider-owned assistant output that must be replayed in its original item
3
+ * order. Flattened content/tool/reasoning projections remain available to the
4
+ * agent loop, but provider adapters prefer this envelope when rebuilding wire
5
+ * history for the same provider family.
6
+ */
7
+ export const PROVIDER_REPLAY_VERSION = 1;
8
+
9
+ function cloneReplayValue(value) {
10
+ try { return structuredClone(value); }
11
+ catch {
12
+ try { return JSON.parse(JSON.stringify(value)); }
13
+ catch { return value; }
14
+ }
15
+ }
16
+
17
+ export function createProviderReplay(provider, items) {
18
+ const id = typeof provider === 'string' ? provider.trim() : '';
19
+ if (!id || !Array.isArray(items) || items.length === 0) return undefined;
20
+ return {
21
+ version: PROVIDER_REPLAY_VERSION,
22
+ provider: id,
23
+ items: cloneReplayValue(items),
24
+ };
25
+ }
26
+
27
+ export function cloneProviderReplay(replay) {
28
+ if (!replay || typeof replay !== 'object'
29
+ || replay.version !== PROVIDER_REPLAY_VERSION
30
+ || typeof replay.provider !== 'string' || !replay.provider
31
+ || !Array.isArray(replay.items) || replay.items.length === 0) {
32
+ return undefined;
33
+ }
34
+ return createProviderReplay(replay.provider, replay.items);
35
+ }
36
+
37
+ export function providerReplayItems(messageOrReplay, acceptedProviders) {
38
+ const replay = messageOrReplay?.providerReplay ?? messageOrReplay;
39
+ const cloned = cloneProviderReplay(replay);
40
+ if (!cloned) return undefined;
41
+ const accepted = Array.isArray(acceptedProviders)
42
+ ? new Set(acceptedProviders)
43
+ : new Set([acceptedProviders]);
44
+ if (!accepted.has(cloned.provider)) return undefined;
45
+ return cloned.items;
46
+ }
47
+
48
+ export function hasProviderReplay(message) {
49
+ return cloneProviderReplay(message?.providerReplay) !== undefined;
50
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * sse-framing.mjs — shared SSE record framing for provider streams.
3
+ *
4
+ * One network chunk carries many SSE records. Splitting it into an array of
5
+ * lines and then awaiting one JSON parse per record turned a single readable
6
+ * chunk into O(records) allocations plus O(records) microtask hops on the
7
+ * shared daemon event loop. These helpers frame a chunk in a single scan with
8
+ * no per-line array, and they are pure so the exact same code runs inline on
9
+ * the event loop and inside the provider stream worker (a worker failure can
10
+ * therefore always be re-run inline with identical results).
11
+ *
12
+ * Framing rules are byte-identical to the previous inline Anthropic loop:
13
+ * - `:`-prefixed lines are comment/keepalive frames and are dropped.
14
+ * - blank lines are record separators and are dropped.
15
+ * - `event: ` sets the current event name, which persists (across chunks)
16
+ * until the next `event: ` line.
17
+ * - `data: ` payloads are trimmed; empty payloads are dropped.
18
+ * - any other line is ignored.
19
+ * CRLF transports behave as before: the trailing `\r` is removed by trim() on
20
+ * the value lines, and a bare `\r` line matches no prefix and is ignored.
21
+ */
22
+
23
+ const COLON_CODE = 58; // ':'
24
+
25
+ /**
26
+ * Split a decoded buffer into the complete-record region and the residual
27
+ * partial line. Equivalent to `const lines = buffer.split('\n'); buffer = lines.pop()`
28
+ * without materializing the line array.
29
+ */
30
+ export function splitSseRegion(buffer) {
31
+ const cut = buffer.lastIndexOf('\n');
32
+ if (cut < 0) return { region: '', rest: buffer };
33
+ return { region: buffer.slice(0, cut + 1), rest: buffer.slice(cut + 1) };
34
+ }
35
+
36
+ /**
37
+ * Frame a complete-record region into ordered `{ name, data }` records.
38
+ * `currentEvent` carries the last `event:` name into and out of the region so
39
+ * a record split across network chunks keeps its event name.
40
+ */
41
+ export function frameSseRegion(text, currentEvent = '') {
42
+ const frames = [];
43
+ let name = String(currentEvent || '');
44
+ const length = text.length;
45
+ let index = 0;
46
+ while (index < length) {
47
+ let end = text.indexOf('\n', index);
48
+ if (end < 0) end = length;
49
+ const start = index;
50
+ index = end + 1;
51
+ if (end === start) continue; // record separator
52
+ if (text.charCodeAt(start) === COLON_CODE) continue; // comment / ping keepalive
53
+ if (text.startsWith('event: ', start)) {
54
+ name = text.slice(start + 7, end).trim();
55
+ continue;
56
+ }
57
+ if (!text.startsWith('data: ', start)) continue;
58
+ const data = text.slice(start + 6, end).trim();
59
+ if (!data) continue;
60
+ frames.push({ name, data });
61
+ }
62
+ return { frames, currentEvent: name };
63
+ }
64
+
65
+ /**
66
+ * Parse framed records with per-record error isolation. A malformed record
67
+ * must never discard the well-formed records batched beside it: the consumer
68
+ * skips entries that carry `error` exactly like the previous per-event
69
+ * try/catch did.
70
+ */
71
+ export function parseSseFrames(frames) {
72
+ const events = [];
73
+ for (const frame of frames) {
74
+ try {
75
+ events.push({ name: frame.name, value: JSON.parse(frame.data) });
76
+ } catch (error) {
77
+ events.push({
78
+ name: frame.name,
79
+ error: {
80
+ name: String(error?.name || 'SyntaxError'),
81
+ message: String(error?.message || error || 'invalid JSON'),
82
+ },
83
+ });
84
+ }
85
+ }
86
+ return events;
87
+ }
88
+
89
+ /** Frame + parse in one pass (the unit of work posted to the stream worker). */
90
+ export function frameAndParseSse(text, currentEvent = '') {
91
+ const framed = frameSseRegion(text, currentEvent);
92
+ return { events: parseSseFrames(framed.frames), currentEvent: framed.currentEvent };
93
+ }
@@ -25,7 +25,7 @@ const CODING_UNFIT_ID_RE = /-search(-preview)?(\b|-|$)|-(audio|realtime)-preview
25
25
  const HOSTED_PROVIDERS = new Set([
26
26
  'openai', 'openai-oauth', 'openai-ws', 'anthropic', 'anthropic-oauth',
27
27
  'gemini', 'google', 'xai', 'grok-oauth', 'deepseek', 'groq', 'mistral',
28
- 'opencode-go', 'azure',
28
+ 'opencode-go', 'openrouter', 'azure',
29
29
  ]);
30
30
 
31
31
  // (a) Non-chat modality ids. Matched as whole word-ish tokens on the id so we
@@ -166,9 +166,9 @@ function _stalenessFamily(row, id) {
166
166
 
167
167
  function _staleMonths() {
168
168
  const raw = process.env.MIXDOG_MODEL_STALE_MONTHS;
169
- if (raw == null || raw === '') return 12;
169
+ if (raw == null || raw === '') return 9;
170
170
  const n = Number(raw);
171
- return Number.isFinite(n) ? n : 12;
171
+ return Number.isFinite(n) ? n : 9;
172
172
  }
173
173
 
174
174
  // Apply catalog-driven staleness to the already-filtered `kept` rows.
@@ -14,6 +14,14 @@ export const OPENAI_COMPAT_PRESETS = {
14
14
  baseURL: 'https://opencode.ai/zen/go/v1',
15
15
  defaultModel: 'glm-5.2',
16
16
  },
17
+ openrouter: {
18
+ baseURL: 'https://openrouter.ai/api/v1',
19
+ defaultModel: 'openrouter/auto',
20
+ extraHeaders: {
21
+ 'HTTP-Referer': 'https://github.com/tribgames/mixdog',
22
+ 'X-OpenRouter-Title': 'mixdog',
23
+ },
24
+ },
17
25
  ollama: {
18
26
  baseURL: 'http://localhost:11434/v1',
19
27
  defaultModel: 'llama3.3:latest',
@@ -296,6 +296,7 @@ export async function consumeCompatChatCompletionStream(stream, {
296
296
  let sawFirstEvent = false;
297
297
  let content = '';
298
298
  let reasoningContent = '';
299
+ const reasoningDetails = [];
299
300
  // Invariant flag for the gateway live-text relay: set once a non-empty
300
301
  // text chunk has been forwarded to the client. A failure after this point
301
302
  // must be treated as permanent — the rendered text cannot be withdrawn and
@@ -435,6 +436,10 @@ export async function consumeCompatChatCompletionStream(stream, {
435
436
  // use reasoning, and some local compatibility shims expose
436
437
  // thinking. They are aliases, never concatenate multiple aliases
437
438
  // from the same chunk.
439
+ if (Array.isArray(choice?.delta?.reasoning_details) && choice.delta.reasoning_details.length) {
440
+ reasoningDetails.push(...choice.delta.reasoning_details);
441
+ reportProgress('reasoning');
442
+ }
438
443
  const reasoningDelta = typeof choice?.delta?.reasoning_content === 'string'
439
444
  ? choice.delta.reasoning_content
440
445
  : typeof choice?.delta?.reasoning === 'string'
@@ -513,6 +518,7 @@ export async function consumeCompatChatCompletionStream(stream, {
513
518
  const message = {
514
519
  content: content || null,
515
520
  ...(reasoningContent ? { reasoning_content: reasoningContent } : {}),
521
+ ...(reasoningDetails.length ? { reasoning_details: reasoningDetails } : {}),
516
522
  };
517
523
  const rawToolCalls = [...toolAcc.values()]
518
524
  .sort((a, b) => a._order - b._order)
@@ -557,6 +563,7 @@ export async function consumeCompatChatCompletionStream(stream, {
557
563
  toolCalls,
558
564
  stopReason,
559
565
  reasoningContent: reasoningContent || null,
566
+ reasoningDetails: reasoningDetails.length ? reasoningDetails : null,
560
567
  rawUsage,
561
568
  };
562
569
  }
@@ -20,6 +20,7 @@ import {
20
20
  import {
21
21
  customToolCallFromResponseItem,
22
22
  } from './custom-tool-wire.mjs';
23
+ import { providerReplayItems } from './lib/provider-replay.mjs';
23
24
 
24
25
  export function positiveTokenInt(value) {
25
26
  const n = Number(value);
@@ -56,6 +57,12 @@ export function toOpenAIMessages(messages, providerName, options = {}) {
56
57
  || providerName === 'xai';
57
58
  const out = [];
58
59
  const pendingToolMedia = [];
60
+ const openRouterReasoningDetails = (message) => {
61
+ if (providerName !== 'openrouter') return null;
62
+ const metadata = message?.providerMetadata?.openrouter;
63
+ const details = metadata?.reasoning_details ?? metadata?.reasoningDetails;
64
+ return Array.isArray(details) && details.length ? details : null;
65
+ };
59
66
  const flushToolMedia = () => {
60
67
  if (!pendingToolMedia.length) return;
61
68
  out.push({ role: 'user', content: pendingToolMedia.splice(0) });
@@ -83,11 +90,20 @@ export function toOpenAIMessages(messages, providerName, options = {}) {
83
90
  })),
84
91
  };
85
92
  if (replaysReasoningContent && m.reasoningContent) msg.reasoning_content = m.reasoningContent;
93
+ const reasoningDetails = openRouterReasoningDetails(m);
94
+ if (reasoningDetails) msg.reasoning_details = reasoningDetails;
86
95
  out.push(msg);
87
96
  continue;
88
97
  }
89
- if (m.role === 'assistant' && replaysReasoningContent && m.reasoningContent) {
90
- out.push({ role: m.role, content: normalizeContentForOpenAIChat(m.content, { role: 'assistant' }), reasoning_content: m.reasoningContent });
98
+ const reasoningDetails = m.role === 'assistant' ? openRouterReasoningDetails(m) : null;
99
+ if (m.role === 'assistant' && ((replaysReasoningContent && m.reasoningContent) || reasoningDetails)) {
100
+ const msg = {
101
+ role: m.role,
102
+ content: normalizeContentForOpenAIChat(m.content, { role: 'assistant' }),
103
+ };
104
+ if (replaysReasoningContent && m.reasoningContent) msg.reasoning_content = m.reasoningContent;
105
+ if (reasoningDetails) msg.reasoning_details = reasoningDetails;
106
+ out.push(msg);
91
107
  continue;
92
108
  }
93
109
  out.push({ role: m.role, content: normalizeContentForOpenAIChat(m.content, { role: m.role }) });
@@ -392,6 +408,13 @@ export function toXaiResponsesInput(messages, providerState, options = {}) {
392
408
  const m = messages[messageIndex];
393
409
  if (!includeSystem && m.role === 'system') continue;
394
410
  if (m.role !== 'tool') flushToolMedia();
411
+ const orderedReplay = m.role === 'assistant'
412
+ ? providerReplayItems(m, 'xai-responses')
413
+ : undefined;
414
+ if (orderedReplay?.length) {
415
+ input.push(...orderedReplay);
416
+ continue;
417
+ }
395
418
  const reasoningItems = reasoningByMessageIndex.get(messageIndex);
396
419
  if (reasoningItems) input.push(...reasoningItems);
397
420
  if (dropToolHistory && m.role === 'tool') continue;