mixdog 0.8.1 → 0.9.1

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 (299) hide show
  1. package/README.md +47 -23
  2. package/package.json +34 -28
  3. package/scripts/_test-folder-dialog.mjs +30 -0
  4. package/scripts/agent-parallel-smoke.mjs +388 -0
  5. package/scripts/agent-tag-reuse-smoke.mjs +183 -0
  6. package/scripts/background-task-meta-smoke.mjs +38 -0
  7. package/scripts/boot-smoke.mjs +52 -9
  8. package/scripts/build-runtime-linux.sh +348 -0
  9. package/scripts/build-runtime-macos.sh +217 -0
  10. package/scripts/build-runtime-windows.ps1 +242 -0
  11. package/scripts/compact-active-turn-test.mjs +68 -0
  12. package/scripts/compact-smoke.mjs +859 -129
  13. package/scripts/compact-trigger-migration-smoke.mjs +187 -0
  14. package/scripts/fix-brief-fn.mjs +35 -0
  15. package/scripts/fix-format-tool-surface.mjs +24 -0
  16. package/scripts/fix-tool-exec-visible.mjs +42 -0
  17. package/scripts/generate-runtime-manifest.mjs +166 -0
  18. package/scripts/hook-bus-test.mjs +330 -0
  19. package/scripts/lead-workflow-smoke.mjs +33 -39
  20. package/scripts/live-worker-smoke.mjs +43 -37
  21. package/scripts/llm-trace-summary.mjs +315 -0
  22. package/scripts/memory-meta-concurrency-test.mjs +20 -0
  23. package/scripts/output-style-smoke.mjs +56 -15
  24. package/scripts/parent-abort-link-test.mjs +44 -0
  25. package/scripts/patch-agent-brief.mjs +48 -0
  26. package/scripts/patch-app.mjs +21 -0
  27. package/scripts/patch-app2.mjs +18 -0
  28. package/scripts/patch-dist-brief.mjs +96 -0
  29. package/scripts/patch-tool-exec.mjs +70 -0
  30. package/scripts/pretool-ask-runtime-test.mjs +54 -0
  31. package/scripts/provider-toolcall-test.mjs +376 -0
  32. package/scripts/reactive-compact-persist-smoke.mjs +124 -0
  33. package/scripts/sanitize-tool-pairs-test.mjs +260 -0
  34. package/scripts/session-context-bench.mjs +205 -33
  35. package/scripts/session-ingest-smoke.mjs +177 -0
  36. package/scripts/set-effort-config-test.mjs +41 -0
  37. package/scripts/smoke-runtime-negative.ps1 +106 -0
  38. package/scripts/smoke-runtime-negative.sh +97 -0
  39. package/scripts/smoke.mjs +25 -0
  40. package/scripts/tool-result-hook-test.mjs +48 -0
  41. package/scripts/tool-smoke.mjs +1222 -90
  42. package/scripts/toolcall-args-test.mjs +150 -0
  43. package/scripts/tui-background-failure-smoke.mjs +73 -0
  44. package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
  45. package/src/agents/debugger/AGENT.md +8 -0
  46. package/src/agents/explore/AGENT.md +4 -0
  47. package/src/agents/heavy-worker/AGENT.md +9 -3
  48. package/src/agents/maintainer/AGENT.md +4 -0
  49. package/src/agents/reviewer/AGENT.md +8 -0
  50. package/src/agents/scheduler-task/AGENT.md +12 -0
  51. package/src/agents/scheduler-task/agent.json +6 -0
  52. package/src/agents/webhook-handler/AGENT.md +12 -0
  53. package/src/agents/webhook-handler/agent.json +6 -0
  54. package/src/agents/worker/AGENT.md +9 -3
  55. package/src/app.mjs +77 -3
  56. package/src/defaults/hidden-roles.json +17 -12
  57. package/src/headless-role.mjs +117 -0
  58. package/src/help.mjs +30 -0
  59. package/src/hooks/lib/permission-evaluator.cjs +11 -475
  60. package/src/lib/keychain-cjs.cjs +9 -1
  61. package/src/lib/mixdog-debug.cjs +0 -29
  62. package/src/lib/plugin-paths.cjs +1 -7
  63. package/src/lib/rules-builder.cjs +242 -98
  64. package/src/lib/text-utils.cjs +1 -1
  65. package/src/mixdog-session-runtime.mjs +2211 -446
  66. package/src/output-styles/default.md +12 -28
  67. package/src/output-styles/extreme-simple.md +9 -6
  68. package/src/output-styles/simple.md +22 -9
  69. package/src/repl.mjs +108 -59
  70. package/src/rules/agent/00-common.md +15 -0
  71. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  72. package/src/rules/agent/30-explorer.md +22 -0
  73. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  75. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  76. package/src/rules/lead/01-general.md +9 -5
  77. package/src/rules/lead/04-workflow.md +51 -12
  78. package/src/rules/lead/lead-tool.md +6 -0
  79. package/src/rules/shared/01-tool.md +12 -1
  80. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  81. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  82. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  85. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  86. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  87. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  88. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  89. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  90. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  92. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  93. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  94. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  95. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +380 -268
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +131 -93
  97. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  98. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  100. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  101. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  102. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  103. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  104. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  107. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +203 -71
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +512 -312
  109. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -518
  110. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  111. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +289 -11
  113. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  114. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  115. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  116. package/src/runtime/agent/orchestrator/session/loop.mjs +840 -612
  117. package/src/runtime/agent/orchestrator/session/manager.mjs +1574 -459
  118. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  119. package/src/runtime/agent/orchestrator/session/store.mjs +67 -29
  120. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  121. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  122. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  123. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +35 -8
  124. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -8
  125. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -53
  126. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  127. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  130. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  133. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  138. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  139. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  140. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  141. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  142. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  143. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  146. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  147. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  148. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  149. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  150. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  151. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  152. package/src/runtime/channels/index.mjs +149 -51
  153. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  154. package/src/runtime/channels/lib/config.mjs +0 -1
  155. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  156. package/src/runtime/channels/lib/executor.mjs +0 -3
  157. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  158. package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
  159. package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
  160. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  161. package/src/runtime/channels/lib/session-discovery.mjs +3 -6
  162. package/src/runtime/channels/lib/tool-format.mjs +0 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -12
  164. package/src/runtime/channels/lib/webhook.mjs +1 -1
  165. package/src/runtime/channels/tool-defs.mjs +29 -29
  166. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  167. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  168. package/src/runtime/memory/index.mjs +519 -118
  169. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  170. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  171. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  172. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  173. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  174. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  175. package/src/runtime/memory/lib/memory-cycle1.mjs +28 -7
  176. package/src/runtime/memory/lib/memory-cycle2.mjs +4 -5
  177. package/src/runtime/memory/lib/memory-cycle3.mjs +2 -2
  178. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -2
  179. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  180. package/src/runtime/memory/lib/memory.mjs +88 -9
  181. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  182. package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
  183. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  184. package/src/runtime/memory/lib/runtime-fetcher.mjs +80 -21
  185. package/src/runtime/memory/lib/session-ingest.mjs +196 -0
  186. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  187. package/src/runtime/memory/tool-defs.mjs +46 -37
  188. package/src/runtime/search/index.mjs +104 -473
  189. package/src/runtime/search/lib/config.mjs +0 -4
  190. package/src/runtime/search/lib/state.mjs +1 -15
  191. package/src/runtime/search/lib/web-tools.mjs +44 -25
  192. package/src/runtime/search/tool-defs.mjs +16 -23
  193. package/src/runtime/shared/abort-controller.mjs +1 -1
  194. package/src/runtime/shared/atomic-file.mjs +4 -3
  195. package/src/runtime/shared/background-tasks.mjs +122 -11
  196. package/src/runtime/shared/child-spawn-gate.mjs +139 -0
  197. package/src/runtime/shared/config.mjs +7 -4
  198. package/src/runtime/shared/err-text.mjs +131 -4
  199. package/src/runtime/shared/llm/cost.mjs +2 -2
  200. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  201. package/src/runtime/shared/llm/index.mjs +34 -11
  202. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  203. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  204. package/src/runtime/shared/singleton-owner.mjs +104 -0
  205. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  206. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  207. package/src/runtime/shared/tool-surface.mjs +624 -95
  208. package/src/runtime/shared/user-data-guard.mjs +0 -2
  209. package/src/standalone/agent-task-status.mjs +203 -0
  210. package/src/standalone/agent-task-status.test.mjs +76 -0
  211. package/src/standalone/agent-tool.mjs +1913 -0
  212. package/src/standalone/channel-worker.mjs +370 -14
  213. package/src/standalone/explore-tool.mjs +165 -70
  214. package/src/standalone/folder-dialog.mjs +314 -0
  215. package/src/standalone/hook-bus.mjs +898 -22
  216. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  217. package/src/standalone/projects.mjs +226 -0
  218. package/src/standalone/provider-admin.mjs +41 -24
  219. package/src/standalone/seeds.mjs +1 -78
  220. package/src/standalone/usage-dashboard.mjs +96 -8
  221. package/src/tui/App.jsx +4800 -2140
  222. package/src/tui/components/AnsiText.jsx +39 -28
  223. package/src/tui/components/ContextPanel.jsx +14 -4
  224. package/src/tui/components/Markdown.jsx +43 -77
  225. package/src/tui/components/MarkdownTable.jsx +9 -184
  226. package/src/tui/components/Message.jsx +28 -11
  227. package/src/tui/components/Picker.jsx +95 -56
  228. package/src/tui/components/PromptInput.jsx +428 -239
  229. package/src/tui/components/QueuedCommands.jsx +1 -1
  230. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  231. package/src/tui/components/Spinner.jsx +67 -38
  232. package/src/tui/components/StatusLine.jsx +606 -38
  233. package/src/tui/components/TextEntryPanel.jsx +128 -9
  234. package/src/tui/components/ToolExecution.jsx +597 -362
  235. package/src/tui/components/TurnDone.jsx +3 -3
  236. package/src/tui/components/UsagePanel.jsx +3 -5
  237. package/src/tui/components/tool-output-format.mjs +499 -0
  238. package/src/tui/components/tool-output-format.test.mjs +312 -0
  239. package/src/tui/dist/index.mjs +9192 -2399
  240. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  241. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  242. package/src/tui/engine.mjs +1455 -279
  243. package/src/tui/figures.mjs +21 -40
  244. package/src/tui/index.jsx +75 -31
  245. package/src/tui/input-editing.mjs +25 -0
  246. package/src/tui/markdown/format-token.mjs +671 -69
  247. package/src/tui/markdown/format-token.test.mjs +312 -0
  248. package/src/tui/markdown/render-ansi.mjs +94 -0
  249. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  250. package/src/tui/markdown/stream-fence.mjs +34 -0
  251. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  252. package/src/tui/markdown/table-layout.mjs +250 -0
  253. package/src/tui/paste-attachments.mjs +0 -7
  254. package/src/tui/spinner-verbs.mjs +1 -2
  255. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  256. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  257. package/src/tui/theme.mjs +756 -24
  258. package/src/tui/time-format.mjs +1 -1
  259. package/src/tui/transcript-tool-failures.mjs +67 -0
  260. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  261. package/src/ui/ansi.mjs +1 -2
  262. package/src/ui/markdown.mjs +85 -26
  263. package/src/ui/markdown.test.mjs +70 -0
  264. package/src/ui/model-display.mjs +121 -0
  265. package/src/ui/session-stats.mjs +44 -0
  266. package/src/ui/statusline-context-label.test.mjs +15 -0
  267. package/src/ui/statusline.mjs +386 -178
  268. package/src/ui/tool-card.mjs +2 -13
  269. package/src/vendor/statusline/bin/statusline-lib.mjs +1 -620
  270. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  271. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  272. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  273. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  274. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  275. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  276. package/src/workflows/default/WORKFLOW.md +12 -5
  277. package/src/workflows/default/workflow.json +0 -1
  278. package/src/workflows/solo/WORKFLOW.md +15 -0
  279. package/src/workflows/solo/workflow.json +7 -0
  280. package/vendor/ink/build/ink.js +54 -8
  281. package/vendor/ink/build/output.js +6 -1
  282. package/src/agents/scheduler-task.md +0 -3
  283. package/src/agents/web-researcher/AGENT.md +0 -3
  284. package/src/agents/web-researcher/agent.json +0 -6
  285. package/src/agents/webhook-handler.md +0 -3
  286. package/src/hooks/lib/permission-rules.cjs +0 -170
  287. package/src/hooks/lib/settings-loader.cjs +0 -112
  288. package/src/lib/hook-pipe-path.cjs +0 -10
  289. package/src/rules/bridge/00-common.md +0 -5
  290. package/src/rules/bridge/30-explorer.md +0 -4
  291. package/src/rules/lead/00-tool-lead.md +0 -5
  292. package/src/rules/shared/00-language.md +0 -3
  293. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  294. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  295. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  296. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  297. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  298. package/src/standalone/bridge-tool.mjs +0 -1414
  299. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -1,9 +1,9 @@
1
1
  /**
2
- * OpenAI ChatGPT OAuth (Codex) provider.
2
+ * OpenAI ChatGPT OAuth subscription provider.
3
3
  *
4
4
  * Dispatches over the WebSocket upgrade of chatgpt.com/backend-api/codex/
5
5
  * responses (responses_websockets=2026-02-06 beta). Authenticates via PKCE
6
- * OAuth or reuses ~/.codex/auth.json. Streaming/framing lives in
6
+ * OAuth using Mixdog-owned token storage. Streaming/framing lives in
7
7
  * openai-oauth-ws.mjs; this file owns auth, model catalog, request-body
8
8
  * shape, and HTTP/SSE fallback when WebSocket transport is unhealthy.
9
9
  */
@@ -11,7 +11,6 @@ import { createServer } from 'http';
11
11
  import { randomBytes, createHash } from 'crypto';
12
12
  import { readFileSync, existsSync, mkdirSync, statSync, unlinkSync } from 'fs';
13
13
  import { join } from 'path';
14
- import { homedir } from 'os';
15
14
  import { getPluginData } from '../config.mjs';
16
15
  import { enrichModels } from './model-catalog.mjs';
17
16
  import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
@@ -22,38 +21,42 @@ import {
22
21
  buildStableProviderPromptCacheKey,
23
22
  resolveProviderPromptCacheLane,
24
23
  resolveProviderCacheKey,
25
- } from '../smart-bridge/cache-strategy.mjs';
24
+ } from '../agent-runtime/cache-strategy.mjs';
26
25
  import {
27
- appendBridgeTrace,
28
- traceBridgeFetch,
29
- traceBridgeSse,
30
- traceBridgeUsage,
31
- } from '../bridge-trace.mjs';
26
+ appendAgentTrace,
27
+ traceAgentFetch,
28
+ traceAgentSse,
29
+ traceAgentUsage,
30
+ } from '../agent-trace.mjs';
32
31
  import {
33
32
  PROVIDER_GENERATE_TOTAL_TIMEOUT_MS,
34
33
  PROVIDER_HTTP_RESPONSE_TIMEOUT_MS,
35
34
  createTimeoutSignal,
36
35
  } from '../stall-policy.mjs';
37
- import { populateHttpStatusFromMessage } from './retry-classifier.mjs';
36
+ import { populateHttpStatusFromMessage, shouldFallbackTransport } from './retry-classifier.mjs';
38
37
  import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
38
+ import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
39
39
  import {
40
- contentHasImage,
41
40
  normalizeContentForOpenAIResponses,
42
41
  splitToolContentForOpenAIResponses,
43
42
  } from './media-normalization.mjs';
43
+ import {
44
+ customToolCallFromResponseItem,
45
+ customToolInputFromArguments,
46
+ isCustomToolCallRecord,
47
+ isResponsesFreeformTool,
48
+ toResponsesCustomTool,
49
+ } from './custom-tool-wire.mjs';
44
50
  // --- Constants ---
45
51
  const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
46
52
  const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs';
47
53
  const TOKEN_URL = 'https://auth.openai.com/oauth/token';
48
54
  const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
49
- const OPENAI_CODEX_REMOTE_COMPACT_FALLBACK = 'openai-codex';
50
- const OPENAI_CODEX_REMOTE_COMPACT_VERSION = 1;
51
- const REMOTE_COMPACT_RETAINED_TOKEN_BUDGET = 64_000;
52
- // Version string baked into the models endpoint query — Codex rejects the
53
- // request without it. Keep close to the latest published Codex CLI because
55
+ // Version string baked into the models endpoint query — the OAuth backend rejects the
56
+ // request without it. Keep close to the latest published @openai/codex CLI because
54
57
  // older versions trigger a visibility-filtered catalog (e.g. only rollout
55
58
  // models). Bump when the real CLI bumps.
56
- // Codex backend gates new model exposures (e.g. gpt-5.5 only on >= 0.130.0)
59
+ // OpenAI OAuth backend gates new model exposures (e.g. gpt-5.5 only on >= 0.130.0)
57
60
  // on the client_version header. Resolve dynamically from npm so newly-shipped
58
61
  // models surface within a day instead of waiting on a hardcoded bump here.
59
62
  // Cached 24h in-process; npm failure falls back to the floor below.
@@ -149,7 +152,7 @@ export function codexModelSupportsServiceTier(id, serviceTier) {
149
152
  return tiers.some(t => t?.id === serviceTier);
150
153
  }
151
154
 
152
- // Codex returns dated ids (gpt-5.4-mini-2026-03-17). Strip the trailing
155
+ // OAuth catalog returns dated ids (gpt-5.4-mini-2026-03-17). Strip the trailing
153
156
  // -YYYY-MM-DD to get the version alias (gpt-5.4-mini). Unknown shapes pass
154
157
  // through unchanged.
155
158
  function _displayCodexModel(id) {
@@ -172,7 +175,7 @@ function _normalizeCodexModel(m) {
172
175
  const additionalSpeedTiers = Array.isArray(m?.additional_speed_tiers)
173
176
  ? m.additional_speed_tiers.map(t => String(t || '').trim()).filter(Boolean)
174
177
  : [];
175
- // Codex doesn't use dated ids everything is effectively a version alias.
178
+ // Catalog ids are version aliases without separate display dating.
176
179
  return {
177
180
  id,
178
181
  name: m?.display_name || id,
@@ -206,8 +209,8 @@ function _codexFamily(id) {
206
209
  return 'gpt';
207
210
  }
208
211
 
209
- // Compare two Codex ids by the X.Y version embedded in `gpt-X.Y`. Mirrors
210
- // anthropic-oauth's _compareVersion, but Codex ids have no trailing date so
212
+ // Compare two model ids by the X.Y version embedded in `gpt-X.Y`. Mirrors
213
+ // anthropic-oauth's _compareVersion; these ids have no trailing date so
211
214
  // the version lives in the dotted number, not a -YYYY-MM-DD suffix.
212
215
  function _compareVersion(a, b) {
213
216
  const na = (String(a).match(/gpt-(\d+)\.(\d+)/) || []).slice(1).map(Number);
@@ -225,7 +228,7 @@ function _isMainCodexFamily(family) {
225
228
  }
226
229
 
227
230
  // Mark the highest-version model per family as `latest: true`. VERSION-based
228
- // (Codex ids carry no `created`), mirroring anthropic-oauth's per-family pass.
231
+ // (ids carry no `created`), mirroring anthropic-oauth's per-family pass.
229
232
  function _markLatestCodex(models) {
230
233
  const byFamily = new Map();
231
234
  for (const m of models) {
@@ -268,7 +271,7 @@ function getOwnTokenPath() {
268
271
  }
269
272
 
270
273
  // Public predicate used by config.buildDefaultConfig — provider is enabled
271
- // when own tokens exist OR codex bootstrap auth is present. Single truth:
274
+ // when own Mixdog tokens exist. Single truth:
272
275
  // same loader the runtime uses (loadTokens), no parallel hard-coded path probe.
273
276
  export function hasOpenAIOAuthCredentials() {
274
277
  try {
@@ -281,7 +284,7 @@ export function describeOpenAIOAuthCredentials() {
281
284
  try {
282
285
  const tokens = loadTokens();
283
286
  if (!tokens?.access_token) {
284
- return { authenticated: false, status: 'Not Set', detail: '~/.codex/auth.json or mixdog token store' };
287
+ return { authenticated: false, status: 'Not Set', detail: 'Mixdog token store' };
285
288
  }
286
289
  const hasRefresh = Boolean(tokens.refresh_token);
287
290
  const expiresAt = _normalizeExpiresAt(tokens.expires_at ?? tokens.expiresAt);
@@ -310,7 +313,7 @@ function _normalizeExpiresAt(value) {
310
313
  }
311
314
  function _tokensMaxMtime() {
312
315
  let max = 0;
313
- const paths = [getOwnTokenPath(), join(homedir(), '.codex', 'auth.json')];
316
+ const paths = [getOwnTokenPath()];
314
317
  for (const p of paths) {
315
318
  try {
316
319
  const s = statSync(p);
@@ -319,10 +322,6 @@ function _tokensMaxMtime() {
319
322
  }
320
323
  return max;
321
324
  }
322
-
323
- function _codexCliAuthPath() {
324
- return join(homedir(), '.codex', 'auth.json');
325
- }
326
325
  function _loadOwnCodexTokens() {
327
326
  const ownPath = getOwnTokenPath();
328
327
  if (!existsSync(ownPath)) return null;
@@ -334,7 +333,7 @@ function _loadOwnCodexTokens() {
334
333
  ...own,
335
334
  expires_at: _normalizeExpiresAt(own.expires_at ?? own.expiresAt) || _expiryFromAccessToken(own.access_token),
336
335
  account_id: own.account_id || extractAccountId(own.access_token),
337
- source: 'mixdog',
336
+ source: 'Mixdog token store',
338
337
  _mtimeMs: stat.mtimeMs,
339
338
  };
340
339
  }
@@ -342,38 +341,8 @@ function _loadOwnCodexTokens() {
342
341
  catch { /* fall through */ }
343
342
  return null;
344
343
  }
345
- function _loadCodexCliTokens() {
346
- const codexPath = _codexCliAuthPath();
347
- if (!existsSync(codexPath)) return null;
348
- try {
349
- const stat = statSync(codexPath);
350
- const data = JSON.parse(readFileSync(codexPath, 'utf-8'));
351
- const tokens = data.tokens || data;
352
- if (tokens.access_token && tokens.refresh_token) {
353
- const expiresAt = _normalizeExpiresAt(data.expires_at ?? tokens.expires_at ?? data.expiresAt ?? tokens.expiresAt) || _expiryFromAccessToken(tokens.access_token);
354
- return {
355
- access_token: tokens.access_token,
356
- refresh_token: tokens.refresh_token,
357
- expires_at: expiresAt,
358
- account_id: tokens.account_id || extractAccountId(tokens.access_token),
359
- source: 'codex-cli',
360
- _mtimeMs: stat.mtimeMs,
361
- };
362
- }
363
- }
364
- catch { /* fall through */ }
365
- return null;
366
- }
367
- // Own store is authoritative (accurate expires_at from refresh); the Codex CLI
368
- // store seeds the initial bootstrap. But the refresh-token lineage is shared
369
- // single-use with the Codex CLI, so when the CLI store is STRICTLY newer on
370
- // disk (an independent `codex login`/CLI refresh) we must adopt it instead of
371
- // replaying our consumed token. Freshest-wins, own preferred on a tie.
372
344
  function loadTokens() {
373
- const own = _loadOwnCodexTokens();
374
- const cli = _loadCodexCliTokens();
375
- if (own && cli) return (cli._mtimeMs > own._mtimeMs) ? cli : own;
376
- return own || cli;
345
+ return _loadOwnCodexTokens();
377
346
  }
378
347
  function saveTokens(tokens) {
379
348
  const target = getOwnTokenPath();
@@ -387,55 +356,8 @@ export function forgetOpenAIOAuthCredentials() {
387
356
  unlinkSync(ownPath);
388
357
  removed = true;
389
358
  }
390
- const codexPath = _codexCliAuthPath();
391
- if (existsSync(codexPath)) {
392
- try {
393
- const raw = JSON.parse(readFileSync(codexPath, 'utf-8'));
394
- if (raw?.tokens && typeof raw.tokens === 'object') {
395
- delete raw.tokens.access_token;
396
- delete raw.tokens.refresh_token;
397
- delete raw.tokens.id_token;
398
- raw.last_refresh = new Date().toISOString();
399
- writeJsonAtomicSync(codexPath, raw, { lock: true, fsyncDir: true });
400
- removed = true;
401
- } else if (raw?.access_token || raw?.refresh_token) {
402
- delete raw.access_token;
403
- delete raw.refresh_token;
404
- delete raw.id_token;
405
- raw.last_refresh = new Date().toISOString();
406
- writeJsonAtomicSync(codexPath, raw, { lock: true, fsyncDir: true });
407
- removed = true;
408
- }
409
- } catch (err) {
410
- throw new Error(`OpenAI OAuth reset failed for ${codexPath}: ${err?.message || err}`);
411
- }
412
- }
413
359
  return { removed };
414
360
  }
415
- // Write rotated tokens back to the Codex CLI store (~/.codex/auth.json) so the
416
- // Codex CLI picks up the rotation instead of replaying a consumed refresh_token
417
- // from the shared single-use lineage. Mirrors anthropic-oauth's write-back.
418
- // Best-effort; the own store stays authoritative. Host-owned file: preserve all
419
- // other fields and don't re-permission it (no secret/mode).
420
- function _writeBackCodexCliTokens(tokens) {
421
- const path = _codexCliAuthPath();
422
- if (!existsSync(path)) return;
423
- try {
424
- const raw = JSON.parse(readFileSync(path, 'utf-8'));
425
- if (!raw || typeof raw !== 'object') return;
426
- const slot = (raw.tokens && typeof raw.tokens === 'object') ? raw.tokens : raw;
427
- slot.access_token = tokens.access_token;
428
- slot.refresh_token = tokens.refresh_token;
429
- raw.last_refresh = new Date().toISOString();
430
- // Preserve the Codex CLI file's existing POSIX mode (writeJsonAtomicSync
431
- // otherwise defaults to 0o600, re-permissioning a host-owned file).
432
- let mode;
433
- try { mode = statSync(path).mode & 0o777; } catch { /* keep helper default */ }
434
- writeJsonAtomicSync(path, raw, { lock: true, fsyncDir: true, mode });
435
- } catch (err) {
436
- process.stderr.write(`[openai-oauth] Codex CLI store write-back failed: ${String(err?.message || err).slice(0, 200)}\n`);
437
- }
438
- }
439
361
  function extractAccountId(token) {
440
362
  try {
441
363
  const parts = token.split('.');
@@ -449,11 +371,7 @@ function extractAccountId(token) {
449
371
  }
450
372
  }
451
373
  // Derive token expiry from the access_token's JWT `exp` claim (epoch ms), as a
452
- // fallback when the source store carries no explicit expires_at — e.g. the Codex
453
- // CLI's ~/.codex/auth.json records only last_refresh, so expires_at resolves to 0
454
- // and ensureAuth reads that as "never expires", disabling proactive refresh; the
455
- // token then only refreshes reactively after a request fails (and a WS handshake
456
- // 401 can surface as an opaque transport error that the 401 path misses). Returns
374
+ // fallback when the Mixdog token store carries no explicit expires_at. Returns
457
375
  // 0 for opaque (non-JWT) tokens. JWT `exp` is epoch SECONDS (RFC 7519).
458
376
  function _expiryFromAccessToken(token) {
459
377
  try {
@@ -487,7 +405,7 @@ async function refreshTokens(refreshToken) {
487
405
  });
488
406
  if (!res.ok) {
489
407
  const text = await res.text().catch(() => '');
490
- // Distinguish a terminally-dead refresh token (consumed by the Codex
408
+ // Distinguish a terminally-dead refresh token (consumed by the official
491
409
  // CLI's single-use lineage) from transient failures, so the caller can
492
410
  // re-read disk and retry once with a newer token instead of
493
411
  // collapsing every failure to a generic null.
@@ -506,11 +424,6 @@ async function refreshTokens(refreshToken) {
506
424
  expires_at: expiresAt,
507
425
  account_id: extractAccountId(json.access_token),
508
426
  };
509
- // CLI store first, own store last: the own store keeps the newest mtime
510
- // (and its accurate refresh expires_at), so freshest-wins loadTokens
511
- // treats our refresh as authoritative while the CLI still picks up the
512
- // rotated token.
513
- _writeBackCodexCliTokens(tokens);
514
427
  saveTokens(tokens);
515
428
  return tokens;
516
429
  } catch (err) {
@@ -526,18 +439,6 @@ function _cloneJson(value) {
526
439
  try { return JSON.parse(JSON.stringify(value)); } catch { return value; }
527
440
  }
528
441
 
529
- function _nativeCompactState(providerState, model) {
530
- const state = providerState?.openaiCodex?.remoteCompact;
531
- if (!state || state.version !== OPENAI_CODEX_REMOTE_COMPACT_VERSION) return null;
532
- if (!Array.isArray(state.nativePrefix) || state.nativePrefix.length === 0) return null;
533
- if (state.model && model && state.model !== model) return null;
534
- return state;
535
- }
536
-
537
- function _isRemoteCompactFallbackMessage(m) {
538
- return m?._mixdogRemoteCompactFallback === OPENAI_CODEX_REMOTE_COMPACT_FALLBACK;
539
- }
540
-
541
442
  function _contentTextParts(content, type = 'input_text') {
542
443
  if (typeof content === 'string') return content ? [{ type, text: content }] : [];
543
444
  if (!Array.isArray(content)) {
@@ -556,127 +457,41 @@ function _contentTextParts(content, type = 'input_text') {
556
457
  return out;
557
458
  }
558
459
 
559
- function _messageToNativeRetainedItem(m) {
560
- const role = m?.role;
561
- if (role !== 'user') return null;
562
- if (_isRemoteCompactFallbackMessage(m)) return null;
563
- const content = _contentTextParts(m.content, 'input_text');
564
- if (!content.length) return null;
565
- return { type: 'message', role, content };
566
- }
567
-
568
- function _nativeMessageTextTokenCount(item) {
569
- if (!item || item.type !== 'message') return 0;
570
- const content = Array.isArray(item.content) ? item.content : [];
571
- let chars = 0;
572
- for (const part of content) {
573
- if (typeof part?.text === 'string') chars += part.text.length;
574
- }
575
- return Math.max(1, Math.ceil(chars / 4));
576
- }
577
-
578
- function _truncateTextForTokens(text, maxTokens) {
579
- const value = String(text || '');
580
- const maxChars = Math.max(0, Math.floor(maxTokens * 4));
581
- if (value.length <= maxChars) return value;
582
- if (maxChars <= 32) return value.slice(0, maxChars);
583
- const marker = `...${Math.max(1, Math.ceil((value.length - maxChars) / 4))} tokens truncated...`;
584
- const room = Math.max(0, maxChars - marker.length);
585
- const head = Math.ceil(room / 2);
586
- const tail = Math.floor(room / 2);
587
- return `${value.slice(0, head)}${marker}${value.slice(value.length - tail)}`;
588
- }
589
-
590
- function _truncateNativeMessageToTokenBudget(item, maxTokens) {
591
- if (!item || item.type !== 'message') return item;
592
- const clone = _cloneJson(item);
593
- let remaining = Math.max(0, Math.floor(maxTokens));
594
- const content = [];
595
- for (const part of Array.isArray(clone.content) ? clone.content : []) {
596
- if (typeof part?.text !== 'string') {
597
- content.push(part);
598
- continue;
599
- }
600
- if (remaining <= 0) continue;
601
- const tokenCount = Math.max(1, Math.ceil(part.text.length / 4));
602
- if (tokenCount <= remaining) {
603
- content.push(part);
604
- remaining -= tokenCount;
605
- } else {
606
- const truncated = _truncateTextForTokens(part.text, remaining);
607
- if (truncated) content.push({ ...part, text: truncated });
608
- remaining = 0;
609
- }
610
- }
611
- if (!content.length) return null;
612
- clone.content = content;
613
- return clone;
614
- }
615
-
616
- function _truncateRetainedNativeMessages(items, maxTokens = REMOTE_COMPACT_RETAINED_TOKEN_BUDGET) {
617
- let remaining = Math.max(0, Math.floor(maxTokens));
618
- const reversed = [];
619
- for (let i = items.length - 1; i >= 0; i -= 1) {
620
- if (remaining <= 0) break;
621
- const item = items[i];
622
- const tokenCount = _nativeMessageTextTokenCount(item);
623
- if (tokenCount <= remaining) {
624
- reversed.push(item);
625
- remaining -= tokenCount;
626
- continue;
627
- }
628
- const truncated = _truncateNativeMessageToTokenBudget(item, remaining);
629
- if (truncated) reversed.push(truncated);
630
- remaining = 0;
631
- }
632
- return reversed.reverse();
633
- }
634
-
635
- function _buildOpenAICodexNativeCompactPrefix(messages, compactionItem) {
636
- const retained = [];
637
- for (const m of messages || []) {
638
- const item = _messageToNativeRetainedItem(m);
639
- if (item) retained.push(item);
640
- }
641
- const compact = _cloneJson(compactionItem);
642
- return [..._truncateRetainedNativeMessages(retained), compact];
643
- }
644
-
645
- function _withOpenAICodexRemoteCompactState(providerState, { model, nativePrefix, responseId } = {}) {
646
- return {
647
- ...(providerState || {}),
648
- openaiCodex: {
649
- ...(providerState?.openaiCodex || {}),
650
- remoteCompact: {
651
- version: OPENAI_CODEX_REMOTE_COMPACT_VERSION,
652
- model: model || null,
653
- nativePrefix,
654
- responseId: responseId || null,
655
- installedAt: Date.now(),
656
- },
657
- },
658
- };
659
- }
660
-
661
460
  /**
662
461
  * Convert a message slice to Responses API input items.
663
462
  */
664
463
  function convertMessagesToResponsesInput(messages, opts = {}) {
665
464
  const out = [];
666
- const nativeCompact = _nativeCompactState(opts.providerState, opts.model);
667
- if (nativeCompact) {
668
- for (const item of nativeCompact.nativePrefix) out.push(_cloneJson(item));
669
- }
670
465
  const pendingToolMedia = [];
466
+ const customToolCallNameById = new Map();
671
467
  const flushToolMedia = () => {
672
468
  if (!pendingToolMedia.length) return;
673
469
  out.push({ role: 'user', content: pendingToolMedia.splice(0) });
674
470
  };
675
471
  for (const m of messages) {
676
472
  if (!m || m.role === 'system') continue;
677
- if (nativeCompact && _isRemoteCompactFallbackMessage(m)) continue;
678
473
  if (m.role === 'tool') {
474
+ if (Array.isArray(m.nativeToolSearch?.openaiTools)) {
475
+ out.push({
476
+ type: 'tool_search_output',
477
+ call_id: m.toolCallId || '',
478
+ status: 'completed',
479
+ execution: 'client',
480
+ tools: m.nativeToolSearch.openaiTools,
481
+ });
482
+ continue;
483
+ }
679
484
  const { output, mediaContent } = splitToolContentForOpenAIResponses(m.content);
485
+ if (customToolCallNameById.has(m.toolCallId || '')) {
486
+ out.push({
487
+ type: 'custom_tool_call_output',
488
+ call_id: m.toolCallId || '',
489
+ name: customToolCallNameById.get(m.toolCallId || '') || undefined,
490
+ output,
491
+ });
492
+ if (mediaContent) pendingToolMedia.push(...mediaContent);
493
+ continue;
494
+ }
680
495
  out.push({
681
496
  type: 'function_call_output',
682
497
  call_id: m.toolCallId || '',
@@ -687,7 +502,7 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
687
502
  }
688
503
  flushToolMedia();
689
504
  if (m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length) {
690
- // Reasoning replay deliberately omitted: Codex rejects an
505
+ // Reasoning replay deliberately omitted: openai-oauth rejects an
691
506
  // `rs_*` reasoning item with the same id across the same
692
507
  // handshake session_id (in-memory conversation state lives
693
508
  // for the WS_IDLE_MS window even after a socket close).
@@ -695,12 +510,29 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
695
510
  // reasoning in `input` triggers "Duplicate item".
696
511
  if (m.content) out.push({ role: 'assistant', content: normalizeContentForOpenAIResponses(m.content, { role: 'assistant' }) });
697
512
  for (const tc of m.toolCalls) {
698
- out.push({
699
- type: 'function_call',
700
- call_id: tc.id,
701
- name: tc.name,
702
- arguments: JSON.stringify(tc.arguments),
703
- });
513
+ if (tc.nativeType === 'tool_search_call' || tc.name === 'tool_search') {
514
+ out.push({
515
+ type: 'tool_search_call',
516
+ call_id: tc.id,
517
+ execution: 'client',
518
+ arguments: tc.arguments || {},
519
+ });
520
+ } else if (isCustomToolCallRecord(tc)) {
521
+ if (tc.id) customToolCallNameById.set(tc.id, tc.name || '');
522
+ out.push({
523
+ type: 'custom_tool_call',
524
+ call_id: tc.id,
525
+ name: tc.name,
526
+ input: customToolInputFromArguments(tc.name, tc.arguments),
527
+ });
528
+ } else {
529
+ out.push({
530
+ type: 'function_call',
531
+ call_id: tc.id,
532
+ name: tc.name,
533
+ arguments: JSON.stringify(tc.arguments),
534
+ });
535
+ }
704
536
  }
705
537
  continue;
706
538
  }
@@ -713,8 +545,22 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
713
545
  return out;
714
546
  }
715
547
 
716
- function messagesHaveImageContent(messages) {
717
- return (messages || []).some((m) => contentHasImage(m?.content));
548
+ function toOpenAIResponsesTool(t) {
549
+ if (t?.name === 'tool_search') {
550
+ return {
551
+ type: 'tool_search',
552
+ execution: 'client',
553
+ description: t.description,
554
+ parameters: t.inputSchema,
555
+ };
556
+ }
557
+ if (isResponsesFreeformTool(t)) return toResponsesCustomTool(t);
558
+ return {
559
+ type: 'function',
560
+ name: t.name,
561
+ description: t.description,
562
+ parameters: t.inputSchema,
563
+ };
718
564
  }
719
565
 
720
566
  export function buildRequestBody(messages, model, tools, sendOpts) {
@@ -726,11 +572,16 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
726
572
  providerState: opts.providerState,
727
573
  model,
728
574
  });
729
- // Match the body shape pi-mono and the official Codex CLI ship so the
575
+ // Match the body shape pi-mono and the official OpenAI CLI ship so the
730
576
  // server-side auto-cache routes correctly. text.verbosity / include /
731
577
  // tool_choice / parallel_tool_calls are all inert without side effects
732
- // for most callers but their presence affects how Codex classifies the
578
+ // for most callers but their presence affects how the OAuth backend classifies the
733
579
  // request (and therefore whether the prompt cache is consulted).
580
+ const include = ['reasoning.encrypted_content'];
581
+ for (const item of Array.isArray(opts.nativeInclude) ? opts.nativeInclude : []) {
582
+ const value = String(item || '').trim();
583
+ if (value && !include.includes(value)) include.push(value);
584
+ }
734
585
  const body = {
735
586
  model,
736
587
  instructions,
@@ -739,7 +590,7 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
739
590
  stream: true,
740
591
  reasoning: { effort: opts.effort || 'medium' },
741
592
  text: { verbosity: 'medium' },
742
- include: ['reasoning.encrypted_content'],
593
+ include,
743
594
  tool_choice: opts.toolChoice || 'auto',
744
595
  parallel_tool_calls: true,
745
596
  };
@@ -750,22 +601,23 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
750
601
  body.max_output_tokens = Math.floor(maxOutputTokens);
751
602
  }
752
603
  if (opts.fast === true) {
753
- // 'priority' is the only fast-class value the Codex OAuth backend
604
+ // 'priority' is the only fast-class value the OpenAI OAuth backend
754
605
  // accepts on the wire: 'fast' is hard-rejected ("Unsupported
755
- // service_tier: fast", probed 2026-06-11). Match official Codex:
606
+ // service_tier: fast", probed 2026-06-11). Match official CLI behavior:
756
607
  // only send the request value when the model catalog advertises it.
757
608
  if (codexModelSupportsServiceTier(model, 'priority')) {
758
609
  body.service_tier = 'priority';
759
610
  }
760
611
  }
761
- // Add tools
762
- if (tools?.length) {
763
- body.tools = tools.map(t => ({
764
- type: 'function',
765
- name: t.name,
766
- description: t.description,
767
- parameters: t.inputSchema,
768
- }));
612
+ // Add tools. `nativeTools` are server-hosted Responses tools (for
613
+ // example web_search) and must be passed through without wrapping them as
614
+ // function tools.
615
+ const functionTools = tools?.length ? tools.map(toOpenAIResponsesTool) : [];
616
+ const nativeTools = Array.isArray(opts.nativeTools)
617
+ ? opts.nativeTools.filter(t => t && typeof t === 'object')
618
+ : [];
619
+ if (functionTools.length || nativeTools.length) {
620
+ body.tools = [...nativeTools, ...functionTools];
769
621
  }
770
622
  const promptCacheProvider = opts.promptCacheProvider || 'openai-oauth';
771
623
  const promptCacheLane = opts.promptCacheLane || resolveProviderPromptCacheLane(promptCacheProvider, opts);
@@ -782,21 +634,9 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
782
634
  cacheLaneShards: promptCacheLane.shards,
783
635
  });
784
636
  // NOTE: prompt_cache_retention is a public OpenAI Responses API parameter,
785
- // but the Codex OAuth endpoint still rejects it ("Unsupported parameter:
637
+ // but the openai-oauth endpoint still rejects it ("Unsupported parameter:
786
638
  // prompt_cache_retention", re-probed 2026-06-22). Leave retention on the
787
- // Codex server default; public OpenAI direct injects 24h separately.
788
- return body;
789
- }
790
-
791
- function buildRemoteCompactionRequestBody(messages, model, tools, sendOpts) {
792
- const body = buildRequestBody(messages, model, tools, {
793
- ...(sendOpts || {}),
794
- expectCompaction: true,
795
- });
796
- body.input = [
797
- ...(Array.isArray(body.input) ? body.input : []),
798
- { type: 'compaction_trigger' },
799
- ];
639
+ // openai-oauth server default; public OpenAI direct injects 24h separately.
800
640
  return body;
801
641
  }
802
642
 
@@ -806,12 +646,29 @@ function _envFlag(name, fallback = true) {
806
646
  return !['0', 'false', 'off', 'no'].includes(String(raw).toLowerCase());
807
647
  }
808
648
 
649
+ function _envPositiveInt(name, fallback) {
650
+ const raw = process.env[name];
651
+ if (raw == null || raw === '') return fallback;
652
+ const n = Number(raw);
653
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
654
+ }
655
+
656
+ // Completed function_call.arguments parse for the OpenAI Responses stream.
657
+ // Native convergence (openai-oauth / anthropic-oauth / opencode): a function_call item
658
+ // arrives only on a completion/done signal, so a non-empty-but-malformed
659
+ // arguments string is deterministic bad JSON — NOT mid-stream truncation.
660
+ // Empty/whitespace input legitimately means "no arguments" → {}. A non-empty
661
+ // string that fails JSON.parse is surfaced as an invalid-args MARKER (instead
662
+ // of being silently swallowed to {}) so the dispatch loop turns it into an
663
+ // is_error tool_result and the model self-corrects in the same turn.
809
664
  function _parseJsonObject(value) {
665
+ const text = typeof value === 'string' ? value : (value == null ? '' : String(value));
666
+ if (text.trim() === '') return {};
810
667
  try {
811
- const parsed = JSON.parse(value || '{}');
668
+ const parsed = JSON.parse(text);
812
669
  return parsed && typeof parsed === 'object' ? parsed : {};
813
- } catch {
814
- return {};
670
+ } catch (err) {
671
+ return makeInvalidToolArgsMarker(text, err instanceof Error ? err.message : String(err));
815
672
  }
816
673
  }
817
674
 
@@ -885,29 +742,14 @@ function _buildOpenAIHttpFallbackHeaders({ auth, cacheKey }) {
885
742
  return headers;
886
743
  }
887
744
 
745
+ // WS→HTTP/SSE fallback predicate → shared shouldFallbackTransport
746
+ // (retry-classifier.mjs). The per-provider env flag is computed here and passed
747
+ // as `enabled`; the deny-order + allow-list are identical to the former copy.
888
748
  function _shouldUseOpenAIHttpFallback(err, externalSignal) {
889
- if (!_envFlag('MIXDOG_OPENAI_OAUTH_HTTP_FALLBACK', true)) return false;
890
- if (externalSignal?.aborted) return false;
891
- // Live-text invariant: if the WS attempt already relayed a non-empty text
892
- // chunk to the client, the HTTP fallback would re-run the request and
893
- // concatenate a second attempt onto rendered output. Never fall back.
894
- if (err?.liveTextEmitted === true) return false;
895
- const status = Number(err?.httpStatus || err?.status || 0);
896
- if (status === 401 || status === 403 || status === 404 || status === 429) return false;
897
- if (status >= 500 && status < 600) return true;
898
- const code = String(err?.code || '');
899
- if (['EWSACQUIRETIMEOUT', 'ETIMEDOUT', 'ESOCKETTIMEDOUT', 'ECONNRESET', 'EAI_AGAIN', 'ENOTFOUND', 'EAI_NODATA', 'ECONNREFUSED', 'ENETUNREACH', 'EHOSTUNREACH', 'EPIPE'].includes(code)) {
900
- return true;
901
- }
902
- const classifier = String(err?.retryClassifier || err?.midstreamClassifier || '');
903
- if (['timeout', 'reset', 'dns', 'refused', 'network', 'acquire_timeout', 'http_5xx', 'first_byte_timeout', 'first_meaningful_timeout'].includes(classifier)) {
904
- return true;
905
- }
906
- if (/^http_5\d\d$/.test(classifier)) return true;
907
- if (err?.firstByteTimeout) return true;
908
- if (err?.firstMeaningfulTimeout) return true;
909
- const msg = String(err?.message || '');
910
- return /opening handshake has timed out|socket hang up|acquire timed out|no first server event|no meaningful output/i.test(msg);
749
+ return shouldFallbackTransport(err, {
750
+ signal: externalSignal,
751
+ enabled: _envFlag('MIXDOG_OPENAI_OAUTH_HTTP_FALLBACK', true),
752
+ });
911
753
  }
912
754
 
913
755
  // Exported for the single-emit regression smoke (scripts/openai-oauth-
@@ -960,7 +802,7 @@ export async function sendViaHttpSse({
960
802
  headerTimeout.cleanup();
961
803
  }
962
804
 
963
- traceBridgeFetch({
805
+ traceAgentFetch({
964
806
  sessionId: poolKey,
965
807
  headersMs: Date.now() - fetchStartedAt,
966
808
  httpStatus: response.status,
@@ -1019,7 +861,6 @@ export async function sendViaHttpSse({
1019
861
  const toolCalls = [];
1020
862
  const pendingCalls = new Map();
1021
863
  const reasoningItems = [];
1022
- const compactionItems = [];
1023
864
  const citations = [];
1024
865
  const citationKeys = new Set();
1025
866
  const webSearchCalls = [];
@@ -1067,10 +908,34 @@ export async function sendViaHttpSse({
1067
908
  });
1068
909
  }
1069
910
  };
1070
- const pushCompactionItem = (item) => {
1071
- if (!item || !['compaction', 'compaction_summary', 'context_compaction'].includes(item.type)) return;
1072
- if (!item.encrypted_content) return;
1073
- compactionItems.push(item);
911
+ const pushToolSearchCall = (item) => {
912
+ if (!item || item.type !== 'tool_search_call') return;
913
+ const callId = item.call_id || item.id || '';
914
+ if (!callId || toolCalls.some(t => t.id === callId)) return;
915
+ let args = {};
916
+ if (item.arguments && typeof item.arguments === 'object') {
917
+ args = item.arguments;
918
+ } else if (typeof item.arguments === 'string' && item.arguments.trim()) {
919
+ // Non-empty but malformed tool_search arguments are deterministic
920
+ // bad JSON (the item is only emitted on completion). Surface an
921
+ // invalid-args marker instead of swallowing to {} so the model can
922
+ // self-correct in the same turn.
923
+ args = _parseJsonObject(item.arguments);
924
+ }
925
+ const call = {
926
+ id: callId,
927
+ name: 'tool_search',
928
+ arguments: args,
929
+ nativeType: 'tool_search_call',
930
+ };
931
+ toolCalls.push(call);
932
+ emitToolCall(call);
933
+ };
934
+ const pushCustomToolCall = (item) => {
935
+ const call = customToolCallFromResponseItem(item);
936
+ if (!call || toolCalls.some(t => t.id === call.id)) return;
937
+ toolCalls.push(call);
938
+ emitToolCall(call);
1074
939
  };
1075
940
  const meaningful = () => {
1076
941
  if (ttftMs == null) ttftMs = Date.now() - sseStartedAt;
@@ -1123,11 +988,13 @@ export async function sendViaHttpSse({
1123
988
  meaningful();
1124
989
  break;
1125
990
  }
991
+ case 'response.custom_tool_call_input.delta':
992
+ meaningful();
993
+ break;
1126
994
  case 'response.output_item.done': {
1127
995
  const item = event.item || {};
1128
996
  pushReasoningItem(item);
1129
997
  pushWebSearchCall(item);
1130
- pushCompactionItem(item);
1131
998
  if (item.type === 'function_call') {
1132
999
  const tc = toolCalls.find(t => t._pendingItemId === (item.id || ''));
1133
1000
  if (tc) {
@@ -1138,6 +1005,11 @@ export async function sendViaHttpSse({
1138
1005
  emitToolCall(tc);
1139
1006
  }
1140
1007
  }
1008
+ } else if (item.type === 'tool_search_call') {
1009
+ pushToolSearchCall(item);
1010
+ } else if (item.type === 'custom_tool_call') {
1011
+ pushCustomToolCall(item);
1012
+ meaningful();
1141
1013
  }
1142
1014
  break;
1143
1015
  }
@@ -1165,8 +1037,11 @@ export async function sendViaHttpSse({
1165
1037
  pushReasoningItem(item);
1166
1038
  } else if (item.type === 'web_search_call') {
1167
1039
  pushWebSearchCall(item);
1168
- } else if (['compaction', 'compaction_summary', 'context_compaction'].includes(item.type)) {
1169
- pushCompactionItem(item);
1040
+ } else if (item.type === 'tool_search_call') {
1041
+ pushToolSearchCall(item);
1042
+ } else if (item.type === 'custom_tool_call') {
1043
+ pushCustomToolCall(item);
1044
+ meaningful();
1170
1045
  } else if (item.type === 'function_call') {
1171
1046
  // Match the still-pending placeholder by item id, or
1172
1047
  // an already-recorded call by its canonical call_id —
@@ -1282,15 +1157,12 @@ export async function sendViaHttpSse({
1282
1157
  if (unresolved) {
1283
1158
  throw new Error(`OpenAI OAuth HTTP fallback function_call salvage failed: missing call_id/name for item_id=${unresolved._pendingItemId || '?'}`);
1284
1159
  }
1285
- if (opts?.expectCompaction === true && compactionItems.length !== 1) {
1286
- throw new Error(`OpenAI OAuth HTTP fallback remote compaction expected exactly one compaction output item, got ${compactionItems.length}`);
1287
- }
1288
1160
  if (!completed && !content && !toolCalls.length) {
1289
1161
  throw new Error('OpenAI OAuth HTTP fallback ended before response.completed');
1290
1162
  }
1291
1163
 
1292
1164
  const liveModel = model || useModel;
1293
- traceBridgeSse({
1165
+ traceAgentSse({
1294
1166
  sessionId: poolKey,
1295
1167
  sseParseMs: Date.now() - sseStartedAt,
1296
1168
  ttftMs,
@@ -1299,7 +1171,7 @@ export async function sendViaHttpSse({
1299
1171
  transport: 'sse',
1300
1172
  });
1301
1173
  if (usage) {
1302
- traceBridgeUsage({
1174
+ traceAgentUsage({
1303
1175
  sessionId: poolKey,
1304
1176
  iteration,
1305
1177
  inputTokens: usage.inputTokens || 0,
@@ -1318,8 +1190,6 @@ export async function sendViaHttpSse({
1318
1190
  content,
1319
1191
  model: liveModel,
1320
1192
  reasoningItems: reasoningItems.length ? reasoningItems : undefined,
1321
- compactionItem: compactionItems.length === 1 ? compactionItems[0] : undefined,
1322
- compactionItems: compactionItems.length ? compactionItems : undefined,
1323
1193
  toolCalls: toolCalls.length ? toolCalls.map(({ _pendingItemId, ...t }) => t) : undefined,
1324
1194
  citations: citations.length ? citations : undefined,
1325
1195
  webSearchCalls: webSearchCalls.length ? webSearchCalls : undefined,
@@ -1339,137 +1209,29 @@ export class OpenAIOAuthProvider {
1339
1209
  tokens = null;
1340
1210
  _refreshFallbackUntil = 0;
1341
1211
  _forceHttpFallback = false;
1212
+ _forceHttpFallbackUntil = 0;
1342
1213
  config;
1343
1214
  constructor(config) {
1344
1215
  this.config = config || {};
1345
1216
  this.tokens = loadTokens();
1346
- // Warm a kept-alive socket to the Codex responses API so the first
1217
+ // Warm a kept-alive socket to the OAuth responses API so the first
1347
1218
  // request skips the cold TLS handshake. Best-effort; never throws.
1348
1219
  preconnect('https://chatgpt.com');
1349
1220
  }
1350
1221
  getCachedModelInfo(model) {
1351
1222
  return _findCachedCodexModel(model);
1352
1223
  }
1353
- async remoteCompactMessages(messages, model, tools, sendOpts = {}) {
1354
- const opts = {
1355
- ...sendOpts,
1356
- expectCompaction: true,
1357
- nativeCompact: true,
1358
- };
1359
- const useModel = model || await ensureLatestCodexModel(this);
1360
- const promptCacheLane = resolveProviderPromptCacheLane('openai-oauth', opts, this.config);
1361
- const bodyOpts = {
1362
- ...opts,
1363
- promptCacheLane,
1364
- };
1365
- const body = buildRemoteCompactionRequestBody(messages, useModel, tools, bodyOpts);
1366
- let auth = await this.ensureAuth();
1367
- const poolKey = opts.sessionId || null;
1368
- const cacheKey = body.prompt_cache_key || resolveProviderCacheKey(opts, 'openai-oauth');
1369
- const iteration = Number.isFinite(Number(opts.iteration)) ? Number(opts.iteration) : null;
1370
- const onStageChange = typeof opts.onStageChange === 'function' ? opts.onStageChange : null;
1371
- const sendWs = typeof opts._sendViaWebSocketFn === 'function' ? opts._sendViaWebSocketFn : sendViaWebSocket;
1372
- const sendHttp = typeof opts._sendViaHttpSseFn === 'function' ? opts._sendViaHttpSseFn : sendViaHttpSse;
1373
- const dispatchHttp = async (reason, originalErr = null) => {
1374
- appendBridgeTrace({
1375
- sessionId: poolKey,
1376
- iteration,
1377
- kind: 'transport_fallback',
1378
- provider: 'openai-oauth',
1379
- model: useModel,
1380
- transport: 'http',
1381
- payload: {
1382
- from: 'websocket',
1383
- to: 'http',
1384
- reason,
1385
- remote_compact: true,
1386
- error_code: originalErr?.code || null,
1387
- error_http_status: Number(originalErr?.httpStatus || 0) || null,
1388
- },
1389
- });
1390
- return sendHttp({
1391
- auth,
1392
- body,
1393
- opts,
1394
- onStreamDelta: null,
1395
- onToolCall: null,
1396
- onStageChange,
1397
- externalSignal: opts.signal || null,
1398
- poolKey,
1399
- cacheKey,
1400
- iteration,
1401
- useModel,
1402
- fetchFn: opts._fetchFn,
1403
- });
1404
- };
1405
- const dispatchWs = (forceFresh = false) => sendWs({
1406
- auth,
1407
- body,
1408
- sendOpts: opts,
1409
- onStreamDelta: null,
1410
- onToolCall: null,
1411
- onStageChange,
1412
- externalSignal: opts.signal || null,
1413
- poolKey,
1414
- cacheKey,
1415
- iteration,
1416
- useModel,
1417
- displayModel: _displayCodexModel,
1418
- forceFresh,
1419
- includeResponseId: true,
1420
- });
1421
-
1422
- let result;
1423
- if (opts.forceHttpFallback === true
1424
- || this._forceHttpFallback
1425
- || _envFlag('MIXDOG_OPENAI_OAUTH_FORCE_HTTP_FALLBACK', false)) {
1426
- result = await dispatchHttp('forced');
1427
- } else {
1428
- try {
1429
- result = await dispatchWs(false);
1430
- } catch (err) {
1431
- const status = err?.httpStatus;
1432
- if (status === 401 || status === 403) {
1433
- this._refreshFallbackUntil = 0;
1434
- auth = await this.ensureAuth({ forceRefresh: true, reason: String(status) });
1435
- result = await dispatchWs(true);
1436
- } else if (_shouldUseOpenAIHttpFallback(err, opts.signal || null)) {
1437
- result = await dispatchHttp(err?.retryClassifier || err?.midstreamClassifier || err?.code || err?.message || 'ws_failed', err);
1438
- } else {
1439
- throw err;
1440
- }
1441
- }
1442
- }
1443
-
1444
- if (!result?.compactionItem) {
1445
- throw new Error('OpenAI OAuth remote compact completed without compaction item');
1446
- }
1447
- const liveModel = result.model || useModel;
1448
- if (liveModel && !_codexCatalogHas(liveModel)) void this._refreshModelCache();
1449
- const nativePrefix = _buildOpenAICodexNativeCompactPrefix(messages, result.compactionItem);
1450
- return {
1451
- model: liveModel,
1452
- usage: result.usage,
1453
- responseId: result.responseId || null,
1454
- providerState: _withOpenAICodexRemoteCompactState(opts.providerState, {
1455
- model: useModel,
1456
- nativePrefix,
1457
- responseId: result.responseId || null,
1458
- }),
1459
- };
1460
- }
1461
1224
  async ensureAuth({ forceRefresh = false, reason = 'preemptive' } = {}) {
1462
1225
  if (!this.tokens) this.tokens = loadTokens();
1463
1226
  if (!this.tokens)
1464
- throw new Error('OpenAI OAuth not authenticated. Run codex login first.');
1465
- // Pick up disk-rotated tokens (codex login, host refresh) the moment
1466
- // the auth file is rewritten — without this, a fresh login is ignored
1467
- // until the in-memory token hits its expiry skew.
1227
+ throw new Error('OpenAI OAuth not authenticated. Open /providers in mixdog to sign in.');
1228
+ // Pick up Mixdog-owned token updates the moment the auth file is
1229
+ // rewritten — without this, a fresh login is ignored until the in-memory
1230
+ // token hits its expiry skew.
1468
1231
  const diskMtime = _tokensMaxMtime();
1469
- // Watermark guards termination: if the newest file on disk isn't loadable
1470
- // (e.g. a logged-out host auth.json beside a valid own store), loadTokens
1471
- // falls back to the older valid store; record the scanned mtime so this
1472
- // check can't re-fire on every ensureAuth().
1232
+ // Watermark guards termination: if the rewritten file is temporarily
1233
+ // unreadable/partial, record the scanned mtime so this check can't
1234
+ // re-fire on every ensureAuth().
1473
1235
  if (diskMtime > 0 && diskMtime > (this._lastDiskScan || 0) && diskMtime > (this.tokens._mtimeMs || 0)) {
1474
1236
  const fresh = loadTokens();
1475
1237
  if (fresh?.access_token) {
@@ -1526,30 +1288,16 @@ export class OpenAIOAuthProvider {
1526
1288
  this._refreshFallbackUntil = Date.now() + TOKEN_REFRESH_SKEW_MS;
1527
1289
  return latest;
1528
1290
  }
1529
- throw new Error('OpenAI OAuth refresh token not available. Run codex login to re-authenticate.');
1291
+ throw new Error('OpenAI OAuth refresh token not available. Open /providers in mixdog to sign in again.');
1530
1292
  }
1531
1293
 
1532
1294
  try {
1533
1295
  const _refreshT0 = Date.now();
1534
1296
  const _expiringInMs = (latest?.expires_at ?? 0) - Date.now();
1535
- if (process.env.MIXDOG_DEBUG_BRIDGE) { process.stderr.write(`[bridge-trace] auth-refresh-needed expiringInMs=${_expiringInMs}\n`); }
1297
+ if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] auth-refresh-needed expiringInMs=${_expiringInMs}\n`); }
1536
1298
  process.stderr.write(`[openai-oauth] Token ${reason}, refreshing...\n`);
1537
- let refreshed;
1538
- try {
1539
- refreshed = await refreshTokens(latest.refresh_token);
1540
- } catch (refreshErr) {
1541
- // invalid_grant: the Codex CLI rotated this single-use refresh
1542
- // token between our disk read and this refresh. Re-read both
1543
- // stores and retry ONCE with the freshest different token.
1544
- if (!refreshErr?.isInvalidGrant) throw refreshErr;
1545
- process.stderr.write('[openai-oauth] invalid_grant — re-reading disk, retrying refresh\n');
1546
- const candidates = [_loadOwnCodexTokens(), _loadCodexCliTokens()].filter(Boolean)
1547
- .sort((a, b) => (b._mtimeMs || 0) - (a._mtimeMs || 0));
1548
- const freshTok = candidates.find(c => c.refresh_token && c.refresh_token !== latest.refresh_token);
1549
- if (!freshTok) throw refreshErr;
1550
- refreshed = await refreshTokens(freshTok.refresh_token);
1551
- }
1552
- if (process.env.MIXDOG_DEBUG_BRIDGE) { process.stderr.write(`[bridge-trace] auth-refresh-done elapsed=${Date.now() - _refreshT0}ms ok=${!!refreshed}\n`); }
1299
+ const refreshed = await refreshTokens(latest.refresh_token);
1300
+ if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] auth-refresh-done elapsed=${Date.now() - _refreshT0}ms ok=${!!refreshed}\n`); }
1553
1301
  if (!refreshed) throw new Error('refresh returned null');
1554
1302
  process.stderr.write(`[openai-oauth] Token refreshed, expires in ${Math.round(((refreshed.expires_at || Date.now()) - Date.now()) / 1000)}s\n`);
1555
1303
  return refreshed;
@@ -1561,7 +1309,7 @@ export class OpenAIOAuthProvider {
1561
1309
  process.stderr.write(`[openai-oauth] Refresh failed (${msg}); using still-valid current token\n`);
1562
1310
  return latest;
1563
1311
  }
1564
- throw new Error(`OpenAI OAuth token refresh failed (${msg}). Run codex login to re-authenticate.`);
1312
+ throw new Error(`OpenAI OAuth token refresh failed (${msg}). Re-authenticate via provider login.`);
1565
1313
  }
1566
1314
  })().finally(() => { _oauthRefreshInFlight = null; });
1567
1315
 
@@ -1569,6 +1317,10 @@ export class OpenAIOAuthProvider {
1569
1317
  return this.tokens;
1570
1318
  }
1571
1319
  async send(messages, model, tools, sendOpts) {
1320
+ // Re-warm a kept-alive socket before the turn (TTL-gated no-op while
1321
+ // hot). After an idle gap it re-opens one in parallel with auth/body
1322
+ // build so the HTTP/SSE path skips the cold TLS handshake.
1323
+ preconnect('https://chatgpt.com');
1572
1324
  const opts = sendOpts || {};
1573
1325
  const onStageChange = typeof opts.onStageChange === 'function' ? opts.onStageChange : null;
1574
1326
  const onStreamDelta = typeof opts.onStreamDelta === 'function' ? opts.onStreamDelta : null;
@@ -1577,7 +1329,7 @@ export class OpenAIOAuthProvider {
1577
1329
  const externalSignal = opts.signal || null;
1578
1330
  const _sendSessionId = opts.sessionId || '(none)';
1579
1331
  const _sendRole = opts.role || '(none)';
1580
- if (process.env.MIXDOG_DEBUG_BRIDGE) { process.stderr.write(`[bridge-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`); }
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`); }
1581
1333
  // Build request body in parallel with auth resolution. ensureAuth is
1582
1334
  // a no-op fast-path on cached tokens, but a refresh round-trip can
1583
1335
  // take 300ms+; the body build (message serialisation) overlaps cleanly.
@@ -1597,11 +1349,10 @@ export class OpenAIOAuthProvider {
1597
1349
  const _authP = this.ensureAuth();
1598
1350
  let auth = await _authP;
1599
1351
  const body = await _bodyP;
1600
- const hasImageContent = messagesHaveImageContent(messages);
1601
1352
  // poolKey ≠ cacheKey by design (see openai-oauth-ws.mjs header note).
1602
1353
  // poolKey is per-session so parallel reviewer/worker callers each
1603
1354
  // get their own socket bucket — a sibling cannot grab a mid-turn
1604
- // entry and trip Codex's "No tool call found for function call
1355
+ // entry and trip the backend's "No tool call found for function call
1605
1356
  // output with call_id …" rejection. cacheKey is prefix-scoped
1606
1357
  // (base namespace + model/system/tools hash) and feeds both
1607
1358
  // `body.prompt_cache_key` and the handshake `session_id` header, so
@@ -1625,8 +1376,21 @@ export class OpenAIOAuthProvider {
1625
1376
  }
1626
1377
  return result;
1627
1378
  };
1628
- const dispatchHttp = async (reason, originalErr = null) => {
1629
- appendBridgeTrace({
1379
+ const httpFallbackActive = () => {
1380
+ if (this._forceHttpFallbackUntil > Date.now()) return true;
1381
+ if (this._forceHttpFallback || this._forceHttpFallbackUntil) {
1382
+ this._forceHttpFallback = false;
1383
+ this._forceHttpFallbackUntil = 0;
1384
+ }
1385
+ return false;
1386
+ };
1387
+ const markStickyHttpFallback = () => {
1388
+ const ttlMs = _envPositiveInt('MIXDOG_OPENAI_OAUTH_HTTP_FALLBACK_STICKY_MS', 60_000);
1389
+ this._forceHttpFallback = true;
1390
+ this._forceHttpFallbackUntil = Date.now() + ttlMs;
1391
+ };
1392
+ const dispatchHttp = async (reason, originalErr = null, { sticky = false } = {}) => {
1393
+ appendAgentTrace({
1630
1394
  sessionId: poolKey,
1631
1395
  iteration,
1632
1396
  kind: 'transport_fallback',
@@ -1642,7 +1406,13 @@ export class OpenAIOAuthProvider {
1642
1406
  error_classifier: originalErr?.retryClassifier || originalErr?.midstreamClassifier || null,
1643
1407
  },
1644
1408
  });
1645
- process.stderr.write(`[openai-oauth] WebSocket unhealthy (${reason}); falling back to HTTP/SSE\n`);
1409
+ if (reason === 'forced') {
1410
+ if (_envFlag('MIXDOG_OPENAI_OAUTH_LOG_FORCED_FALLBACK', false)) {
1411
+ process.stderr.write('[openai-oauth] WebSocket bypassed (forced); using HTTP/SSE\n');
1412
+ }
1413
+ } else {
1414
+ process.stderr.write(`[openai-oauth] WebSocket unhealthy (${reason}); falling back to HTTP/SSE\n`);
1415
+ }
1646
1416
  const result = await sendHttp({
1647
1417
  auth,
1648
1418
  body,
@@ -1658,9 +1428,9 @@ export class OpenAIOAuthProvider {
1658
1428
  useModel,
1659
1429
  fetchFn: opts._fetchFn,
1660
1430
  });
1661
- this._forceHttpFallback = true;
1662
- if (process.env.MIXDOG_DEBUG_BRIDGE) {
1663
- process.stderr.write(`[bridge-trace] provider-send-end elapsed=${Date.now() - _t1}ms result=ok transport=http-fallback\n`);
1431
+ if (sticky) markStickyHttpFallback();
1432
+ if (process.env.MIXDOG_DEBUG_AGENT) {
1433
+ process.stderr.write(`[agent-trace] provider-send-end elapsed=${Date.now() - _t1}ms result=ok transport=http-fallback\n`);
1664
1434
  }
1665
1435
  return recordLiveModel(result);
1666
1436
  };
@@ -1681,18 +1451,17 @@ export class OpenAIOAuthProvider {
1681
1451
  forceFresh,
1682
1452
  });
1683
1453
  if (opts.forceHttpFallback === true
1684
- || this._forceHttpFallback
1685
- || hasImageContent
1454
+ || httpFallbackActive()
1686
1455
  || _envFlag('MIXDOG_OPENAI_OAUTH_FORCE_HTTP_FALLBACK', false)) {
1687
- return dispatchHttp(hasImageContent ? 'image_content' : 'forced');
1456
+ return dispatchHttp('forced');
1688
1457
  }
1689
1458
 
1690
1459
  // Prefer WebSocket for hot cache/delta transport; fall back to HTTP/SSE
1691
1460
  // after retry-exhausted handshake/acquire/no-first-event failures.
1692
1461
  try {
1693
- if (process.env.MIXDOG_DEBUG_BRIDGE) { process.stderr.write(`[bridge-trace] provider-send-start model=${useModel} role=${_sendRole} sessionHash=${createHash('sha256').update(String(_sendSessionId)).digest('hex').slice(0, 8)} iteration=${iteration ?? '(none)'}\n`); }
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`); }
1694
1463
  const result = await dispatchWs(false);
1695
- if (process.env.MIXDOG_DEBUG_BRIDGE) { process.stderr.write(`[bridge-trace] provider-send-end elapsed=${Date.now() - _t1}ms result=ok\n`); }
1464
+ if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] provider-send-end elapsed=${Date.now() - _t1}ms result=ok\n`); }
1696
1465
  return recordLiveModel(result);
1697
1466
  } catch (err) {
1698
1467
  const status = err?.httpStatus;
@@ -1705,17 +1474,21 @@ export class OpenAIOAuthProvider {
1705
1474
  const liveTextEmitted = err?.liveTextEmitted === true || err?.unsafeToRetry === true;
1706
1475
  if ((status === 401 || status === 403) && !liveTextEmitted) {
1707
1476
  process.stderr.write(`[openai-oauth-ws] ${status} — forcing refresh and retrying once over WS\n`);
1708
- if (process.env.MIXDOG_DEBUG_BRIDGE) { process.stderr.write(`[bridge-trace] provider-${status}-retry attempt=1\n`); }
1477
+ if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] provider-${status}-retry attempt=1\n`); }
1709
1478
  this._refreshFallbackUntil = 0;
1710
1479
  auth = await this.ensureAuth({ forceRefresh: true, reason: String(status) });
1711
1480
  try {
1712
1481
  const result = await dispatchWs(true);
1713
- if (process.env.MIXDOG_DEBUG_BRIDGE) { process.stderr.write(`[bridge-trace] provider-send-end elapsed=${Date.now() - _t1}ms result=ok\n`); }
1482
+ if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] provider-send-end elapsed=${Date.now() - _t1}ms result=ok\n`); }
1714
1483
  return recordLiveModel(result);
1715
1484
  } catch (retryErr) {
1716
1485
  if (_shouldUseOpenAIHttpFallback(retryErr, externalSignal)) {
1717
1486
  try {
1718
- return await dispatchHttp(retryErr?.retryClassifier || retryErr?.code || retryErr?.message || 'ws_auth_retry_failed', retryErr);
1487
+ return await dispatchHttp(
1488
+ retryErr?.retryClassifier || retryErr?.code || retryErr?.message || 'ws_auth_retry_failed',
1489
+ retryErr,
1490
+ { sticky: true },
1491
+ );
1719
1492
  } catch (fallbackErr) {
1720
1493
  try { retryErr.fallbackError = fallbackErr; } catch {}
1721
1494
  throw retryErr;
@@ -1738,7 +1511,11 @@ export class OpenAIOAuthProvider {
1738
1511
  }
1739
1512
  if (_shouldUseOpenAIHttpFallback(err, externalSignal)) {
1740
1513
  try {
1741
- return await dispatchHttp(err?.retryClassifier || err?.midstreamClassifier || err?.code || err?.message || 'ws_failed', err);
1514
+ return await dispatchHttp(
1515
+ err?.retryClassifier || err?.midstreamClassifier || err?.code || err?.message || 'ws_failed',
1516
+ err,
1517
+ { sticky: true },
1518
+ );
1742
1519
  } catch (fallbackErr) {
1743
1520
  try { err.fallbackError = fallbackErr; } catch {}
1744
1521
  throw err;
@@ -1748,7 +1525,7 @@ export class OpenAIOAuthProvider {
1748
1525
  }
1749
1526
  }
1750
1527
  async listModels() {
1751
- // Dynamic lookup via Codex /backend-api/codex/models. Cached 24h.
1528
+ // Dynamic lookup via /backend-api/codex/models. Cached 24h.
1752
1529
  // Endpoint returns rich metadata (context_window, reasoning levels,
1753
1530
  // visibility) that is more detailed than /v1/models.
1754
1531
  const cached = await _loadCodexModelCache();
@@ -1772,7 +1549,7 @@ export class OpenAIOAuthProvider {
1772
1549
  },
1773
1550
  dispatcher: getLlmDispatcher(),
1774
1551
  });
1775
- if (!res.ok) throw new Error(`codex list_models ${res.status}`);
1552
+ if (!res.ok) throw new Error(`openai-oauth list_models ${res.status}`);
1776
1553
  const data = await res.json();
1777
1554
  const items = Array.isArray(data?.models) ? data.models : [];
1778
1555
  const normalized = items.map(m => _normalizeCodexModel(m));
@@ -1785,7 +1562,7 @@ export class OpenAIOAuthProvider {
1785
1562
  _lastCodexListModelsError = err?.message || String(err);
1786
1563
  process.stderr.write(`[openai-oauth] listModels fetch failed (${_lastCodexListModelsError})\n`);
1787
1564
  // No fallback catalog — empty list signals the UI to show a
1788
- // "catalog unavailable, retry" state. Codex has no equivalent to
1565
+ // "catalog unavailable, retry" state. openai-oauth has no equivalent to
1789
1566
  // Anthropic's family tokens so there's no meaningful minimal list.
1790
1567
  return [];
1791
1568
  }
@@ -1857,7 +1634,64 @@ function _scrubOAuthLoginBody(text) {
1857
1634
  .replace(/[A-Za-z0-9_-]{32,}\.[A-Za-z0-9._-]+/g, '[REDACTED]');
1858
1635
  }
1859
1636
 
1860
- export async function loginOAuth() {
1637
+ function _parseOAuthCodeInput(input) {
1638
+ const value = String(input || '').trim();
1639
+ if (!value) return { code: '', state: '' };
1640
+ try {
1641
+ const url = new URL(value);
1642
+ const code = url.searchParams.get('code') || '';
1643
+ const state = url.searchParams.get('state') || '';
1644
+ if (code || state) return { code, state };
1645
+ } catch { /* not a URL */ }
1646
+ if (value.includes('#')) {
1647
+ const [code, state] = value.split('#', 2);
1648
+ return { code: String(code || '').trim(), state: String(state || '').trim() };
1649
+ }
1650
+ if (value.includes('code=')) {
1651
+ const params = new URLSearchParams(value.startsWith('?') ? value.slice(1) : value);
1652
+ return { code: params.get('code') || '', state: params.get('state') || '' };
1653
+ }
1654
+ return { code: value, state: '' };
1655
+ }
1656
+
1657
+ async function exchangeAuthorizationCode({ pkce, code }) {
1658
+ const cleanCode = String(code || '').trim();
1659
+ if (!cleanCode) throw new Error('[openai-oauth] authorization code is required');
1660
+ const tokenRes = await fetch(TOKEN_URL, {
1661
+ method: 'POST',
1662
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1663
+ body: new URLSearchParams({
1664
+ grant_type: 'authorization_code',
1665
+ code: cleanCode,
1666
+ redirect_uri: REDIRECT_URI,
1667
+ client_id: CLIENT_ID,
1668
+ code_verifier: pkce.verifier,
1669
+ }),
1670
+ redirect: 'error',
1671
+ signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
1672
+ });
1673
+ if (!tokenRes.ok) {
1674
+ const text = await tokenRes.text().catch(() => '');
1675
+ throw new Error(`[openai-oauth] token exchange ${tokenRes.status}: ${_scrubOAuthLoginBody(text).slice(0, 500)}`);
1676
+ }
1677
+ const json = await tokenRes.json();
1678
+ if (!json.access_token || !json.refresh_token) {
1679
+ throw new Error('[openai-oauth] token exchange response missing access_token or refresh_token');
1680
+ }
1681
+ const expiresAt = (typeof json.expires_in === 'number'
1682
+ ? Date.now() + json.expires_in * 1000
1683
+ : 0) || _expiryFromAccessToken(json.access_token);
1684
+ const tokens = {
1685
+ access_token: json.access_token,
1686
+ refresh_token: json.refresh_token,
1687
+ expires_at: expiresAt,
1688
+ account_id: extractAccountId(json.access_token),
1689
+ };
1690
+ saveTokens(tokens);
1691
+ return tokens;
1692
+ }
1693
+
1694
+ export async function beginOAuthLogin() {
1861
1695
  const pkce = generatePKCE();
1862
1696
  const state = randomBytes(16).toString('hex');
1863
1697
  const url = new URL(AUTHORIZE_URL);
@@ -1872,18 +1706,20 @@ export async function loginOAuth() {
1872
1706
  url.searchParams.set('state', state);
1873
1707
  url.searchParams.set('originator', CODEX_OAUTH_ORIGINATOR);
1874
1708
 
1875
- return new Promise((resolve, reject) => {
1709
+ let server = null;
1710
+ let timeout = null;
1711
+ let finish = null;
1712
+ const waitForCallback = new Promise((resolve, reject) => {
1876
1713
  let settled = false;
1877
- let timeout = null;
1878
- const settle = (value, error = null) => {
1714
+ finish = (value, error = null) => {
1879
1715
  if (settled) return;
1880
1716
  settled = true;
1881
1717
  if (timeout) clearTimeout(timeout);
1882
- try { server.close(); } catch { /* already closed */ }
1718
+ try { server?.close(); } catch { /* already closed */ }
1883
1719
  if (error) reject(error);
1884
1720
  else resolve(value);
1885
1721
  };
1886
- const server = createServer(async (req, res) => {
1722
+ server = createServer(async (req, res) => {
1887
1723
  const u = new URL(req.url || '/', `http://${CALLBACK_HOST}:${CALLBACK_PORT}`);
1888
1724
  if (u.pathname !== CALLBACK_PATH) {
1889
1725
  res.writeHead(404);
@@ -1894,53 +1730,21 @@ export async function loginOAuth() {
1894
1730
  if (!code || u.searchParams.get('state') !== state) {
1895
1731
  res.writeHead(400);
1896
1732
  res.end('Invalid');
1897
- settle(null);
1733
+ finish(null);
1898
1734
  return;
1899
1735
  }
1900
1736
  res.writeHead(200, { 'Content-Type': 'text/html' });
1901
- res.end('<html><body><h2>Codex login successful! You can close this tab.</h2></body></html>');
1737
+ res.end('<html><body><h2>OpenAI OAuth login successful! You can close this tab.</h2></body></html>');
1902
1738
  try {
1903
- const tokenRes = await fetch(TOKEN_URL, {
1904
- method: 'POST',
1905
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1906
- body: new URLSearchParams({
1907
- grant_type: 'authorization_code',
1908
- code,
1909
- redirect_uri: REDIRECT_URI,
1910
- client_id: CLIENT_ID,
1911
- code_verifier: pkce.verifier,
1912
- }),
1913
- redirect: 'error',
1914
- signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
1915
- });
1916
- if (!tokenRes.ok) {
1917
- const text = await tokenRes.text().catch(() => '');
1918
- settle(null, new Error(`[openai-oauth] token exchange ${tokenRes.status}: ${_scrubOAuthLoginBody(text).slice(0, 500)}`));
1919
- return;
1920
- }
1921
- const json = await tokenRes.json();
1922
- if (!json.access_token || !json.refresh_token) {
1923
- settle(null, new Error('[openai-oauth] token exchange response missing access_token or refresh_token'));
1924
- return;
1925
- }
1926
- const expiresAt = (typeof json.expires_in === 'number'
1927
- ? Date.now() + json.expires_in * 1000
1928
- : 0) || _expiryFromAccessToken(json.access_token);
1929
- const tokens = {
1930
- access_token: json.access_token,
1931
- refresh_token: json.refresh_token,
1932
- expires_at: expiresAt,
1933
- account_id: extractAccountId(json.access_token),
1934
- };
1935
- saveTokens(tokens);
1936
- settle(tokens);
1739
+ const tokens = await exchangeAuthorizationCode({ pkce, code });
1740
+ finish(tokens);
1937
1741
  } catch (err) {
1938
- settle(null, err instanceof Error ? err : new Error(String(err)));
1742
+ finish(null, err instanceof Error ? err : new Error(String(err)));
1939
1743
  }
1940
1744
  });
1941
- timeout = setTimeout(() => settle(null), LOGIN_TIMEOUT_MS);
1745
+ timeout = setTimeout(() => finish(null), LOGIN_TIMEOUT_MS);
1942
1746
  server.listen(CALLBACK_PORT, CALLBACK_HOST, async () => {
1943
- process.stderr.write(`\n[openai-oauth] Open this URL to log in to ChatGPT (Codex):\n${url.toString()}\n\n`);
1747
+ process.stderr.write(`\n[openai-oauth] Open this URL to log in to ChatGPT (OpenAI OAuth):\n${url.toString()}\n\n`);
1944
1748
  try {
1945
1749
  const { openInBrowser } = await import('../../../shared/open-url.mjs');
1946
1750
  openInBrowser(url.toString());
@@ -1948,6 +1752,27 @@ export async function loginOAuth() {
1948
1752
  process.stderr.write(`[openai-oauth] browser open failed: ${String(err?.message || err).slice(0, 200)}\n`);
1949
1753
  }
1950
1754
  });
1951
- server.on('error', (err) => settle(null, new Error(`[openai-oauth] callback server failed on ${CALLBACK_HOST}:${CALLBACK_PORT}: ${err?.message || err}`)));
1755
+ server.on('error', (err) => finish(null, new Error(`[openai-oauth] callback server failed on ${CALLBACK_HOST}:${CALLBACK_PORT}: ${err?.message || err}`)));
1952
1756
  });
1757
+
1758
+ return {
1759
+ provider: 'openai-oauth',
1760
+ url: url.toString(),
1761
+ waitForCallback,
1762
+ completeCode: async (input) => {
1763
+ const parsed = _parseOAuthCodeInput(input);
1764
+ if (parsed.state && parsed.state !== state) throw new Error('[openai-oauth] OAuth state mismatch');
1765
+ const tokens = await exchangeAuthorizationCode({ pkce, code: parsed.code });
1766
+ finish?.(tokens);
1767
+ return tokens;
1768
+ },
1769
+ cancel: () => {
1770
+ finish?.(null);
1771
+ },
1772
+ };
1773
+ }
1774
+
1775
+ export async function loginOAuth() {
1776
+ const login = await beginOAuthLogin();
1777
+ return await login.waitForCallback;
1953
1778
  }