mixdog 0.9.0 → 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 (240) hide show
  1. package/package.json +10 -3
  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/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. 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
- import { populateHttpStatusFromMessage } from './retry-classifier.mjs';
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,
@@ -742,36 +757,14 @@ function _buildOpenAIHttpFallbackHeaders({ auth, cacheKey }) {
742
757
  return headers;
743
758
  }
744
759
 
760
+ // WS→HTTP/SSE fallback predicate → shared shouldFallbackTransport
761
+ // (retry-classifier.mjs). The per-provider env flag is computed here and passed
762
+ // as `enabled`; the deny-order + allow-list are identical to the former copy.
745
763
  function _shouldUseOpenAIHttpFallback(err, externalSignal) {
746
- if (!_envFlag('MIXDOG_OPENAI_OAUTH_HTTP_FALLBACK', true)) return false;
747
- if (externalSignal?.aborted) return false;
748
- // Live-text invariant: if the WS attempt already relayed a non-empty text
749
- // chunk to the client, the HTTP fallback would re-run the request and
750
- // concatenate a second attempt onto rendered output. Never fall back.
751
- if (err?.liveTextEmitted === true) return false;
752
- if (err?.emittedToolCall === true || err?.unsafeToRetry === true) return false;
753
- const status = Number(err?.httpStatus || err?.status || 0);
754
- if (status === 401 || status === 403 || status === 404 || status === 429) return false;
755
- if (status >= 500 && status < 600) return true;
756
- const code = String(err?.code || '');
757
- if (['EWSACQUIRETIMEOUT', 'ETIMEDOUT', 'ESOCKETTIMEDOUT', 'ECONNRESET', 'EAI_AGAIN', 'ENOTFOUND', 'EAI_NODATA', 'ECONNREFUSED', 'ENETUNREACH', 'EHOSTUNREACH', 'EPIPE'].includes(code)) {
758
- return true;
759
- }
760
- const classifier = String(err?.retryClassifier || err?.midstreamClassifier || '');
761
- if ([
762
- 'timeout', 'reset', 'dns', 'refused', 'network', 'acquire_timeout', 'http_5xx',
763
- 'first_byte_timeout', 'first_meaningful_timeout',
764
- 'ws_1006', 'ws_1011', 'ws_1012', 'ws_1000', 'ws_4000', 'agent_stall', 'stream_stalled',
765
- 'response_failed_disconnected', 'response_failed_network', 'response_failed_auth_expired',
766
- 'ws_send_failed',
767
- ].includes(classifier)) {
768
- return true;
769
- }
770
- if (/^http_5\d\d$/.test(classifier)) return true;
771
- if (err?.firstByteTimeout) return true;
772
- if (err?.firstMeaningfulTimeout) return true;
773
- const msg = String(err?.message || '');
774
- return /opening handshake has timed out|socket hang up|acquire timed out|no first server event|no meaningful output/i.test(msg);
764
+ return shouldFallbackTransport(err, {
765
+ signal: externalSignal,
766
+ enabled: _envFlag('MIXDOG_OPENAI_OAUTH_HTTP_FALLBACK', true),
767
+ });
775
768
  }
776
769
 
777
770
  // Exported for the single-emit regression smoke (scripts/openai-oauth-
@@ -795,11 +788,20 @@ export async function sendViaHttpSse({
795
788
  useModel,
796
789
  fetchFn = fetch,
797
790
  } = {}) {
798
- const totalTimeout = createTimeoutSignal(
799
- externalSignal,
800
- PROVIDER_GENERATE_TOTAL_TIMEOUT_MS,
801
- 'OpenAI OAuth HTTP fallback total',
802
- );
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);
803
805
  const headerTimeout = createTimeoutSignal(
804
806
  totalTimeout.signal,
805
807
  PROVIDER_HTTP_RESPONSE_TIMEOUT_MS,
@@ -873,6 +875,33 @@ export async function sendViaHttpSse({
873
875
  if (totalTimeout.signal.aborted) _onTotalAbort();
874
876
  else totalTimeout.signal.addEventListener('abort', _onTotalAbort, { once: true });
875
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
+ };
876
905
  let buffer = '';
877
906
  let content = '';
878
907
  let model = '';
@@ -907,13 +936,75 @@ export async function sendViaHttpSse({
907
936
  // first complete frame still emits; only redundant re-emits are
908
937
  // suppressed.
909
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();
910
942
  const emitToolCall = (call) => {
911
943
  if (!call || !call.id) return;
912
944
  if (emittedToolCallIds.has(call.id)) return;
913
945
  emittedToolCallIds.add(call.id);
946
+ if (!_toolDedupe.shouldDispatch(call.name, call.arguments)) return;
914
947
  try { onToolCall?.(call); } catch {}
915
948
  };
916
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
+
917
1008
  const pushWebSearchCall = (item) => {
918
1009
  if (!item || item.type !== 'web_search_call') return;
919
1010
  const key = item.id || JSON.stringify(item.action || item);
@@ -961,6 +1052,7 @@ export async function sendViaHttpSse({
961
1052
  };
962
1053
  const meaningful = () => {
963
1054
  if (ttftMs == null) ttftMs = Date.now() - sseStartedAt;
1055
+ _armSemanticIdle();
964
1056
  try { onStreamDelta?.(); } catch {}
965
1057
  };
966
1058
  const handleEvent = (event) => {
@@ -971,12 +1063,8 @@ export async function sendViaHttpSse({
971
1063
  if (event.response?.id) responseId = event.response.id;
972
1064
  break;
973
1065
  case 'response.output_text.delta':
974
- content += event.delta || '';
975
1066
  meaningful();
976
- if (event.delta && onTextDelta) {
977
- emittedText = true;
978
- try { onTextDelta(event.delta); } catch {}
979
- }
1067
+ relayLeakText(event.delta || '');
980
1068
  break;
981
1069
  case 'response.reasoning_text.delta':
982
1070
  case 'response.reasoning_summary_text.delta':
@@ -1052,7 +1140,20 @@ export async function sendViaHttpSse({
1052
1140
  for (const item of resp.output || []) {
1053
1141
  if (item.type === 'message') {
1054
1142
  for (const part of item.content || []) {
1055
- 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
+ }
1056
1157
  if (part.type === 'output_text') _pushOutputTextAnnotations(part, citations, citationKeys);
1057
1158
  }
1058
1159
  } else if (item.type === 'reasoning') {
@@ -1138,10 +1239,12 @@ export async function sendViaHttpSse({
1138
1239
 
1139
1240
  try {
1140
1241
  while (true) {
1141
- if (totalTimeout.signal.aborted) {
1242
+ if (totalTimeout.signal?.aborted) {
1243
+ _clearSemanticIdle();
1142
1244
  const reason = totalTimeout.signal.reason;
1143
1245
  throw reason instanceof Error ? reason : new Error('OpenAI OAuth HTTP fallback aborted');
1144
1246
  }
1247
+ if (_streamAbortReason) throw _streamAbortReason;
1145
1248
  const { value, done } = await reader.read();
1146
1249
  if (done) break;
1147
1250
  buffer += decoder.decode(value, { stream: true });
@@ -1162,12 +1265,16 @@ export async function sendViaHttpSse({
1162
1265
  const event = _parseSseFrame(frame);
1163
1266
  if (event) handleEvent(event);
1164
1267
  }
1268
+ // Flush any partial-sentinel tail held back mid-stream so legitimate
1269
+ // trailing text is never lost (streamed-text path).
1270
+ flushLeak();
1165
1271
  } catch (err) {
1166
1272
  // Live-text invariant: once a non-empty chunk has been relayed it
1167
1273
  // cannot be withdrawn — flag the error so no upstream layer retries.
1168
1274
  if (emittedText && err) { try { err.liveTextEmitted = true; err.unsafeToRetry = true; } catch {} }
1169
1275
  throw err;
1170
1276
  } finally {
1277
+ _clearSemanticIdle();
1171
1278
  try { reader.releaseLock?.(); } catch {}
1172
1279
  if (_onTotalAbort && totalTimeout.signal) {
1173
1280
  try { totalTimeout.signal.removeEventListener('abort', _onTotalAbort); } catch {}
@@ -1208,15 +1315,27 @@ export async function sendViaHttpSse({
1208
1315
  serviceTier,
1209
1316
  });
1210
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;
1211
1324
  return {
1212
1325
  content,
1213
1326
  model: liveModel,
1214
1327
  reasoningItems: reasoningItems.length ? reasoningItems : undefined,
1215
- toolCalls: toolCalls.length ? toolCalls.map(({ _pendingItemId, ...t }) => t) : undefined,
1328
+ toolCalls: _returnedToolCalls,
1216
1329
  citations: citations.length ? citations : undefined,
1217
1330
  webSearchCalls: webSearchCalls.length ? webSearchCalls : undefined,
1218
1331
  usage: usage || undefined,
1219
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 } : {}),
1220
1339
  responseId: responseId || undefined,
1221
1340
  serviceTier: serviceTier || undefined,
1222
1341
  };
@@ -1350,8 +1469,8 @@ export class OpenAIOAuthProvider {
1350
1469
  const onTextDelta = typeof opts.onTextDelta === 'function' ? opts.onTextDelta : null;
1351
1470
  const externalSignal = opts.signal || null;
1352
1471
  const _sendSessionId = opts.sessionId || '(none)';
1353
- const _sendRole = opts.role || '(none)';
1354
- 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`); }
1355
1474
  // Build request body in parallel with auth resolution. ensureAuth is
1356
1475
  // a no-op fast-path on cached tokens, but a refresh round-trip can
1357
1476
  // take 300ms+; the body build (message serialisation) overlaps cleanly.
@@ -1411,6 +1530,26 @@ export class OpenAIOAuthProvider {
1411
1530
  this._forceHttpFallback = true;
1412
1531
  this._forceHttpFallbackUntil = Date.now() + ttlMs;
1413
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
+ };
1414
1553
  const dispatchHttp = async (reason, originalErr = null, { sticky = false } = {}) => {
1415
1554
  appendAgentTrace({
1416
1555
  sessionId: poolKey,
@@ -1433,7 +1572,7 @@ export class OpenAIOAuthProvider {
1433
1572
  process.stderr.write('[openai-oauth] WebSocket bypassed (forced); using HTTP/SSE\n');
1434
1573
  }
1435
1574
  } else {
1436
- 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`);
1437
1576
  }
1438
1577
  const result = await sendHttp({
1439
1578
  auth,
@@ -1481,11 +1620,12 @@ export class OpenAIOAuthProvider {
1481
1620
  // Prefer WebSocket for hot cache/delta transport; fall back to HTTP/SSE
1482
1621
  // after retry-exhausted handshake/acquire/no-first-event failures.
1483
1622
  try {
1484
- 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`); }
1485
1624
  const result = await dispatchWs(false);
1486
1625
  if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] provider-send-end elapsed=${Date.now() - _t1}ms result=ok\n`); }
1487
1626
  return recordLiveModel(result);
1488
1627
  } catch (err) {
1628
+ traceWsError(err, 'primary');
1489
1629
  const status = err?.httpStatus;
1490
1630
  // Live-text invariant: if the WS attempt already relayed a
1491
1631
  // non-empty text chunk to the client, NO recovery path may reissue
@@ -1504,6 +1644,7 @@ export class OpenAIOAuthProvider {
1504
1644
  if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] provider-send-end elapsed=${Date.now() - _t1}ms result=ok\n`); }
1505
1645
  return recordLiveModel(result);
1506
1646
  } catch (retryErr) {
1647
+ traceWsError(retryErr, 'auth_retry');
1507
1648
  if (_shouldUseOpenAIHttpFallback(retryErr, externalSignal)) {
1508
1649
  try {
1509
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