mixdog 0.9.1 → 0.9.2

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 (214) hide show
  1. package/package.json +8 -1
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/task-bench.mjs +207 -0
  23. package/scripts/tool-failures.mjs +6 -6
  24. package/scripts/tool-smoke.mjs +306 -66
  25. package/scripts/toolcall-args-test.mjs +81 -0
  26. package/src/agents/debugger/AGENT.md +4 -4
  27. package/src/agents/heavy-worker/AGENT.md +4 -2
  28. package/src/agents/reviewer/AGENT.md +4 -4
  29. package/src/agents/worker/AGENT.md +4 -2
  30. package/src/app.mjs +10 -6
  31. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  32. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  33. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  34. package/src/headless-role.mjs +14 -14
  35. package/src/help.mjs +1 -0
  36. package/src/lib/rules-builder.cjs +32 -54
  37. package/src/mixdog-session-runtime.mjs +710 -318
  38. package/src/output-styles/default.md +12 -7
  39. package/src/output-styles/minimal.md +25 -0
  40. package/src/output-styles/oneline.md +21 -0
  41. package/src/output-styles/simple.md +10 -9
  42. package/src/repl.mjs +12 -2
  43. package/src/rules/agent/00-common.md +7 -5
  44. package/src/rules/agent/30-explorer.md +7 -8
  45. package/src/rules/lead/01-general.md +3 -1
  46. package/src/rules/lead/lead-tool.md +7 -0
  47. package/src/rules/shared/01-tool.md +17 -12
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  50. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  51. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  52. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  53. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  54. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  55. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  56. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  57. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  58. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  59. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  60. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  61. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  62. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
  63. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
  64. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  65. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  66. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  67. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  68. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  69. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
  70. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
  71. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
  72. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  73. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  74. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  75. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
  76. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  77. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  78. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  80. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  82. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  83. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  84. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  85. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  87. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  88. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  90. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  91. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  92. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  95. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  96. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  97. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  99. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  100. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  101. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  102. package/src/runtime/channels/backends/discord.mjs +99 -9
  103. package/src/runtime/channels/backends/telegram.mjs +501 -0
  104. package/src/runtime/channels/index.mjs +441 -1224
  105. package/src/runtime/channels/lib/config.mjs +54 -2
  106. package/src/runtime/channels/lib/format.mjs +4 -2
  107. package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
  108. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  109. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  110. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  111. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  112. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  113. package/src/runtime/channels/lib/webhook.mjs +59 -31
  114. package/src/runtime/channels/tool-defs.mjs +1 -1
  115. package/src/runtime/memory/index.mjs +184 -19
  116. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  117. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  118. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  119. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  120. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  121. package/src/runtime/memory/lib/memory.mjs +101 -4
  122. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  123. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  124. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  125. package/src/runtime/memory/tool-defs.mjs +6 -3
  126. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  127. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  128. package/src/runtime/shared/config.mjs +9 -0
  129. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  130. package/src/runtime/shared/schedules-store.mjs +21 -19
  131. package/src/runtime/shared/tool-surface.mjs +98 -13
  132. package/src/runtime/shared/transcript-writer.mjs +129 -0
  133. package/src/runtime/shared/update-checker.mjs +214 -0
  134. package/src/standalone/agent-tool.mjs +255 -109
  135. package/src/standalone/channel-admin.mjs +133 -40
  136. package/src/standalone/channel-worker.mjs +8 -291
  137. package/src/standalone/explore-tool.mjs +2 -2
  138. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  139. package/src/standalone/provider-admin.mjs +11 -0
  140. package/src/standalone/usage-dashboard.mjs +1 -1
  141. package/src/tui/App.jsx +2096 -732
  142. package/src/tui/components/ConfirmBar.jsx +47 -0
  143. package/src/tui/components/ContextPanel.jsx +5 -3
  144. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  145. package/src/tui/components/Markdown.jsx +22 -98
  146. package/src/tui/components/Message.jsx +14 -35
  147. package/src/tui/components/Picker.jsx +87 -12
  148. package/src/tui/components/PromptInput.jsx +83 -7
  149. package/src/tui/components/QueuedCommands.jsx +1 -1
  150. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  151. package/src/tui/components/Spinner.jsx +7 -7
  152. package/src/tui/components/StatusLine.jsx +40 -21
  153. package/src/tui/components/TextEntryPanel.jsx +51 -7
  154. package/src/tui/components/ToolExecution.jsx +170 -98
  155. package/src/tui/components/TurnDone.jsx +4 -4
  156. package/src/tui/components/UsagePanel.jsx +1 -1
  157. package/src/tui/components/tool-output-format.mjs +159 -21
  158. package/src/tui/components/tool-output-format.test.mjs +87 -0
  159. package/src/tui/display-width.mjs +69 -0
  160. package/src/tui/display-width.test.mjs +35 -0
  161. package/src/tui/dist/index.mjs +6965 -2391
  162. package/src/tui/engine.mjs +287 -126
  163. package/src/tui/index.jsx +117 -7
  164. package/src/tui/keyboard-protocol.mjs +42 -0
  165. package/src/tui/lib/voice-recorder.mjs +453 -0
  166. package/src/tui/markdown/format-token.mjs +129 -76
  167. package/src/tui/markdown/format-token.test.mjs +61 -19
  168. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  169. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  170. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  171. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  172. package/src/tui/markdown/table-layout.mjs +9 -9
  173. package/src/tui/paste-attachments.mjs +0 -11
  174. package/src/tui/prompt-history-store.mjs +129 -0
  175. package/src/tui/prompt-history-store.test.mjs +52 -0
  176. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  177. package/src/tui/theme.mjs +41 -657
  178. package/src/tui/themes/base.mjs +86 -0
  179. package/src/tui/themes/basic.mjs +85 -0
  180. package/src/tui/themes/catppuccin.mjs +72 -0
  181. package/src/tui/themes/dracula.mjs +70 -0
  182. package/src/tui/themes/everforest.mjs +71 -0
  183. package/src/tui/themes/gruvbox.mjs +71 -0
  184. package/src/tui/themes/index.mjs +71 -0
  185. package/src/tui/themes/indigo.mjs +78 -0
  186. package/src/tui/themes/kanagawa.mjs +80 -0
  187. package/src/tui/themes/light.mjs +81 -0
  188. package/src/tui/themes/nord.mjs +72 -0
  189. package/src/tui/themes/onedark.mjs +16 -0
  190. package/src/tui/themes/rosepine.mjs +70 -0
  191. package/src/tui/themes/teal.mjs +81 -0
  192. package/src/tui/themes/tokyonight.mjs +79 -0
  193. package/src/tui/themes/utils.mjs +106 -0
  194. package/src/tui/themes/warm.mjs +79 -0
  195. package/src/tui/transcript-tool-failures.mjs +13 -2
  196. package/src/ui/markdown.mjs +1 -1
  197. package/src/ui/model-display.mjs +2 -2
  198. package/src/ui/statusline.mjs +26 -27
  199. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  200. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  201. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  202. package/src/workflows/default/WORKFLOW.md +39 -12
  203. package/src/workflows/sequential/WORKFLOW.md +46 -0
  204. package/src/workflows/solo/WORKFLOW.md +7 -0
  205. package/vendor/ink/build/display-width.js +62 -0
  206. package/vendor/ink/build/ink.js +100 -12
  207. package/vendor/ink/build/measure-text.js +4 -1
  208. package/vendor/ink/build/output.js +115 -9
  209. package/vendor/ink/build/render-node-to-output.js +4 -1
  210. package/vendor/ink/build/render.js +4 -0
  211. package/src/output-styles/extreme-simple.md +0 -20
  212. package/src/rules/lead/04-workflow.md +0 -51
  213. package/src/workflows/default/workflow.json +0 -13
  214. package/src/workflows/solo/workflow.json +0 -7
@@ -29,13 +29,17 @@ import {
29
29
  traceAgentUsage,
30
30
  } from '../agent-trace.mjs';
31
31
  import {
32
- PROVIDER_GENERATE_TOTAL_TIMEOUT_MS,
33
32
  PROVIDER_HTTP_RESPONSE_TIMEOUT_MS,
33
+ PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
34
+ PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
35
+ streamStalledError,
34
36
  createTimeoutSignal,
37
+ createPassthroughSignal,
35
38
  } from '../stall-policy.mjs';
36
39
  import { populateHttpStatusFromMessage, shouldFallbackTransport } from './retry-classifier.mjs';
37
40
  import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
38
41
  import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
42
+ import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
39
43
  import {
40
44
  normalizeContentForOpenAIResponses,
41
45
  splitToolContentForOpenAIResponses,
@@ -160,6 +164,17 @@ function _displayCodexModel(id) {
160
164
  return id.replace(/-\d{4}-\d{2}-\d{2}$/, '');
161
165
  }
162
166
 
167
+ function _positiveCodexContextWindow(value) {
168
+ const n = Number(value);
169
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
170
+ }
171
+
172
+ function _codexContextWindowFromApi(m) {
173
+ return _positiveCodexContextWindow(m?.context_window)
174
+ || _positiveCodexContextWindow(m?.max_context_window)
175
+ || null;
176
+ }
177
+
163
178
  function _normalizeCodexModel(m) {
164
179
  const id = m?.slug || m?.id;
165
180
  const family = _codexFamily(id);
@@ -182,8 +197,8 @@ function _normalizeCodexModel(m) {
182
197
  display: m?.display_name || id,
183
198
  family,
184
199
  provider: 'openai-oauth',
185
- contextWindow: m?.context_window || m?.max_context_window || 1000000,
186
- maxContextWindow: m?.max_context_window || null,
200
+ contextWindow: _codexContextWindowFromApi(m),
201
+ maxContextWindow: _positiveCodexContextWindow(m?.max_context_window),
187
202
  outputTokens: m?.max_output_tokens || m?.output_tokens || 32768,
188
203
  autoCompactTokenLimit: m?.auto_compact_token_limit || null,
189
204
  effectiveContextWindowPercent: m?.effective_context_window_percent || null,
@@ -773,11 +788,20 @@ export async function sendViaHttpSse({
773
788
  useModel,
774
789
  fetchFn = fetch,
775
790
  } = {}) {
776
- const totalTimeout = createTimeoutSignal(
777
- externalSignal,
778
- PROVIDER_GENERATE_TOTAL_TIMEOUT_MS,
779
- 'OpenAI OAuth HTTP fallback total',
780
- );
791
+ // P1 audit fix: no fixed wall-clock total cap on the HTTP/SSE fallback
792
+ // stream. The old createTimeoutSignal(..., PROVIDER_GENERATE_TOTAL_TIMEOUT_MS)
793
+ // killed a healthy, still-streaming turn purely on elapsed time, unlike
794
+ // every other streaming provider path (anthropic-oauth uses the same
795
+ // createPassthroughSignal pattern — see anthropic-oauth.mjs "Option A").
796
+ // The stream is bounded instead by:
797
+ // (a) headerTimeout below (PROVIDER_HTTP_RESPONSE_TIMEOUT_MS) for a
798
+ // socket that never sends the initial response,
799
+ // (b) the SEMANTIC idle watchdog (_armSemanticIdle /
800
+ // PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS), which resets on every
801
+ // meaningful() chunk — a live stream stays alive, a truly silent
802
+ // one still aborts, and
803
+ // (c) externalSignal (client disconnect / replaced-by-newer-request).
804
+ const totalTimeout = createPassthroughSignal(externalSignal);
781
805
  const headerTimeout = createTimeoutSignal(
782
806
  totalTimeout.signal,
783
807
  PROVIDER_HTTP_RESPONSE_TIMEOUT_MS,
@@ -851,6 +875,33 @@ export async function sendViaHttpSse({
851
875
  if (totalTimeout.signal.aborted) _onTotalAbort();
852
876
  else totalTimeout.signal.addEventListener('abort', _onTotalAbort, { once: true });
853
877
  }
878
+ // SEMANTIC idle watchdog: reset ONLY on meaningful() (text/reasoning/tool
879
+ // deltas), never on raw bytes/keepalive frames, so a stream that emits some
880
+ // deltas then goes silent trips a short, named terminal failure instead of
881
+ // hanging until the 30-min agent watchdog. Disablable via the shared env.
882
+ let _semanticIdleTimer = null;
883
+ const _clearSemanticIdle = () => {
884
+ if (_semanticIdleTimer) { clearTimeout(_semanticIdleTimer); _semanticIdleTimer = null; }
885
+ };
886
+ const _armSemanticIdle = () => {
887
+ if (!PROVIDER_SSE_IDLE_WATCHDOG_ENABLED || !(PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS > 0)) return;
888
+ _clearSemanticIdle();
889
+ _semanticIdleTimer = setTimeout(() => {
890
+ _streamAbortReason = streamStalledError('OpenAI OAuth HTTP fallback', PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS, { emittedToolCall: emittedToolCallIds.size > 0 });
891
+ // Partial-final recovery parity with anthropic-oauth: attach the
892
+ // streamed partial state so the agent loop can accept a wedged FINAL
893
+ // no-tool summary as a successful partial-final instead of dropping
894
+ // the result. pendingToolUse gates out any mid-flight tool call.
895
+ try {
896
+ _streamAbortReason.partialContent = content;
897
+ _streamAbortReason.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
898
+ _streamAbortReason.pendingToolUse = pendingCalls.size > 0 || emittedToolCallIds.size > 0;
899
+ _streamAbortReason.partialModel = model || undefined;
900
+ } catch { /* best-effort enrichment */ }
901
+ try { reader.cancel(_streamAbortReason).catch(() => {}); } catch {}
902
+ }, PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS);
903
+ try { _semanticIdleTimer.unref?.(); } catch {}
904
+ };
854
905
  let buffer = '';
855
906
  let content = '';
856
907
  let model = '';
@@ -885,13 +936,75 @@ export async function sendViaHttpSse({
885
936
  // first complete frame still emits; only redundant re-emits are
886
937
  // suppressed.
887
938
  const emittedToolCallIds = new Set();
939
+ // Fix 2: cross-path name+args dedupe. A text-leaked synthetic and an
940
+ // identical native function_call must fire onToolCall exactly once.
941
+ const _toolDedupe = createToolCallDedupe();
888
942
  const emitToolCall = (call) => {
889
943
  if (!call || !call.id) return;
890
944
  if (emittedToolCallIds.has(call.id)) return;
891
945
  emittedToolCallIds.add(call.id);
946
+ if (!_toolDedupe.shouldDispatch(call.name, call.arguments)) return;
892
947
  try { onToolCall?.(call); } catch {}
893
948
  };
894
949
 
950
+ // Leaked tool-call guard. The model sometimes emits a tool call as plain
951
+ // text (XML `<invoke>`/`<function_calls>` or gpt-oss harmony
952
+ // `<|channel|>...to=functions.NAME...<|call|>`) inside
953
+ // `response.output_text.delta` instead of a native function_call. Route
954
+ // text through the guard so leaked calls are suppressed from the visible
955
+ // stream, synthesized (native `call_...` id shape), and dispatched like
956
+ // native ones. Known tool names come from the request body so recovery
957
+ // only fires for tools the model was actually offered. Additive: the
958
+ // native function_call path is untouched.
959
+ const _leakKnownTools = new Set(
960
+ (Array.isArray(body?.tools) ? body.tools : [])
961
+ .map((t) => (typeof t?.name === 'string' ? t.name : null))
962
+ .filter(Boolean),
963
+ );
964
+ const leakGuard = createLeakGuard({ knownToolNames: _leakKnownTools, harmony: true });
965
+ const dispatchLeakedCall = (recovered) => {
966
+ let args = recovered?.arguments;
967
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) args = {};
968
+ const call = {
969
+ id: `call_leaked_${randomBytes(8).toString('hex')}`,
970
+ name: recovered.name,
971
+ arguments: args,
972
+ };
973
+ toolCalls.push(call);
974
+ emitToolCall(call);
975
+ };
976
+ const relayLeakText = (delta) => {
977
+ if (!leakGuard.enabled) {
978
+ content += delta || '';
979
+ if (delta && onTextDelta) {
980
+ emittedText = true;
981
+ try { onTextDelta(delta); } catch {}
982
+ }
983
+ return;
984
+ }
985
+ const { text, calls } = leakGuard.push(delta);
986
+ if (text) {
987
+ content += text;
988
+ if (onTextDelta) {
989
+ emittedText = true;
990
+ try { onTextDelta(text); } catch {}
991
+ }
992
+ }
993
+ for (const c of calls) dispatchLeakedCall(c);
994
+ };
995
+ const flushLeak = () => {
996
+ if (!leakGuard.enabled) return;
997
+ const { text, calls } = leakGuard.flush();
998
+ if (text) {
999
+ content += text;
1000
+ if (onTextDelta) {
1001
+ emittedText = true;
1002
+ try { onTextDelta(text); } catch {}
1003
+ }
1004
+ }
1005
+ for (const c of calls) dispatchLeakedCall(c);
1006
+ };
1007
+
895
1008
  const pushWebSearchCall = (item) => {
896
1009
  if (!item || item.type !== 'web_search_call') return;
897
1010
  const key = item.id || JSON.stringify(item.action || item);
@@ -939,6 +1052,7 @@ export async function sendViaHttpSse({
939
1052
  };
940
1053
  const meaningful = () => {
941
1054
  if (ttftMs == null) ttftMs = Date.now() - sseStartedAt;
1055
+ _armSemanticIdle();
942
1056
  try { onStreamDelta?.(); } catch {}
943
1057
  };
944
1058
  const handleEvent = (event) => {
@@ -949,12 +1063,8 @@ export async function sendViaHttpSse({
949
1063
  if (event.response?.id) responseId = event.response.id;
950
1064
  break;
951
1065
  case 'response.output_text.delta':
952
- content += event.delta || '';
953
1066
  meaningful();
954
- if (event.delta && onTextDelta) {
955
- emittedText = true;
956
- try { onTextDelta(event.delta); } catch {}
957
- }
1067
+ relayLeakText(event.delta || '');
958
1068
  break;
959
1069
  case 'response.reasoning_text.delta':
960
1070
  case 'response.reasoning_summary_text.delta':
@@ -1030,7 +1140,20 @@ export async function sendViaHttpSse({
1030
1140
  for (const item of resp.output || []) {
1031
1141
  if (item.type === 'message') {
1032
1142
  for (const part of item.content || []) {
1033
- if (!content && part.type === 'output_text') content += part.text || '';
1143
+ if (!content && part.type === 'output_text') {
1144
+ // Completed-output fallback (no streamed text).
1145
+ // Route through the leak guard so a tool call
1146
+ // leaked only in the final bundle is recovered
1147
+ // rather than surfaced as visible content. push
1148
+ // with final=true flushes fully (no held tail).
1149
+ if (leakGuard.enabled) {
1150
+ const { text, calls } = leakGuard.push(part.text || '', true);
1151
+ content += text;
1152
+ for (const c of calls) dispatchLeakedCall(c);
1153
+ } else {
1154
+ content += part.text || '';
1155
+ }
1156
+ }
1034
1157
  if (part.type === 'output_text') _pushOutputTextAnnotations(part, citations, citationKeys);
1035
1158
  }
1036
1159
  } else if (item.type === 'reasoning') {
@@ -1116,10 +1239,12 @@ export async function sendViaHttpSse({
1116
1239
 
1117
1240
  try {
1118
1241
  while (true) {
1119
- if (totalTimeout.signal.aborted) {
1242
+ if (totalTimeout.signal?.aborted) {
1243
+ _clearSemanticIdle();
1120
1244
  const reason = totalTimeout.signal.reason;
1121
1245
  throw reason instanceof Error ? reason : new Error('OpenAI OAuth HTTP fallback aborted');
1122
1246
  }
1247
+ if (_streamAbortReason) throw _streamAbortReason;
1123
1248
  const { value, done } = await reader.read();
1124
1249
  if (done) break;
1125
1250
  buffer += decoder.decode(value, { stream: true });
@@ -1140,12 +1265,16 @@ export async function sendViaHttpSse({
1140
1265
  const event = _parseSseFrame(frame);
1141
1266
  if (event) handleEvent(event);
1142
1267
  }
1268
+ // Flush any partial-sentinel tail held back mid-stream so legitimate
1269
+ // trailing text is never lost (streamed-text path).
1270
+ flushLeak();
1143
1271
  } catch (err) {
1144
1272
  // Live-text invariant: once a non-empty chunk has been relayed it
1145
1273
  // cannot be withdrawn — flag the error so no upstream layer retries.
1146
1274
  if (emittedText && err) { try { err.liveTextEmitted = true; err.unsafeToRetry = true; } catch {} }
1147
1275
  throw err;
1148
1276
  } finally {
1277
+ _clearSemanticIdle();
1149
1278
  try { reader.releaseLock?.(); } catch {}
1150
1279
  if (_onTotalAbort && totalTimeout.signal) {
1151
1280
  try { totalTimeout.signal.removeEventListener('abort', _onTotalAbort); } catch {}
@@ -1186,15 +1315,27 @@ export async function sendViaHttpSse({
1186
1315
  serviceTier,
1187
1316
  });
1188
1317
  }
1318
+ // Dedupe the returned array by name+args (Fix 2, array side): a synthetic
1319
+ // leaked call and an identical native function_call must not both survive,
1320
+ // else the agent loop executes the side-effecting tool twice.
1321
+ const _returnedToolCalls = toolCalls.length
1322
+ ? dedupeToolCallList(toolCalls.map(({ _pendingItemId, ...t }) => t))
1323
+ : undefined;
1189
1324
  return {
1190
1325
  content,
1191
1326
  model: liveModel,
1192
1327
  reasoningItems: reasoningItems.length ? reasoningItems : undefined,
1193
- toolCalls: toolCalls.length ? toolCalls.map(({ _pendingItemId, ...t }) => t) : undefined,
1328
+ toolCalls: _returnedToolCalls,
1194
1329
  citations: citations.length ? citations : undefined,
1195
1330
  webSearchCalls: webSearchCalls.length ? webSearchCalls : undefined,
1196
1331
  usage: usage || undefined,
1197
1332
  stopReason: stopReason || undefined,
1333
+ // P1 audit fix: text-only max-output cutoff (openai-oauth HTTP/SSE
1334
+ // fallback maps status:'incomplete'/reason=max_output_tokens to
1335
+ // stopReason='length' above and treats it as success). Flag it so
1336
+ // loop.mjs can surface a truncation warning instead of accepting
1337
+ // silently-cut content as a clean final answer.
1338
+ ...(stopReason === 'length' && content.length > 0 ? { truncated: true } : {}),
1198
1339
  responseId: responseId || undefined,
1199
1340
  serviceTier: serviceTier || undefined,
1200
1341
  };
@@ -1328,8 +1469,8 @@ export class OpenAIOAuthProvider {
1328
1469
  const onTextDelta = typeof opts.onTextDelta === 'function' ? opts.onTextDelta : null;
1329
1470
  const externalSignal = opts.signal || null;
1330
1471
  const _sendSessionId = opts.sessionId || '(none)';
1331
- const _sendRole = opts.role || '(none)';
1332
- if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] auth-start sessionHash=${createHash('sha256').update(String(_sendSessionId)).digest('hex').slice(0, 8)} role=${_sendRole} expiringInMs=${this.tokens?.expires_at ? this.tokens.expires_at - Date.now() : 'unknown'}\n`); }
1472
+ const _sendAgent = opts.agent || '(none)';
1473
+ if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] auth-start sessionHash=${createHash('sha256').update(String(_sendSessionId)).digest('hex').slice(0, 8)} agent=${_sendAgent} expiringInMs=${this.tokens?.expires_at ? this.tokens.expires_at - Date.now() : 'unknown'}\n`); }
1333
1474
  // Build request body in parallel with auth resolution. ensureAuth is
1334
1475
  // a no-op fast-path on cached tokens, but a refresh round-trip can
1335
1476
  // take 300ms+; the body build (message serialisation) overlaps cleanly.
@@ -1389,6 +1530,26 @@ export class OpenAIOAuthProvider {
1389
1530
  this._forceHttpFallback = true;
1390
1531
  this._forceHttpFallbackUntil = Date.now() + ttlMs;
1391
1532
  };
1533
+ const traceWsError = (err, stage = 'primary') => {
1534
+ try {
1535
+ appendAgentTrace({
1536
+ sessionId: poolKey,
1537
+ iteration,
1538
+ kind: 'transport_error',
1539
+ provider: 'openai-oauth',
1540
+ model: useModel,
1541
+ transport: 'websocket',
1542
+ payload: {
1543
+ stage,
1544
+ error_code: err?.code || null,
1545
+ error_http_status: Number(err?.httpStatus || 0) || null,
1546
+ error_classifier: err?.retryClassifier || err?.midstreamClassifier || null,
1547
+ live_text_emitted: err?.liveTextEmitted === true || err?.unsafeToRetry === true,
1548
+ message: String(err?.message || err || '').slice(0, 500),
1549
+ },
1550
+ });
1551
+ } catch {}
1552
+ };
1392
1553
  const dispatchHttp = async (reason, originalErr = null, { sticky = false } = {}) => {
1393
1554
  appendAgentTrace({
1394
1555
  sessionId: poolKey,
@@ -1411,7 +1572,7 @@ export class OpenAIOAuthProvider {
1411
1572
  process.stderr.write('[openai-oauth] WebSocket bypassed (forced); using HTTP/SSE\n');
1412
1573
  }
1413
1574
  } else {
1414
- process.stderr.write(`[openai-oauth] WebSocket unhealthy (${reason}); falling back to HTTP/SSE\n`);
1575
+ if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(`[openai-oauth] WebSocket unhealthy (${reason}); falling back to HTTP/SSE\n`);
1415
1576
  }
1416
1577
  const result = await sendHttp({
1417
1578
  auth,
@@ -1459,11 +1620,12 @@ export class OpenAIOAuthProvider {
1459
1620
  // Prefer WebSocket for hot cache/delta transport; fall back to HTTP/SSE
1460
1621
  // after retry-exhausted handshake/acquire/no-first-event failures.
1461
1622
  try {
1462
- if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] provider-send-start model=${useModel} role=${_sendRole} sessionHash=${createHash('sha256').update(String(_sendSessionId)).digest('hex').slice(0, 8)} iteration=${iteration ?? '(none)'}\n`); }
1623
+ if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] provider-send-start model=${useModel} agent=${_sendAgent} sessionHash=${createHash('sha256').update(String(_sendSessionId)).digest('hex').slice(0, 8)} iteration=${iteration ?? '(none)'}\n`); }
1463
1624
  const result = await dispatchWs(false);
1464
1625
  if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] provider-send-end elapsed=${Date.now() - _t1}ms result=ok\n`); }
1465
1626
  return recordLiveModel(result);
1466
1627
  } catch (err) {
1628
+ traceWsError(err, 'primary');
1467
1629
  const status = err?.httpStatus;
1468
1630
  // Live-text invariant: if the WS attempt already relayed a
1469
1631
  // non-empty text chunk to the client, NO recovery path may reissue
@@ -1482,6 +1644,7 @@ export class OpenAIOAuthProvider {
1482
1644
  if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] provider-send-end elapsed=${Date.now() - _t1}ms result=ok\n`); }
1483
1645
  return recordLiveModel(result);
1484
1646
  } catch (retryErr) {
1647
+ traceWsError(retryErr, 'auth_retry');
1485
1648
  if (_shouldUseOpenAIHttpFallback(retryErr, externalSignal)) {
1486
1649
  try {
1487
1650
  return await dispatchHttp(
@@ -71,6 +71,24 @@ export class OpenAIDirectProvider {
71
71
  // so gpt-5.4-mini can opt into Priority even when the OAuth catalog does
72
72
  // not advertise a Fast tier for its OAuth endpoint.
73
73
  applyOpenAIDirectFastTier(body, useModel, opts);
74
+ // P0 audit fix: buildRequestBody (openai-oauth.mjs) defaults
75
+ // store:false (env-gated, MIXDOG_OAI_STORE), which is correct for
76
+ // the openai-oauth ChatGPT-subscription backend — that backend keeps
77
+ // its own conversation state via the WS handshake session_id
78
+ // (see openai-oauth-ws.mjs "conversation slot ... in-memory prefix
79
+ // state"), independent of the public Responses API `store` field.
80
+ // The public OpenAI direct WS path below, however, talks to the real
81
+ // api.openai.com Responses API, where `previous_response_id`
82
+ // continuation is only valid when the anchored response was actually
83
+ // stored — store:false + previous_response_id is a broken
84
+ // combination there (the server has nothing to look up). This
85
+ // provider's WS transport always injects previous_response_id via
86
+ // openai-oauth-ws.mjs's delta path once a response id is cached, so
87
+ // force store:true here — same override xAI's Responses path takes
88
+ // (see openai-compat.mjs _doSendXaiResponses/_doSendXaiResponsesWebSocket:
89
+ // "the public endpoint currently returns previous_response_not_found
90
+ // ... unless the chain is stored").
91
+ body.store = true;
74
92
  // Public Responses API supports prompt_cache_retention='24h' at no
75
93
  // extra cost (same cached_input_tokens billing as the default 5–10
76
94
  // min in-memory cache). openai-oauth rejects the parameter, so it's
@@ -1,6 +1,6 @@
1
- import { existsSync, readFileSync, mkdirSync } from 'node:fs';
2
- import * as fsp from 'node:fs/promises';
1
+ import { existsSync, readFileSync } from 'node:fs';
3
2
  import { join } from 'node:path';
3
+ import { updateJsonAtomicSync } from '../../../shared/atomic-file.mjs';
4
4
  import { resolvePluginData } from '../../../shared/plugin-paths.mjs';
5
5
  import { getOpenCodeGoAuthCookie } from '../../../shared/config.mjs';
6
6
 
@@ -53,12 +53,18 @@ function readJson(file) {
53
53
  }
54
54
  }
55
55
 
56
+ // Synchronous atomic+lock write (updateJsonAtomicSync) instead of the prior
57
+ // fire-and-forget fsp.writeFile: this cache is single-entry (one snapshot
58
+ // per file, no cross-process merge), so the lock protects against a torn
59
+ // write racing readers, not a lost-update merge. Only one write happens
60
+ // per successful fetch (TTL-gated, at most once per LIVE_TTL_MS), so the
61
+ // switch off async has no meaningful latency impact on the request path.
56
62
  function writeJson(file, value) {
57
- diskJsonCache = { at: Date.now(), file, value };
63
+ let next = null;
58
64
  try {
59
- mkdirSync(resolvePluginData(), { recursive: true });
60
- void fsp.writeFile(file, JSON.stringify(value, null, 2), 'utf8').catch(() => {});
65
+ next = updateJsonAtomicSync(file, () => value, { lock: true, fsyncDir: true, timeoutMs: 1000 }); // best-effort cache write: short lock timeout, don't block on contention
61
66
  } catch {}
67
+ if (next) diskJsonCache = { at: Date.now(), file, value: next }; // only mirror on confirmed write, avoid phantom cache on lock timeout
62
68
  }
63
69
 
64
70
  function freshSnapshot(snapshot, ttlMs) {
@@ -201,7 +201,8 @@ export function getProvider(name) {
201
201
  // unregistered providers default to false (the openai/gemini majority).
202
202
  export function providerInputExcludesCache(name) {
203
203
  const p = getProvider(name);
204
- return p?.constructor?.inputExcludesCache === true;
204
+ if (p?.constructor?.inputExcludesCache === true) return true;
205
+ return String(name || '').toLowerCase().includes('anthropic');
205
206
  }
206
207
  export function getAllProviders() {
207
208
  // Defensive copy — callers must not mutate the live registry or retain
@@ -126,7 +126,7 @@ export function classifyError(err) {
126
126
  || code === 'EAI_AGAIN' || code === 'ENOTFOUND' || code === 'EAI_NODATA'
127
127
  || code === 'ECONNREFUSED' || code === 'ENETUNREACH' || code === 'EHOSTUNREACH'
128
128
  || code === 'EPIPE'
129
- || code === 'EPROVIDERTIMEOUT' || code === 'EGEMINITIMEOUT'
129
+ || code === 'EPROVIDERTIMEOUT' || code === 'EGEMINITIMEOUT' || code === 'ESTREAMSTALL'
130
130
  || code === 'EWSACQUIRETIMEOUT') {
131
131
  return 'transient'
132
132
  }
@@ -282,7 +282,7 @@ export function classifyMidstreamError(err, signals, policy = {}) {
282
282
 
283
283
  // Verbatim relocation of openai-oauth-ws's _classifyMidstreamError. `signals`
284
284
  // carries sawResponseCreated / sawCompleted / emittedText / emittedToolCall /
285
- // wsCloseCode / firstByteTimeout / firstMeaningfulTimeout / wsSendFailed /
285
+ // wsCloseCode / firstByteTimeout / wsSendFailed /
286
286
  // userAbort / watchdogAbort / responseFailedPayload exactly as before.
287
287
  function _classifyMidstreamWs(err, state, attemptIndex, policy) {
288
288
  if (state.sawCompleted) return null
@@ -294,9 +294,6 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
294
294
  if (state.firstByteTimeout || err?.firstByteTimeout) {
295
295
  return _allowMidstream('first_byte_timeout', attemptIndex, policy)
296
296
  }
297
- if (state.firstMeaningfulTimeout || err?.firstMeaningfulTimeout) {
298
- return _allowMidstream('first_meaningful_timeout', attemptIndex, policy)
299
- }
300
297
  if (err?.wsSendFailed || state.wsSendFailed) {
301
298
  return _allowMidstream('ws_send_failed', attemptIndex, policy)
302
299
  }
@@ -315,7 +312,12 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
315
312
 
316
313
  const name = err?.name || ''
317
314
  if (name === 'AgentStallAbortError') return _allowMidstream('agent_stall', attemptIndex, policy)
318
- if (name === 'StreamStalledAbortError') return _allowMidstream('stream_stalled', attemptIndex, policy)
315
+ if (name === 'StreamStalledAbortError' || name === 'StreamStalledError' || err?.code === 'ESTREAMSTALL' || err?.streamStalled === true) {
316
+ // A stall that fired AFTER a tool call was emitted is unsafe to replay
317
+ // (double side-effect); surface it as terminal so the turn is not retried.
318
+ if (err?.unsafeToRetry === true) return null
319
+ return _allowMidstream('stream_stalled', attemptIndex, policy)
320
+ }
319
321
 
320
322
  if (state.watchdogAbort === 'AgentStallAbortError') return _allowMidstream('agent_stall', attemptIndex, policy)
321
323
  if (state.watchdogAbort === 'StreamStalledAbortError') return _allowMidstream('stream_stalled', attemptIndex, policy)
@@ -357,7 +359,12 @@ function _classifyMidstreamSse(err, state, attemptIndex, policy) {
357
359
 
358
360
  const name = err?.name || ''
359
361
  if (name === 'AgentStallAbortError') return 'agent_stall'
360
- if (name === 'StreamStalledAbortError') return 'stream_stalled'
362
+ if (name === 'StreamStalledAbortError' || name === 'StreamStalledError' || err?.code === 'ESTREAMSTALL' || err?.streamStalled === true) {
363
+ // A stall AFTER a tool emit is unsafe to replay (double side-effect) →
364
+ // terminal (null), no mid-stream retry. Otherwise route to stream_stalled.
365
+ if (err?.unsafeToRetry === true) return null
366
+ return 'stream_stalled'
367
+ }
361
368
  if (state.watchdogAbort === 'AgentStallAbortError') return 'agent_stall'
362
369
  if (state.watchdogAbort === 'StreamStalledAbortError') return 'stream_stalled'
363
370
 
@@ -379,7 +386,7 @@ function _classifyMidstreamSse(err, state, attemptIndex, policy) {
379
386
  // env-flag check (caller computes the flag and passes it).
380
387
  const TRANSPORT_FALLBACK_CLASSIFIERS = new Set([
381
388
  'timeout', 'reset', 'dns', 'refused', 'network', 'acquire_timeout', 'http_5xx',
382
- 'first_byte_timeout', 'first_meaningful_timeout',
389
+ 'first_byte_timeout',
383
390
  'ws_1006', 'ws_1011', 'ws_1012', 'ws_1000', 'ws_4000', 'agent_stall', 'stream_stalled',
384
391
  'response_failed_disconnected', 'response_failed_network', 'response_failed_auth_expired',
385
392
  'ws_send_failed',
@@ -403,7 +410,6 @@ export function shouldFallbackTransport(err, { signal, enabled = true } = {}) {
403
410
  if (TRANSPORT_FALLBACK_CLASSIFIERS.has(classifier)) return true
404
411
  if (/^http_5\d\d$/.test(classifier)) return true
405
412
  if (err?.firstByteTimeout) return true
406
- if (err?.firstMeaningfulTimeout) return true
407
413
  const msg = String(err?.message || '')
408
414
  return /opening handshake has timed out|socket hang up|acquire timed out|no first server event|no meaningful output/i.test(msg)
409
415
  }