mixdog 0.9.50 → 0.9.52

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 (134) hide show
  1. package/package.json +5 -3
  2. package/scripts/abort-recovery-test.mjs +17 -1
  3. package/scripts/agent-model-liveness-test.mjs +79 -2
  4. package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
  5. package/scripts/anthropic-transport-policy-test.mjs +466 -0
  6. package/scripts/atomic-lock-tryonce-test.mjs +60 -1
  7. package/scripts/build-tui.mjs +13 -1
  8. package/scripts/channel-daemon-smoke.mjs +630 -10
  9. package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
  10. package/scripts/code-graph-disk-hit-test.mjs +11 -0
  11. package/scripts/code-graph-root-federation-test.mjs +273 -0
  12. package/scripts/compact-pressure-test.mjs +55 -1
  13. package/scripts/compact-smoke.mjs +80 -0
  14. package/scripts/context-mcp-metering-test.mjs +1350 -19
  15. package/scripts/deferred-tool-loading-test.mjs +17 -0
  16. package/scripts/gemini-provider-test.mjs +1053 -0
  17. package/scripts/hook-bus-test.mjs +23 -0
  18. package/scripts/internal-tools-normalization-test.mjs +10 -0
  19. package/scripts/interrupted-turn-history-test.mjs +371 -0
  20. package/scripts/lifecycle-api-test.mjs +76 -0
  21. package/scripts/max-output-recovery-test.mjs +55 -0
  22. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  23. package/scripts/memory-pg-recovery-test.mjs +59 -0
  24. package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
  25. package/scripts/process-lifecycle-test.mjs +389 -0
  26. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  27. package/scripts/provider-contract-test.mjs +268 -0
  28. package/scripts/provider-toolcall-test.mjs +520 -3
  29. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  30. package/scripts/resource-admission-test.mjs +789 -0
  31. package/scripts/session-bench-cache-break-test.mjs +102 -0
  32. package/scripts/session-bench.mjs +101 -42
  33. package/scripts/shell-failure-diagnostics-test.mjs +73 -4
  34. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  35. package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
  36. package/scripts/smoke-loop-failure-summary.mjs +16 -0
  37. package/scripts/smoke-loop.mjs +2 -7
  38. package/scripts/steering-drain-buckets-test.mjs +18 -0
  39. package/scripts/tool-failures.mjs +15 -1
  40. package/scripts/toolcall-args-test.mjs +14 -6
  41. package/scripts/tui-transcript-perf-test.mjs +43 -7
  42. package/scripts/web-fetch-routing-test.mjs +158 -0
  43. package/src/cli.mjs +15 -2
  44. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  45. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  46. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
  47. package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
  48. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  49. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  50. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  51. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  52. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  53. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  54. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  55. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  56. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  57. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  58. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  60. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  61. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
  64. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
  66. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
  67. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  68. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  69. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  70. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  71. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  72. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
  73. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  74. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  75. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
  76. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
  77. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  78. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  79. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  80. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  81. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  82. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  83. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  84. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
  85. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  86. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
  87. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  88. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  89. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  90. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  91. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  92. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  93. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  94. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  95. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  96. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  97. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  98. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  99. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  100. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  101. package/src/runtime/memory/index.mjs +22 -4
  102. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  103. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  104. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  105. package/src/runtime/search/index.mjs +41 -0
  106. package/src/runtime/search/lib/http-fetch.mjs +154 -0
  107. package/src/runtime/search/tool-defs.mjs +23 -0
  108. package/src/runtime/shared/atomic-file.mjs +28 -150
  109. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  110. package/src/runtime/shared/process-shutdown.mjs +27 -4
  111. package/src/runtime/shared/resource-admission.mjs +359 -0
  112. package/src/runtime/shared/staged-child-result.mjs +19 -0
  113. package/src/session-runtime/context-status.mjs +38 -27
  114. package/src/session-runtime/lifecycle-api.mjs +26 -6
  115. package/src/session-runtime/provider-request-tools.mjs +72 -0
  116. package/src/session-runtime/runtime-core.mjs +43 -18
  117. package/src/session-runtime/session-turn-api.mjs +5 -4
  118. package/src/session-runtime/tool-catalog.mjs +375 -15
  119. package/src/standalone/agent-tool.mjs +17 -38
  120. package/src/standalone/channel-daemon-client.mjs +200 -38
  121. package/src/standalone/channel-daemon-transport.mjs +136 -9
  122. package/src/standalone/channel-worker.mjs +79 -12
  123. package/src/standalone/hook-bus/handlers.mjs +4 -0
  124. package/src/standalone/hook-bus/rules.mjs +7 -1
  125. package/src/standalone/hook-bus.mjs +10 -2
  126. package/src/tui/App.jsx +17 -11
  127. package/src/tui/app/live-spinner-visibility.mjs +20 -0
  128. package/src/tui/dist/index.mjs +101 -281
  129. package/src/tui/engine/session-api-ext.mjs +13 -2
  130. package/src/tui/engine/session-api.mjs +1 -1
  131. package/src/tui/engine/turn.mjs +10 -6
  132. package/src/tui/engine.mjs +13 -4
  133. package/src/tui/index.jsx +4 -1
  134. package/src/tui/lib/voice-setup.mjs +16 -0
@@ -36,7 +36,9 @@ const OPENAI_WS_URL = 'wss://api.openai.com/v1/responses';
36
36
  const XAI_WS_URL = 'wss://api.x.ai/v1/responses';
37
37
  export const WS_IDLE_MS = resolveTimeoutMs(
38
38
  'MIXDOG_PROVIDER_WS_IDLE_MS',
39
- 20 * 60_000,
39
+ // Codex keeps its cached connection until the backend's documented
40
+ // 60-minute Responses WebSocket connection limit.
41
+ 60 * 60_000,
40
42
  { minMs: 60_000, maxMs: 60 * 60_000 },
41
43
  );
42
44
  const WS_HANDSHAKE_TIMEOUT_MS = PROVIDER_WS_HANDSHAKE_TIMEOUT_MS;
@@ -67,6 +69,44 @@ const MAX_POOLED_SOCKETS_PER_KEY = 8;
67
69
  // lastRequestInput, lastResponseItems, lastInputLen, turnState,
68
70
  // closing, ephemeral }
69
71
  const _wsPool = new Map();
72
+ let _releaseSequence = 0;
73
+
74
+ function _poolCompatibility(auth, cacheKey) {
75
+ const type = String(auth?.type || 'openai-oauth');
76
+ // Prefer the stable OAuth account id so an access-token refresh can keep
77
+ // using the same connection. If it is unavailable, fail closed to the
78
+ // credential itself; a refresh then opens a new socket rather than risking
79
+ // cross-account continuation.
80
+ const credentialIdentity = auth?.account_id || auth?.apiKey || auth?.access_token || '';
81
+ return {
82
+ authIdentity: createHash('sha256')
83
+ .update(`${type}\0${String(credentialIdentity)}`)
84
+ .digest('hex'),
85
+ cacheKeyIdentity: String(cacheKey || ''),
86
+ };
87
+ }
88
+
89
+ function _entryCompatible(entry, compatibility) {
90
+ return entry?.authIdentity === compatibility.authIdentity
91
+ && entry?.cacheKeyIdentity === compatibility.cacheKeyIdentity;
92
+ }
93
+
94
+ // A previous_response_id is valid on the connection that produced it. When
95
+ // concurrency temporarily creates sibling sockets for one pool key, continue
96
+ // on the most recently completed sibling instead of whichever socket happened
97
+ // to be inserted first. This is the closest representation of Codex's
98
+ // turn-local single-connection ownership without weakening the existing
99
+ // full-frame fallback checks.
100
+ function _selectIdleEntry(entries, compatibility) {
101
+ let selected;
102
+ for (const entry of entries || []) {
103
+ if (entry?.busy || !_entryCompatible(entry, compatibility)) continue;
104
+ if (!selected || (entry.releaseSequence || 0) > (selected.releaseSequence || 0)) {
105
+ selected = entry;
106
+ }
107
+ }
108
+ return selected;
109
+ }
70
110
 
71
111
  // --- Cache-route probe state (2026-07-04 hunt) -----------------------------
72
112
  // CF cookie stickiness (codex chatgpt_cloudflare_cookies.rs:22-55 persists
@@ -696,6 +736,8 @@ function _openSocket({ auth, sessionToken, turnState, externalSignal, cacheKey,
696
736
  });
697
737
  }
698
738
 
739
+ let _openSocketImpl = _openSocket;
740
+
699
741
  export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders, forceFresh, externalSignal }) {
700
742
  const _acqStart = Date.now();
701
743
  if (process.env.MIXDOG_DEBUG_AGENT) {
@@ -707,9 +749,16 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
707
749
  }
708
750
  if (poolKey && !forceFresh) {
709
751
  const arr = _wsPool.get(poolKey) || [];
710
- // Prune dead entries first.
752
+ const compatibility = _poolCompatibility(auth, cacheKey);
753
+ // Prune dead entries and idle sockets from an obsolete auth/cache
754
+ // boundary. Busy incompatible entries are left alone until their owner
755
+ // releases them, but can never be selected by this acquire.
711
756
  for (let i = arr.length - 1; i >= 0; i--) {
712
- if (!_isOpen(arr[i]) || arr[i].closing) {
757
+ const incompatibleIdle = !arr[i].busy && !_entryCompatible(arr[i], compatibility);
758
+ if (!_isOpen(arr[i]) || arr[i].closing || incompatibleIdle) {
759
+ if (incompatibleIdle) {
760
+ try { arr[i].socket.close(1000, 'pool_boundary_changed'); } catch {}
761
+ }
713
762
  _clearIdle(arr[i]);
714
763
  _clearLiveness(arr[i]);
715
764
  arr.splice(i, 1);
@@ -721,7 +770,7 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
721
770
  // bound before hand-out; a dead one is evicted and the scan retries the
722
771
  // next idle entry so a busy caller is never handed a wedged socket.
723
772
  let idle;
724
- while ((idle = arr.find(e => !e.busy))) {
773
+ while ((idle = _selectIdleEntry(arr, compatibility))) {
725
774
  _clearIdle(idle);
726
775
  _clearLiveness(idle);
727
776
  // Reserve the entry BEFORE awaiting the probe: _pingProbe yields the
@@ -760,7 +809,7 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
760
809
  process.stderr.write(`[agent-trace] acquire-ephemeral cacheKey=${cacheKey} reason=cap elapsed=${Date.now() - _acqStart}ms\n`);
761
810
  }
762
811
  const ephSessionToken = _mintSessionToken(cacheKey, auth);
763
- const { socket, turnState } = await _openSocket({ auth, sessionToken: ephSessionToken, turnState: null, externalSignal, cacheKey, codexHeaders });
812
+ const { socket, turnState } = await _openSocketImpl({ auth, sessionToken: ephSessionToken, turnState: null, externalSignal, cacheKey, codexHeaders });
764
813
  // Drain-complete fence: same invariant as the normal acquire path —
765
814
  // if drain fired during the await, do NOT push an ephemeral entry
766
815
  // back into the pool.
@@ -778,6 +827,8 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
778
827
  lastResponseItems: null,
779
828
  lastInputLen: 0,
780
829
  lastInputPrefixHash: null,
830
+ releaseSequence: 0,
831
+ ...compatibility,
781
832
  turnState: turnState || null,
782
833
  closing: false,
783
834
  ephemeral: true,
@@ -797,10 +848,17 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
797
848
  // returns "No tool output found for function call …". turnState only
798
849
  // propagates within a single entry across its own iterations.
799
850
  const sessionToken = _mintSessionToken(cacheKey, auth);
851
+ const compatibility = _poolCompatibility(auth, cacheKey);
800
852
  if (process.env.MIXDOG_DEBUG_AGENT) {
801
853
  process.stderr.write(`[agent-trace] acquire-new tokenHash=${createHash('sha256').update(String(sessionToken)).digest('hex').slice(0, 8)} elapsed=${Date.now() - _acqStart}ms\n`);
802
854
  }
803
- const { socket, turnState } = await _openSocket({ auth, sessionToken, turnState: null, externalSignal, cacheKey, codexHeaders });
855
+ const { socket, turnState } = await _openSocketImpl({ auth, sessionToken, turnState: null, externalSignal, cacheKey, codexHeaders });
856
+ // Drain may complete while the normal handshake is awaiting 'open'. Never
857
+ // return or insert that late socket into the already-drained process pool.
858
+ if (_drainComplete) {
859
+ try { socket.close(1000, 'drain-complete'); } catch {}
860
+ throw new Error('WS pool drained — process exiting');
861
+ }
804
862
  const entry = {
805
863
  socket,
806
864
  busy: true,
@@ -811,6 +869,8 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
811
869
  lastResponseItems: null,
812
870
  lastInputLen: 0,
813
871
  lastInputPrefixHash: null,
872
+ releaseSequence: 0,
873
+ ...compatibility,
814
874
  turnState: turnState || null,
815
875
  closing: false,
816
876
  ephemeral: false,
@@ -840,6 +900,7 @@ export function releaseWebSocket({ entry, poolKey, keep }) {
840
900
  // Mark activity at release, then arm both the idle-close timer and the
841
901
  // periodic liveness ping so a socket that dies while pooled is evicted
842
902
  // before the next acquire can hand it out.
903
+ entry.releaseSequence = ++_releaseSequence;
843
904
  entry.lastAliveAt = Date.now();
844
905
  _scheduleIdleClose(poolKey, entry);
845
906
  if (WS_PING_ENABLED) _armLiveness(poolKey, entry);
@@ -848,6 +909,48 @@ export function releaseWebSocket({ entry, poolKey, keep }) {
848
909
  _setTransportReferenced(entry, false);
849
910
  }
850
911
 
912
+ export function closeOpenaiWsPoolForSession(poolKey, reason = 'session_closed') {
913
+ if (!poolKey) return;
914
+ const entries = _wsPool.get(poolKey);
915
+ if (!entries) return;
916
+ _wsPool.delete(poolKey);
917
+ for (const entry of entries) {
918
+ _clearIdle(entry);
919
+ _clearLiveness(entry);
920
+ try { entry.socket.close(1000, reason); } catch {}
921
+ }
922
+ }
923
+
924
+ // Focused pool lifecycle test seam. Entries still pass through the production
925
+ // release/acquire code; this only avoids a live provider handshake.
926
+ export function _seedWebSocketEntryForTest({ poolKey, auth, cacheKey, entry }) {
927
+ Object.assign(entry, _poolCompatibility(auth, cacheKey));
928
+ if (entry.releaseSequence === undefined) entry.releaseSequence = 0;
929
+ _getPoolArr(poolKey).push(entry);
930
+ return entry;
931
+ }
932
+
933
+ export function _clearWebSocketPoolForTest() {
934
+ for (const arr of _wsPool.values()) {
935
+ for (const entry of arr) {
936
+ _clearIdle(entry);
937
+ _clearLiveness(entry);
938
+ try { entry.socket.close(1000, 'test_cleanup'); } catch {}
939
+ }
940
+ }
941
+ _wsPool.clear();
942
+ _releaseSequence = 0;
943
+ }
944
+
945
+ export function _setOpenSocketForTest(fn) {
946
+ _openSocketImpl = typeof fn === 'function' ? fn : _openSocket;
947
+ }
948
+
949
+ export function _resetOpenSocketDrainForTest() {
950
+ _openSocketImpl = _openSocket;
951
+ _drainComplete = false;
952
+ }
953
+
851
954
  // Drain-complete fence — set true once _closeAllPooledSockets runs so any
852
955
  // in-flight acquire that resumes after drain throws instead of pushing a
853
956
  // fresh socket into the cleared pool. Single-set, process-lifetime invariant.
@@ -871,4 +974,6 @@ export function _closeAllPooledSockets(reason = 'shutdown') {
871
974
  _wsPool.clear();
872
975
  }
873
976
  export const drainOpenaiWsPool = _closeAllPooledSockets;
977
+ globalThis.__mixdogCloseProviderConnectionsForSession = closeOpenaiWsPoolForSession;
978
+ globalThis.__mixdogDrainProviderConnections = drainOpenaiWsPool;
874
979
  process.on('exit', drainOpenaiWsPool);
@@ -23,6 +23,7 @@ import { populateHttpStatusFromMessage } from './retry-classifier.mjs';
23
23
  import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
24
24
  import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
25
25
  import {
26
+ PROVIDER_FIRST_BYTE_TIMEOUT_MS,
26
27
  PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS,
27
28
  PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
28
29
  PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS,
@@ -91,8 +92,8 @@ export function _positiveInt(value, fallback = 0) {
91
92
  export const WS_PRE_RESPONSE_CREATED_MS = (() => {
92
93
  const raw = process.env.MIXDOG_PROVIDER_WS_PRE_RESPONSE_CREATED_TIMEOUT_MS;
93
94
  const n = Number(raw);
94
- if (Number.isFinite(n) && n > 0) return Math.min(Math.max(n, 1_000), 120_000);
95
- return 10_000;
95
+ if (Number.isFinite(n) && n > 0) return Math.min(Math.max(n, 1_000), PROVIDER_FIRST_BYTE_TIMEOUT_MS);
96
+ return PROVIDER_FIRST_BYTE_TIMEOUT_MS;
96
97
  })();
97
98
  // Single inter-chunk idle timer. Resets on EVERY received frame — any frame,
98
99
  // including metadata/keepalive, proves the socket is live.
@@ -866,12 +867,14 @@ export async function _streamResponse({
866
867
  // Only non-empty reasoning text is model progress. Empty
867
868
  // deltas remain transport activity via resetIdle() above.
868
869
  if (event.delta) {
870
+ midState.emittedReasoning = true;
869
871
  try { onStreamDelta?.('reasoning'); } catch {}
870
872
  bumpSemanticIdle();
871
873
  }
872
874
  break;
873
875
  case 'response.output_item.added':
874
876
  if (event.item?.type === 'function_call') {
877
+ midState.startedToolCall = true;
875
878
  markActiveToolItem(event.item);
876
879
  pendingCalls.set(event.item.id || '', {
877
880
  name: event.item.name || '',
@@ -879,9 +882,11 @@ export async function _streamResponse({
879
882
  });
880
883
  _toolInFlight = true;
881
884
  } else if (event.item?.type === 'custom_tool_call') {
885
+ midState.startedToolCall = true;
882
886
  markActiveToolItem(event.item);
883
887
  _toolInFlight = true;
884
888
  } else if (event.item?.type === 'tool_search_call') {
889
+ midState.startedToolCall = true;
885
890
  markActiveToolItem(event.item);
886
891
  // Mark tool_search in-flight at item-added time, same
887
892
  // as function_call/custom_tool_call above, so the
@@ -897,12 +902,14 @@ export async function _streamResponse({
897
902
  try { onStreamDelta?.(_toolInFlight ? 'tool' : 'semantic'); } catch {}
898
903
  break;
899
904
  case 'response.function_call_arguments.delta':
905
+ if (event.delta) midState.startedToolCall = true;
900
906
  markActiveToolItem(null, event.item_id);
901
907
  _toolInFlight = true;
902
908
  try { onStreamDelta?.('tool'); } catch {}
903
909
  bumpSemanticIdle();
904
910
  break;
905
911
  case 'response.custom_tool_call_input.delta':
912
+ if (event.delta) midState.startedToolCall = true;
906
913
  markActiveToolItem(null, event.item_id);
907
914
  _toolInFlight = true;
908
915
  try { onStreamDelta?.('tool'); } catch {}
@@ -1271,6 +1278,7 @@ export async function _streamResponse({
1271
1278
  // Only non-empty reasoning deltas are model progress;
1272
1279
  // empty variants remain transport activity only.
1273
1280
  if (event.delta) {
1281
+ midState.emittedReasoning = true;
1274
1282
  try { onStreamDelta?.('reasoning'); } catch {}
1275
1283
  bumpSemanticIdle();
1276
1284
  }
@@ -1369,21 +1377,9 @@ export async function _streamResponse({
1369
1377
  }
1370
1378
  armPreStreamWatchdog();
1371
1379
  armFirstMeaningfulWatchdog();
1372
- // Periodic client-side WS ping while the stream is active. The server's
1373
- // server closes with 1011 "keepalive ping timeout" when it thinks the
1374
- // peer is silent during long reasoning windows where no data frames
1375
- // flow. Sending a ping every 10s from our side keeps the socket warm.
1376
- // The interval is unref'd so it never holds the event loop open, and
1377
- // cleanup() clears it on every terminal path (completed / close /
1378
- // error / abort / mid-stream retry teardown).
1379
- keepaliveTimer = setInterval(() => {
1380
- try {
1381
- if (socket.readyState !== WebSocket.OPEN) return;
1382
- if (WS_TRACE_ENABLED) _writeWsLifecycleTrace('ping');
1383
- socket.ping();
1384
- } catch {}
1385
- }, 10_000);
1386
- try { keepaliveTimer.unref?.(); } catch {}
1380
+ // No proactive client ping: Codex's Responses WebSocket uses the
1381
+ // library default (automatic pong replies, no periodic client ping).
1382
+ // Incoming frames remain bounded by the 300s stream idle timeout.
1387
1383
  });
1388
1384
  }
1389
1385
 
@@ -43,6 +43,53 @@ export function applyOpenAIDirectFastTier(body, model, opts) {
43
43
  return body;
44
44
  }
45
45
 
46
+ function shouldFallbackDirectTransport(err, options) {
47
+ const status = Number(err?.httpStatus || err?.status || 0);
48
+ const unsafeToRetry = err?.liveTextEmitted === true
49
+ || err?.emittedToolCall === true
50
+ || err?.unsafeToRetry === true;
51
+ if (unsafeToRetry || options?.signal?.aborted || options?.enabled === false) return false;
52
+ // Public OpenAI can reject the WebSocket upgrade while still supporting
53
+ // the same request over HTTP/SSE. Only the shared transport's explicit
54
+ // handshake marker may turn these application-looking statuses into a
55
+ // one-shot transport fallback.
56
+ if (err?.wsFailurePhase === 'handshake'
57
+ && err?.wsHttpFallbackEligible === true
58
+ && [403, 404, 429].includes(status)) {
59
+ return true;
60
+ }
61
+ // These are application/auth/quota outcomes from the public Responses API,
62
+ // not evidence that WebSocket transport is unhealthy. Reissuing them over
63
+ // HTTP cannot recover and can duplicate an accepted throttled request.
64
+ if (status >= 400 && status < 500) return false;
65
+ return shouldFallbackTransport(err, options);
66
+ }
67
+
68
+ function directOpenAiHandshakeErrorPolicy({ status }) {
69
+ if ([403, 404, 429].includes(Number(status))) {
70
+ return { retry: false, httpFallback: true };
71
+ }
72
+ return null;
73
+ }
74
+
75
+ const DIRECT_WS_TEST_SEAM_KEYS = Object.freeze([
76
+ '_acquireWithRetryFn',
77
+ '_streamFn',
78
+ '_sendFrameFn',
79
+ '_sleepFn',
80
+ '_sendSpanTraceFn',
81
+ '_agentTraceFn',
82
+ ]);
83
+
84
+ function directWebSocketTestSeams(value) {
85
+ if (!value || typeof value !== 'object') return {};
86
+ const out = {};
87
+ for (const key of DIRECT_WS_TEST_SEAM_KEYS) {
88
+ if (typeof value[key] === 'function') out[key] = value[key];
89
+ }
90
+ return out;
91
+ }
92
+
46
93
  export class OpenAIDirectProvider {
47
94
  // input_tokens INCLUDES cached tokens (OpenAI convention). See registry.mjs.
48
95
  static inputExcludesCache = false;
@@ -56,7 +103,7 @@ export class OpenAIDirectProvider {
56
103
  if (!k) throw new Error('OPENAI_API_KEY not configured (providers.openai.apiKey)');
57
104
  return k;
58
105
  }
59
- // Auth-recovery mirror of openai-compat.reloadApiKey: on a 401/403 the key
106
+ // Auth-recovery mirror of openai-compat.reloadApiKey: on a 401 the key
60
107
  // was likely rotated after this provider instance was built, so re-read
61
108
  // only OpenAI's environment/keychain sources before the single retry.
62
109
  // Returns the fresh key (or null if none) — no client to rebuild here since
@@ -140,7 +187,18 @@ export class OpenAIDirectProvider {
140
187
  iteration,
141
188
  useModel,
142
189
  };
143
- const dispatchWs = (a) => sendViaWebSocket({
190
+ // Keep the same deterministic transport seams as openai-oauth. Besides
191
+ // making the direct path regression-testable without a live request,
192
+ // this lets embedded callers supply their already-instrumented
193
+ // Responses transports without changing the public request envelope.
194
+ const sendWs = typeof opts._sendViaWebSocketFn === 'function'
195
+ ? opts._sendViaWebSocketFn
196
+ : sendViaWebSocket;
197
+ const sendHttp = typeof opts._sendViaHttpSseFn === 'function'
198
+ ? opts._sendViaHttpSseFn
199
+ : sendViaHttpSse;
200
+ const wsTestSeams = directWebSocketTestSeams(opts._webSocketTestSeams);
201
+ const dispatchWs = (a) => sendWs({
144
202
  ...common,
145
203
  auth: a,
146
204
  sendOpts: opts,
@@ -151,12 +209,18 @@ export class OpenAIDirectProvider {
151
209
  // the OAuth/Codex handshake headers. Direct API-key auth pins its own
152
210
  // provider so it stays on the public (non-Codex) envelope.
153
211
  traceProvider: 'openai-direct',
212
+ // Narrow deterministic seams for transport-policy regression tests.
213
+ // Production callers never set this object.
214
+ ...wsTestSeams,
215
+ // Mandatory and deliberately applied AFTER sanitized test seams:
216
+ // no caller can disable or invert direct handshake provenance.
217
+ handshakeErrorPolicy: directOpenAiHandshakeErrorPolicy,
154
218
  });
155
219
  // WS→HTTP/SSE fallback mirrors the openai-oauth wrapper: the shared
156
220
  // HTTP transport now accepts auth.type==='openai-direct' (public
157
221
  // Responses endpoint + Bearer <apiKey>), so the api-key provider gets
158
- // the same envelope. Gate via shouldFallbackTransport (denies
159
- // 401/403/404/429 + liveTextEmitted/emittedToolCall/unsafeToRetry).
222
+ // the same envelope. The direct gate denies every application 4xx and
223
+ // any liveTextEmitted/emittedToolCall/unsafeToRetry outcome.
160
224
  const transportPolicy = resolveOpenAiTransportPolicy();
161
225
  const httpFallbackEnabled = transportPolicy.allowHttpFallback
162
226
  && _envFlag('MIXDOG_OPENAI_HTTP_FALLBACK', true);
@@ -164,7 +228,7 @@ export class OpenAIDirectProvider {
164
228
  if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) {
165
229
  process.stderr.write('[openai-ws] WebSocket unhealthy; falling back to HTTP/SSE\n');
166
230
  }
167
- return sendViaHttpSse({ ...common, auth: a, opts, fetchFn: opts._fetchFn });
231
+ return sendHttp({ ...common, auth: a, opts, fetchFn: opts._fetchFn });
168
232
  };
169
233
  // Transport-policy switch (MIXDOG_OAI_TRANSPORT). 'http-sse' forces the
170
234
  // HTTP/SSE transport directly — no WS attempt, so skip the fallback log
@@ -172,7 +236,7 @@ export class OpenAIDirectProvider {
172
236
  // ('auto'/'ws-full'/'ws-delta') keep the WS-first path below; ws-full vs
173
237
  // ws-delta only affects the delta gate inside openai-ws-delta.mjs.
174
238
  if (transportPolicy.transport === 'http') {
175
- return await sendViaHttpSse({ ...common, auth, opts, fetchFn: opts._fetchFn });
239
+ return await sendHttp({ ...common, auth, opts, fetchFn: opts._fetchFn });
176
240
  }
177
241
  try {
178
242
  return await dispatchWs(auth);
@@ -183,10 +247,12 @@ export class OpenAIDirectProvider {
183
247
  const unsafeToRetry = err?.liveTextEmitted === true
184
248
  || err?.emittedToolCall === true
185
249
  || err?.unsafeToRetry === true;
186
- // (1) 401/403 → reload only OpenAI auth and retry once over WS.
187
- // shouldFallbackTransport denies 401/403, so this branch owns
188
- // its own guard.
189
- if ((status === 401 || status === 403) && !unsafeToRetry) {
250
+ // (1) 401 → reload only OpenAI auth and retry once over WS.
251
+ // Ordinary 403 is a permission/policy decision, not evidence that
252
+ // an API key rotated. Match the OAuth path and keep it terminal.
253
+ // The direct fallback guard denies auth statuses, so this branch
254
+ // owns the single credential-reload replay.
255
+ if (status === 401 && err?.wsFailurePhase !== 'stream' && !unsafeToRetry) {
190
256
  process.stderr.write(`[openai-ws] ${status} — reloading apiKey and retrying once\n`);
191
257
  const freshKey = this.reloadApiKey();
192
258
  if (freshKey) {
@@ -194,7 +260,7 @@ export class OpenAIDirectProvider {
194
260
  try {
195
261
  return await dispatchWs(retryAuth);
196
262
  } catch (retryErr) {
197
- if (shouldFallbackTransport(retryErr, { signal: externalSignal, enabled: httpFallbackEnabled })) {
263
+ if (shouldFallbackDirectTransport(retryErr, { signal: externalSignal, enabled: httpFallbackEnabled })) {
198
264
  return await dispatchHttp(retryAuth);
199
265
  }
200
266
  throw retryErr;
@@ -204,7 +270,7 @@ export class OpenAIDirectProvider {
204
270
  }
205
271
  // (2) WS transport failure → HTTP/SSE fallback (predicate handles
206
272
  // the safety denies).
207
- if (shouldFallbackTransport(err, { signal: externalSignal, enabled: httpFallbackEnabled })) {
273
+ if (shouldFallbackDirectTransport(err, { signal: externalSignal, enabled: httpFallbackEnabled })) {
208
274
  return await dispatchHttp(auth);
209
275
  }
210
276
  throw err;
@@ -20,11 +20,44 @@ const OPENCODE_GO_CONTEXT_WINDOWS = Object.freeze({
20
20
  'glm-5': 202752,
21
21
  });
22
22
 
23
- function isAnthropicGoModel(model) {
23
+ export function isAnthropicGoModel(model) {
24
24
  const id = String(model || '').toLowerCase();
25
25
  return ANTHROPIC_MODEL_PREFIXES.some(prefix => id.startsWith(prefix));
26
26
  }
27
27
 
28
+ export function resolveOpenCodeGoBaseURLs(configuredBaseURL) {
29
+ const presetBase = OPENAI_COMPAT_PRESETS['opencode-go'].baseURL;
30
+ const openai = String(configuredBaseURL || presetBase).replace(/\/+$/, '');
31
+ // Anthropic's SDK appends `/v1/messages` itself. Supplying the OpenAI base
32
+ // (`.../v1`) would therefore produce the invalid `.../v1/v1/messages`.
33
+ const anthropic = openai.replace(/\/v1$/i, '');
34
+ return { openai, anthropic };
35
+ }
36
+
37
+ export function openCodeGoEndpointForModel(model, configuredBaseURL) {
38
+ const bases = resolveOpenCodeGoBaseURLs(configuredBaseURL);
39
+ return isAnthropicGoModel(model)
40
+ ? `${bases.anthropic}/v1/messages`
41
+ : `${bases.openai}/chat/completions`;
42
+ }
43
+
44
+ export function normalizeOpenCodeGoResultUsage(result, anthropicRoute) {
45
+ if (!anthropicRoute || !result?.usage) return result;
46
+ const usage = result.usage;
47
+ const input = Number(usage.inputTokens) || 0;
48
+ const cached = Number(usage.cachedTokens) || 0;
49
+ const cacheWrite = Number(usage.cacheWriteTokens) || 0;
50
+ const inclusiveInput = input + cached + cacheWrite;
51
+ return {
52
+ ...result,
53
+ usage: {
54
+ ...usage,
55
+ inputTokens: inclusiveInput,
56
+ promptTokens: Math.max(Number(usage.promptTokens) || 0, inclusiveInput),
57
+ },
58
+ };
59
+ }
60
+
28
61
  function opencodeGoContextWindow(_modelId, current = 0) {
29
62
  const native = Number(current);
30
63
  if (Number.isFinite(native) && native > 0) return native;
@@ -46,24 +79,29 @@ function opencodeGoReasoningLevels(model, current = null) {
46
79
  export class OpenCodeGoProvider {
47
80
  static inputExcludesCache = false;
48
81
  name = 'opencode-go';
82
+ config;
49
83
  openai;
50
84
  anthropic;
51
85
 
52
86
  constructor(config = {}) {
87
+ // Retain the outer account config for the common provider-admission
88
+ // lane. The delegated transports are intentionally not independently
89
+ // wrapped, so all model-family routes share this one 64-wide account.
90
+ this.config = config;
53
91
  const preset = OPENAI_COMPAT_PRESETS['opencode-go'];
54
- const baseURL = config.baseURL || preset.baseURL;
55
- this.openai = new OpenAICompatProvider('opencode-go', { ...config, baseURL });
92
+ const bases = resolveOpenCodeGoBaseURLs(config.baseURL || preset.baseURL);
93
+ this.openai = new OpenAICompatProvider('opencode-go', { ...config, baseURL: bases.openai });
56
94
  this.anthropic = new AnthropicProvider({
57
95
  ...config,
58
96
  name: 'opencode-go',
59
- baseURL,
97
+ baseURL: bases.anthropic,
60
98
  disableBetaHeaders: true,
61
99
  });
62
100
  }
63
101
 
64
102
  async send(messages, model, tools, sendOpts) {
65
103
  if (isAnthropicGoModel(model)) {
66
- return this.anthropic.send(messages, model, tools, {
104
+ const result = await this.anthropic.send(messages, model, tools, {
67
105
  ...(sendOpts || {}),
68
106
  cacheStrategy: {
69
107
  tools: 'none',
@@ -72,6 +110,7 @@ export class OpenCodeGoProvider {
72
110
  messages: 'none',
73
111
  },
74
112
  });
113
+ return normalizeOpenCodeGoResultUsage(result, true);
75
114
  }
76
115
  return this.openai.send(messages, model, tools, sendOpts);
77
116
  }
@@ -5,6 +5,7 @@ import {
5
5
  hasGrokOAuthCredentials,
6
6
  } from './oauth-credential-probes.mjs';
7
7
  import { refreshCatalog as refreshMetadataCatalog, warmModelMetadataCatalogs } from './model-catalog.mjs';
8
+ import { wrapProviderAdmission } from './admission-scheduler.mjs';
8
9
  // OpenAI-compat provider names are self-declared by openai-compat-presets.mjs via
9
10
  // OPENAI_COMPAT_PRESETS. No parallel list maintained here.
10
11
  const providers = new Map();
@@ -15,6 +16,7 @@ const providerModulePromises = new Map();
15
16
  // byte identical to the live one, so lazy-init misses that re-run initProviders
16
17
  // don't churn (tear down + rebuild) every live provider instance on every call.
17
18
  const signatures = new Map();
19
+ const KNOWN_INPUT_EXCLUDES_CACHE = new Set(['anthropic', 'anthropic-oauth']);
18
20
 
19
21
  // Module-level init serialization. agent-tool.mjs's ensureProvider() already
20
22
  // serializes inits per provider on a chain promise, but its gateOnPrior() lets
@@ -108,9 +110,9 @@ async function loadProviderCtor(name, signal = null) {
108
110
 
109
111
  function instantiateProvider(name, Ctor, cfg) {
110
112
  if (Object.prototype.hasOwnProperty.call(OPENAI_COMPAT_PRESETS, name) && name !== 'opencode-go') {
111
- return new Ctor(name, cfg);
113
+ return wrapProviderAdmission(new Ctor(name, cfg), name);
112
114
  }
113
- return new Ctor(cfg);
115
+ return wrapProviderAdmission(new Ctor(cfg), name);
114
116
  }
115
117
 
116
118
  export async function initProviders(config, { signal = null } = {}) {
@@ -232,21 +234,21 @@ export function getProvider(name) {
232
234
  if (name === 'anthropic-oauth' && hasAnthropicOAuthCredentials()) {
233
235
  const Ctor = providerCtors.get('anthropic-oauth');
234
236
  if (!Ctor) return undefined;
235
- const inst = new Ctor({});
237
+ const inst = wrapProviderAdmission(new Ctor({}), name);
236
238
  providers.set(name, inst);
237
239
  return inst;
238
240
  }
239
241
  if (name === 'openai-oauth' && hasOpenAIOAuthCredentials()) {
240
242
  const Ctor = providerCtors.get('openai-oauth');
241
243
  if (!Ctor) return undefined;
242
- const inst = new Ctor({});
244
+ const inst = wrapProviderAdmission(new Ctor({}), name);
243
245
  providers.set(name, inst);
244
246
  return inst;
245
247
  }
246
248
  if (name === 'grok-oauth' && hasGrokOAuthCredentials()) {
247
249
  const Ctor = providerCtors.get('grok-oauth');
248
250
  if (!Ctor) return undefined;
249
- const inst = new Ctor({});
251
+ const inst = wrapProviderAdmission(new Ctor({}), name);
250
252
  providers.set(name, inst);
251
253
  return inst;
252
254
  }
@@ -261,15 +263,54 @@ export function getProvider(name) {
261
263
  // provider states its own answer — no central regex to keep in sync. Unknown /
262
264
  // unregistered providers default to false (the openai/gemini majority).
263
265
  export function providerInputExcludesCache(name) {
264
- const p = getProvider(name);
265
- if (p?.constructor?.inputExcludesCache === true) return true;
266
- return String(name || '').toLowerCase().includes('anthropic');
266
+ const normalized = String(name || '').toLowerCase();
267
+ // Usage accounting is a pure lookup. In particular, never call
268
+ // getProvider() here: its OAuth miss path probes credentials and may lazily
269
+ // instantiate/register a provider.
270
+ const p = providers.get(normalized);
271
+ if (p?.constructor && typeof p.constructor.inputExcludesCache === 'boolean') {
272
+ return p.constructor.inputExcludesCache;
273
+ }
274
+ // A constructor may already be loaded even when the provider is currently
275
+ // absent during a route/config transition. Preserve its declaration, but
276
+ // never infer usage semantics from an arbitrary provider-name substring.
277
+ const Ctor = providerCtors.get(normalized);
278
+ if (Ctor && typeof Ctor.inputExcludesCache === 'boolean') {
279
+ return Ctor.inputExcludesCache;
280
+ }
281
+ // Built-in usage semantics must also be correct before lazy construction
282
+ // (including disabled providers in a fresh process).
283
+ return KNOWN_INPUT_EXCLUDES_CACHE.has(normalized);
267
284
  }
268
285
  export function getAllProviders() {
269
286
  // Defensive copy — callers must not mutate the live registry or retain
270
287
  // stale entries across re-init (initProviders rebuilds the map in place).
271
288
  return new Map(providers);
272
289
  }
290
+ // Narrow synchronous test seam for the lazy-OAuth boundary. It models a
291
+ // constructor whose module is already loaded while guaranteeing every touched
292
+ // registry entry is restored, even when the assertion callback throws.
293
+ export function _withLoadedProviderCtorForTest(name, Ctor, fn) {
294
+ const hadProvider = providers.has(name);
295
+ const priorProvider = providers.get(name);
296
+ const hadCtor = providerCtors.has(name);
297
+ const priorCtor = providerCtors.get(name);
298
+ const hadSignature = signatures.has(name);
299
+ const priorSignature = signatures.get(name);
300
+ providers.delete(name);
301
+ signatures.delete(name);
302
+ providerCtors.set(name, Ctor);
303
+ try {
304
+ return fn();
305
+ } finally {
306
+ if (hadProvider) providers.set(name, priorProvider);
307
+ else providers.delete(name);
308
+ if (hadCtor) providerCtors.set(name, priorCtor);
309
+ else providerCtors.delete(name);
310
+ if (hadSignature) signatures.set(name, priorSignature);
311
+ else signatures.delete(name);
312
+ }
313
+ }
273
314
  // Background catalog warm-up. Each provider's listModels() either hits its
274
315
  // own cached model list (no-op) or fires a single HTTP refresh. Called from
275
316
  // agent.init() after providers are registered so the first agent dispatch call