mixdog 0.8.0 → 0.9.0

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 (285) hide show
  1. package/README.md +47 -23
  2. package/package.json +33 -27
  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 +344 -0
  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 +1223 -95
  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 -7
  62. package/src/lib/rules-builder.cjs +240 -96
  63. package/src/lib/text-utils.cjs +1 -1
  64. package/src/mixdog-session-runtime.mjs +2325 -450
  65. package/src/output-styles/default.md +12 -28
  66. package/src/output-styles/extreme-simple.md +9 -6
  67. package/src/output-styles/simple.md +22 -9
  68. package/src/repl.mjs +118 -59
  69. package/src/rules/agent/00-common.md +15 -0
  70. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  71. package/src/rules/agent/30-explorer.md +22 -0
  72. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  73. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  75. package/src/rules/lead/01-general.md +9 -5
  76. package/src/rules/lead/04-workflow.md +51 -12
  77. package/src/rules/lead/lead-tool.md +6 -0
  78. package/src/rules/shared/01-tool.md +12 -1
  79. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  80. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  81. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  82. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  83. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  85. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  86. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  87. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  88. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  89. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  90. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  91. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  92. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  93. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +377 -243
  95. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +146 -93
  96. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  97. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  99. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  100. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  101. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  102. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  103. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  104. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  106. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +221 -70
  107. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +477 -147
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -496
  109. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  110. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  111. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +31 -11
  112. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  113. package/src/runtime/agent/orchestrator/session/cache/post-edit-marks.mjs +4 -4
  114. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +1 -1
  115. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  116. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  117. package/src/runtime/agent/orchestrator/session/loop.mjs +851 -674
  118. package/src/runtime/agent/orchestrator/session/manager.mjs +1593 -466
  119. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  120. package/src/runtime/agent/orchestrator/session/store.mjs +291 -46
  121. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  122. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  123. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  124. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +34 -7
  125. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
  126. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -0
  127. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +9 -9
  128. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  129. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  130. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  131. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  132. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  133. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  139. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  140. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  141. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  142. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  143. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  144. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +1 -1
  145. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  146. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  147. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  148. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  149. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  150. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  151. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  152. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  153. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  154. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  155. package/src/runtime/channels/index.mjs +152 -24
  156. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  157. package/src/runtime/channels/lib/session-discovery.mjs +3 -2
  158. package/src/runtime/channels/lib/tool-format.mjs +0 -2
  159. package/src/runtime/channels/lib/transcript-discovery.mjs +3 -2
  160. package/src/runtime/channels/lib/webhook.mjs +1 -1
  161. package/src/runtime/channels/tool-defs.mjs +29 -29
  162. package/src/runtime/memory/index.mjs +635 -107
  163. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  164. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  165. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  166. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  167. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  168. package/src/runtime/memory/lib/memory-cycle1.mjs +38 -17
  169. package/src/runtime/memory/lib/memory-cycle2.mjs +6 -7
  170. package/src/runtime/memory/lib/memory-cycle3.mjs +4 -4
  171. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -1
  172. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  173. package/src/runtime/memory/lib/memory.mjs +88 -9
  174. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  175. package/src/runtime/memory/lib/pg/adapter.mjs +15 -1
  176. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  177. package/src/runtime/memory/lib/runtime-fetcher.mjs +37 -3
  178. package/src/runtime/memory/lib/session-ingest.mjs +194 -0
  179. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  180. package/src/runtime/memory/tool-defs.mjs +46 -37
  181. package/src/runtime/search/index.mjs +102 -466
  182. package/src/runtime/search/lib/web-tools.mjs +45 -25
  183. package/src/runtime/search/tool-defs.mjs +16 -23
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/atomic-file.mjs +4 -3
  186. package/src/runtime/shared/background-tasks.mjs +122 -11
  187. package/src/runtime/shared/child-spawn-gate.mjs +145 -0
  188. package/src/runtime/shared/config.mjs +7 -4
  189. package/src/runtime/shared/err-text.mjs +131 -4
  190. package/src/runtime/shared/llm/cost.mjs +2 -2
  191. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  192. package/src/runtime/shared/llm/index.mjs +34 -11
  193. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  194. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  195. package/src/runtime/shared/singleton-owner.mjs +104 -0
  196. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  197. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  198. package/src/runtime/shared/tool-surface.mjs +624 -98
  199. package/src/runtime/shared/user-data-guard.mjs +0 -2
  200. package/src/standalone/agent-task-status.mjs +203 -0
  201. package/src/standalone/agent-task-status.test.mjs +76 -0
  202. package/src/standalone/agent-tool.mjs +1913 -0
  203. package/src/standalone/channel-worker.mjs +370 -14
  204. package/src/standalone/explore-tool.mjs +165 -70
  205. package/src/standalone/folder-dialog.mjs +314 -0
  206. package/src/standalone/hook-bus.mjs +898 -22
  207. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  208. package/src/standalone/projects.mjs +226 -0
  209. package/src/standalone/provider-admin.mjs +41 -24
  210. package/src/standalone/seeds.mjs +2 -69
  211. package/src/standalone/usage-dashboard.mjs +96 -8
  212. package/src/tui/App.jsx +4798 -2153
  213. package/src/tui/components/AnsiText.jsx +39 -28
  214. package/src/tui/components/ContextPanel.jsx +87 -29
  215. package/src/tui/components/Markdown.jsx +43 -77
  216. package/src/tui/components/MarkdownTable.jsx +9 -184
  217. package/src/tui/components/Message.jsx +28 -11
  218. package/src/tui/components/Picker.jsx +95 -56
  219. package/src/tui/components/PromptInput.jsx +367 -239
  220. package/src/tui/components/QueuedCommands.jsx +1 -1
  221. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  222. package/src/tui/components/Spinner.jsx +67 -38
  223. package/src/tui/components/StatusLine.jsx +606 -38
  224. package/src/tui/components/TextEntryPanel.jsx +128 -9
  225. package/src/tui/components/ToolExecution.jsx +617 -368
  226. package/src/tui/components/TurnDone.jsx +3 -3
  227. package/src/tui/components/UsagePanel.jsx +3 -5
  228. package/src/tui/components/tool-output-format.mjs +365 -0
  229. package/src/tui/components/tool-output-format.test.mjs +220 -0
  230. package/src/tui/dist/index.mjs +8915 -2418
  231. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  232. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  233. package/src/tui/engine.mjs +1455 -279
  234. package/src/tui/figures.mjs +21 -40
  235. package/src/tui/index.jsx +75 -31
  236. package/src/tui/input-editing.mjs +25 -0
  237. package/src/tui/markdown/format-token.mjs +511 -68
  238. package/src/tui/markdown/format-token.test.mjs +216 -0
  239. package/src/tui/markdown/render-ansi.mjs +94 -0
  240. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  241. package/src/tui/markdown/stream-fence.mjs +34 -0
  242. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  243. package/src/tui/markdown/table-layout.mjs +250 -0
  244. package/src/tui/paste-attachments.mjs +0 -7
  245. package/src/tui/spinner-verbs.mjs +1 -2
  246. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  247. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  248. package/src/tui/theme.mjs +746 -24
  249. package/src/tui/time-format.mjs +1 -1
  250. package/src/tui/transcript-tool-failures.mjs +67 -0
  251. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  252. package/src/ui/ansi.mjs +1 -2
  253. package/src/ui/markdown.mjs +85 -26
  254. package/src/ui/markdown.test.mjs +70 -0
  255. package/src/ui/model-display.mjs +121 -0
  256. package/src/ui/session-stats.mjs +44 -0
  257. package/src/ui/statusline-context-label.test.mjs +15 -0
  258. package/src/ui/statusline.mjs +386 -178
  259. package/src/ui/tool-card.mjs +3 -16
  260. package/src/vendor/statusline/bin/statusline-lib.mjs +8 -4
  261. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  262. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  263. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  264. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  265. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  266. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  267. package/src/workflows/default/WORKFLOW.md +12 -5
  268. package/src/workflows/default/workflow.json +0 -1
  269. package/src/workflows/solo/WORKFLOW.md +15 -0
  270. package/src/workflows/solo/workflow.json +7 -0
  271. package/vendor/ink/build/output.js +6 -1
  272. package/src/agents/scheduler-task.md +0 -3
  273. package/src/agents/web-researcher/AGENT.md +0 -3
  274. package/src/agents/web-researcher/agent.json +0 -6
  275. package/src/agents/webhook-handler.md +0 -3
  276. package/src/rules/bridge/00-common.md +0 -5
  277. package/src/rules/bridge/30-explorer.md +0 -4
  278. package/src/rules/lead/00-tool-lead.md +0 -5
  279. package/src/rules/shared/00-language.md +0 -3
  280. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  281. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  282. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  283. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  284. package/src/standalone/bridge-tool.mjs +0 -1414
  285. 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,13 +21,13 @@ 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,
@@ -36,24 +35,28 @@ import {
36
35
  } from '../stall-policy.mjs';
37
36
  import { populateHttpStatusFromMessage } 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
 
@@ -892,6 +749,7 @@ function _shouldUseOpenAIHttpFallback(err, externalSignal) {
892
749
  // chunk to the client, the HTTP fallback would re-run the request and
893
750
  // concatenate a second attempt onto rendered output. Never fall back.
894
751
  if (err?.liveTextEmitted === true) return false;
752
+ if (err?.emittedToolCall === true || err?.unsafeToRetry === true) return false;
895
753
  const status = Number(err?.httpStatus || err?.status || 0);
896
754
  if (status === 401 || status === 403 || status === 404 || status === 429) return false;
897
755
  if (status >= 500 && status < 600) return true;
@@ -900,7 +758,13 @@ function _shouldUseOpenAIHttpFallback(err, externalSignal) {
900
758
  return true;
901
759
  }
902
760
  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)) {
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)) {
904
768
  return true;
905
769
  }
906
770
  if (/^http_5\d\d$/.test(classifier)) return true;
@@ -960,7 +824,7 @@ export async function sendViaHttpSse({
960
824
  headerTimeout.cleanup();
961
825
  }
962
826
 
963
- traceBridgeFetch({
827
+ traceAgentFetch({
964
828
  sessionId: poolKey,
965
829
  headersMs: Date.now() - fetchStartedAt,
966
830
  httpStatus: response.status,
@@ -1019,7 +883,6 @@ export async function sendViaHttpSse({
1019
883
  const toolCalls = [];
1020
884
  const pendingCalls = new Map();
1021
885
  const reasoningItems = [];
1022
- const compactionItems = [];
1023
886
  const citations = [];
1024
887
  const citationKeys = new Set();
1025
888
  const webSearchCalls = [];
@@ -1067,10 +930,34 @@ export async function sendViaHttpSse({
1067
930
  });
1068
931
  }
1069
932
  };
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);
933
+ const pushToolSearchCall = (item) => {
934
+ if (!item || item.type !== 'tool_search_call') return;
935
+ const callId = item.call_id || item.id || '';
936
+ if (!callId || toolCalls.some(t => t.id === callId)) return;
937
+ let args = {};
938
+ if (item.arguments && typeof item.arguments === 'object') {
939
+ args = item.arguments;
940
+ } else if (typeof item.arguments === 'string' && item.arguments.trim()) {
941
+ // Non-empty but malformed tool_search arguments are deterministic
942
+ // bad JSON (the item is only emitted on completion). Surface an
943
+ // invalid-args marker instead of swallowing to {} so the model can
944
+ // self-correct in the same turn.
945
+ args = _parseJsonObject(item.arguments);
946
+ }
947
+ const call = {
948
+ id: callId,
949
+ name: 'tool_search',
950
+ arguments: args,
951
+ nativeType: 'tool_search_call',
952
+ };
953
+ toolCalls.push(call);
954
+ emitToolCall(call);
955
+ };
956
+ const pushCustomToolCall = (item) => {
957
+ const call = customToolCallFromResponseItem(item);
958
+ if (!call || toolCalls.some(t => t.id === call.id)) return;
959
+ toolCalls.push(call);
960
+ emitToolCall(call);
1074
961
  };
1075
962
  const meaningful = () => {
1076
963
  if (ttftMs == null) ttftMs = Date.now() - sseStartedAt;
@@ -1123,11 +1010,13 @@ export async function sendViaHttpSse({
1123
1010
  meaningful();
1124
1011
  break;
1125
1012
  }
1013
+ case 'response.custom_tool_call_input.delta':
1014
+ meaningful();
1015
+ break;
1126
1016
  case 'response.output_item.done': {
1127
1017
  const item = event.item || {};
1128
1018
  pushReasoningItem(item);
1129
1019
  pushWebSearchCall(item);
1130
- pushCompactionItem(item);
1131
1020
  if (item.type === 'function_call') {
1132
1021
  const tc = toolCalls.find(t => t._pendingItemId === (item.id || ''));
1133
1022
  if (tc) {
@@ -1138,6 +1027,11 @@ export async function sendViaHttpSse({
1138
1027
  emitToolCall(tc);
1139
1028
  }
1140
1029
  }
1030
+ } else if (item.type === 'tool_search_call') {
1031
+ pushToolSearchCall(item);
1032
+ } else if (item.type === 'custom_tool_call') {
1033
+ pushCustomToolCall(item);
1034
+ meaningful();
1141
1035
  }
1142
1036
  break;
1143
1037
  }
@@ -1165,8 +1059,11 @@ export async function sendViaHttpSse({
1165
1059
  pushReasoningItem(item);
1166
1060
  } else if (item.type === 'web_search_call') {
1167
1061
  pushWebSearchCall(item);
1168
- } else if (['compaction', 'compaction_summary', 'context_compaction'].includes(item.type)) {
1169
- pushCompactionItem(item);
1062
+ } else if (item.type === 'tool_search_call') {
1063
+ pushToolSearchCall(item);
1064
+ } else if (item.type === 'custom_tool_call') {
1065
+ pushCustomToolCall(item);
1066
+ meaningful();
1170
1067
  } else if (item.type === 'function_call') {
1171
1068
  // Match the still-pending placeholder by item id, or
1172
1069
  // an already-recorded call by its canonical call_id —
@@ -1282,15 +1179,12 @@ export async function sendViaHttpSse({
1282
1179
  if (unresolved) {
1283
1180
  throw new Error(`OpenAI OAuth HTTP fallback function_call salvage failed: missing call_id/name for item_id=${unresolved._pendingItemId || '?'}`);
1284
1181
  }
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
1182
  if (!completed && !content && !toolCalls.length) {
1289
1183
  throw new Error('OpenAI OAuth HTTP fallback ended before response.completed');
1290
1184
  }
1291
1185
 
1292
1186
  const liveModel = model || useModel;
1293
- traceBridgeSse({
1187
+ traceAgentSse({
1294
1188
  sessionId: poolKey,
1295
1189
  sseParseMs: Date.now() - sseStartedAt,
1296
1190
  ttftMs,
@@ -1299,7 +1193,7 @@ export async function sendViaHttpSse({
1299
1193
  transport: 'sse',
1300
1194
  });
1301
1195
  if (usage) {
1302
- traceBridgeUsage({
1196
+ traceAgentUsage({
1303
1197
  sessionId: poolKey,
1304
1198
  iteration,
1305
1199
  inputTokens: usage.inputTokens || 0,
@@ -1318,8 +1212,6 @@ export async function sendViaHttpSse({
1318
1212
  content,
1319
1213
  model: liveModel,
1320
1214
  reasoningItems: reasoningItems.length ? reasoningItems : undefined,
1321
- compactionItem: compactionItems.length === 1 ? compactionItems[0] : undefined,
1322
- compactionItems: compactionItems.length ? compactionItems : undefined,
1323
1215
  toolCalls: toolCalls.length ? toolCalls.map(({ _pendingItemId, ...t }) => t) : undefined,
1324
1216
  citations: citations.length ? citations : undefined,
1325
1217
  webSearchCalls: webSearchCalls.length ? webSearchCalls : undefined,
@@ -1339,137 +1231,29 @@ export class OpenAIOAuthProvider {
1339
1231
  tokens = null;
1340
1232
  _refreshFallbackUntil = 0;
1341
1233
  _forceHttpFallback = false;
1234
+ _forceHttpFallbackUntil = 0;
1342
1235
  config;
1343
1236
  constructor(config) {
1344
1237
  this.config = config || {};
1345
1238
  this.tokens = loadTokens();
1346
- // Warm a kept-alive socket to the Codex responses API so the first
1239
+ // Warm a kept-alive socket to the OAuth responses API so the first
1347
1240
  // request skips the cold TLS handshake. Best-effort; never throws.
1348
1241
  preconnect('https://chatgpt.com');
1349
1242
  }
1350
1243
  getCachedModelInfo(model) {
1351
1244
  return _findCachedCodexModel(model);
1352
1245
  }
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
1246
  async ensureAuth({ forceRefresh = false, reason = 'preemptive' } = {}) {
1462
1247
  if (!this.tokens) this.tokens = loadTokens();
1463
1248
  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.
1249
+ throw new Error('OpenAI OAuth not authenticated. Open /providers in mixdog to sign in.');
1250
+ // Pick up Mixdog-owned token updates the moment the auth file is
1251
+ // rewritten — without this, a fresh login is ignored until the in-memory
1252
+ // token hits its expiry skew.
1468
1253
  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().
1254
+ // Watermark guards termination: if the rewritten file is temporarily
1255
+ // unreadable/partial, record the scanned mtime so this check can't
1256
+ // re-fire on every ensureAuth().
1473
1257
  if (diskMtime > 0 && diskMtime > (this._lastDiskScan || 0) && diskMtime > (this.tokens._mtimeMs || 0)) {
1474
1258
  const fresh = loadTokens();
1475
1259
  if (fresh?.access_token) {
@@ -1526,30 +1310,16 @@ export class OpenAIOAuthProvider {
1526
1310
  this._refreshFallbackUntil = Date.now() + TOKEN_REFRESH_SKEW_MS;
1527
1311
  return latest;
1528
1312
  }
1529
- throw new Error('OpenAI OAuth refresh token not available. Run codex login to re-authenticate.');
1313
+ throw new Error('OpenAI OAuth refresh token not available. Open /providers in mixdog to sign in again.');
1530
1314
  }
1531
1315
 
1532
1316
  try {
1533
1317
  const _refreshT0 = Date.now();
1534
1318
  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`); }
1319
+ if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] auth-refresh-needed expiringInMs=${_expiringInMs}\n`); }
1536
1320
  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`); }
1321
+ const refreshed = await refreshTokens(latest.refresh_token);
1322
+ if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] auth-refresh-done elapsed=${Date.now() - _refreshT0}ms ok=${!!refreshed}\n`); }
1553
1323
  if (!refreshed) throw new Error('refresh returned null');
1554
1324
  process.stderr.write(`[openai-oauth] Token refreshed, expires in ${Math.round(((refreshed.expires_at || Date.now()) - Date.now()) / 1000)}s\n`);
1555
1325
  return refreshed;
@@ -1561,7 +1331,7 @@ export class OpenAIOAuthProvider {
1561
1331
  process.stderr.write(`[openai-oauth] Refresh failed (${msg}); using still-valid current token\n`);
1562
1332
  return latest;
1563
1333
  }
1564
- throw new Error(`OpenAI OAuth token refresh failed (${msg}). Run codex login to re-authenticate.`);
1334
+ throw new Error(`OpenAI OAuth token refresh failed (${msg}). Re-authenticate via provider login.`);
1565
1335
  }
1566
1336
  })().finally(() => { _oauthRefreshInFlight = null; });
1567
1337
 
@@ -1569,6 +1339,10 @@ export class OpenAIOAuthProvider {
1569
1339
  return this.tokens;
1570
1340
  }
1571
1341
  async send(messages, model, tools, sendOpts) {
1342
+ // Re-warm a kept-alive socket before the turn (TTL-gated no-op while
1343
+ // hot). After an idle gap it re-opens one in parallel with auth/body
1344
+ // build so the HTTP/SSE path skips the cold TLS handshake.
1345
+ preconnect('https://chatgpt.com');
1572
1346
  const opts = sendOpts || {};
1573
1347
  const onStageChange = typeof opts.onStageChange === 'function' ? opts.onStageChange : null;
1574
1348
  const onStreamDelta = typeof opts.onStreamDelta === 'function' ? opts.onStreamDelta : null;
@@ -1577,7 +1351,7 @@ export class OpenAIOAuthProvider {
1577
1351
  const externalSignal = opts.signal || null;
1578
1352
  const _sendSessionId = opts.sessionId || '(none)';
1579
1353
  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`); }
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`); }
1581
1355
  // Build request body in parallel with auth resolution. ensureAuth is
1582
1356
  // a no-op fast-path on cached tokens, but a refresh round-trip can
1583
1357
  // take 300ms+; the body build (message serialisation) overlaps cleanly.
@@ -1597,11 +1371,10 @@ export class OpenAIOAuthProvider {
1597
1371
  const _authP = this.ensureAuth();
1598
1372
  let auth = await _authP;
1599
1373
  const body = await _bodyP;
1600
- const hasImageContent = messagesHaveImageContent(messages);
1601
1374
  // poolKey ≠ cacheKey by design (see openai-oauth-ws.mjs header note).
1602
1375
  // poolKey is per-session so parallel reviewer/worker callers each
1603
1376
  // 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
1377
+ // entry and trip the backend's "No tool call found for function call
1605
1378
  // output with call_id …" rejection. cacheKey is prefix-scoped
1606
1379
  // (base namespace + model/system/tools hash) and feeds both
1607
1380
  // `body.prompt_cache_key` and the handshake `session_id` header, so
@@ -1625,8 +1398,21 @@ export class OpenAIOAuthProvider {
1625
1398
  }
1626
1399
  return result;
1627
1400
  };
1628
- const dispatchHttp = async (reason, originalErr = null) => {
1629
- appendBridgeTrace({
1401
+ const httpFallbackActive = () => {
1402
+ if (this._forceHttpFallbackUntil > Date.now()) return true;
1403
+ if (this._forceHttpFallback || this._forceHttpFallbackUntil) {
1404
+ this._forceHttpFallback = false;
1405
+ this._forceHttpFallbackUntil = 0;
1406
+ }
1407
+ return false;
1408
+ };
1409
+ const markStickyHttpFallback = () => {
1410
+ const ttlMs = _envPositiveInt('MIXDOG_OPENAI_OAUTH_HTTP_FALLBACK_STICKY_MS', 60_000);
1411
+ this._forceHttpFallback = true;
1412
+ this._forceHttpFallbackUntil = Date.now() + ttlMs;
1413
+ };
1414
+ const dispatchHttp = async (reason, originalErr = null, { sticky = false } = {}) => {
1415
+ appendAgentTrace({
1630
1416
  sessionId: poolKey,
1631
1417
  iteration,
1632
1418
  kind: 'transport_fallback',
@@ -1642,7 +1428,13 @@ export class OpenAIOAuthProvider {
1642
1428
  error_classifier: originalErr?.retryClassifier || originalErr?.midstreamClassifier || null,
1643
1429
  },
1644
1430
  });
1645
- process.stderr.write(`[openai-oauth] WebSocket unhealthy (${reason}); falling back to HTTP/SSE\n`);
1431
+ if (reason === 'forced') {
1432
+ if (_envFlag('MIXDOG_OPENAI_OAUTH_LOG_FORCED_FALLBACK', false)) {
1433
+ process.stderr.write('[openai-oauth] WebSocket bypassed (forced); using HTTP/SSE\n');
1434
+ }
1435
+ } else {
1436
+ process.stderr.write(`[openai-oauth] WebSocket unhealthy (${reason}); falling back to HTTP/SSE\n`);
1437
+ }
1646
1438
  const result = await sendHttp({
1647
1439
  auth,
1648
1440
  body,
@@ -1658,9 +1450,9 @@ export class OpenAIOAuthProvider {
1658
1450
  useModel,
1659
1451
  fetchFn: opts._fetchFn,
1660
1452
  });
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`);
1453
+ if (sticky) markStickyHttpFallback();
1454
+ if (process.env.MIXDOG_DEBUG_AGENT) {
1455
+ process.stderr.write(`[agent-trace] provider-send-end elapsed=${Date.now() - _t1}ms result=ok transport=http-fallback\n`);
1664
1456
  }
1665
1457
  return recordLiveModel(result);
1666
1458
  };
@@ -1681,18 +1473,17 @@ export class OpenAIOAuthProvider {
1681
1473
  forceFresh,
1682
1474
  });
1683
1475
  if (opts.forceHttpFallback === true
1684
- || this._forceHttpFallback
1685
- || hasImageContent
1476
+ || httpFallbackActive()
1686
1477
  || _envFlag('MIXDOG_OPENAI_OAUTH_FORCE_HTTP_FALLBACK', false)) {
1687
- return dispatchHttp(hasImageContent ? 'image_content' : 'forced');
1478
+ return dispatchHttp('forced');
1688
1479
  }
1689
1480
 
1690
1481
  // Prefer WebSocket for hot cache/delta transport; fall back to HTTP/SSE
1691
1482
  // after retry-exhausted handshake/acquire/no-first-event failures.
1692
1483
  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`); }
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`); }
1694
1485
  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`); }
1486
+ if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] provider-send-end elapsed=${Date.now() - _t1}ms result=ok\n`); }
1696
1487
  return recordLiveModel(result);
1697
1488
  } catch (err) {
1698
1489
  const status = err?.httpStatus;
@@ -1705,17 +1496,21 @@ export class OpenAIOAuthProvider {
1705
1496
  const liveTextEmitted = err?.liveTextEmitted === true || err?.unsafeToRetry === true;
1706
1497
  if ((status === 401 || status === 403) && !liveTextEmitted) {
1707
1498
  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`); }
1499
+ if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] provider-${status}-retry attempt=1\n`); }
1709
1500
  this._refreshFallbackUntil = 0;
1710
1501
  auth = await this.ensureAuth({ forceRefresh: true, reason: String(status) });
1711
1502
  try {
1712
1503
  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`); }
1504
+ if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] provider-send-end elapsed=${Date.now() - _t1}ms result=ok\n`); }
1714
1505
  return recordLiveModel(result);
1715
1506
  } catch (retryErr) {
1716
1507
  if (_shouldUseOpenAIHttpFallback(retryErr, externalSignal)) {
1717
1508
  try {
1718
- return await dispatchHttp(retryErr?.retryClassifier || retryErr?.code || retryErr?.message || 'ws_auth_retry_failed', retryErr);
1509
+ return await dispatchHttp(
1510
+ retryErr?.retryClassifier || retryErr?.code || retryErr?.message || 'ws_auth_retry_failed',
1511
+ retryErr,
1512
+ { sticky: true },
1513
+ );
1719
1514
  } catch (fallbackErr) {
1720
1515
  try { retryErr.fallbackError = fallbackErr; } catch {}
1721
1516
  throw retryErr;
@@ -1738,7 +1533,11 @@ export class OpenAIOAuthProvider {
1738
1533
  }
1739
1534
  if (_shouldUseOpenAIHttpFallback(err, externalSignal)) {
1740
1535
  try {
1741
- return await dispatchHttp(err?.retryClassifier || err?.midstreamClassifier || err?.code || err?.message || 'ws_failed', err);
1536
+ return await dispatchHttp(
1537
+ err?.retryClassifier || err?.midstreamClassifier || err?.code || err?.message || 'ws_failed',
1538
+ err,
1539
+ { sticky: true },
1540
+ );
1742
1541
  } catch (fallbackErr) {
1743
1542
  try { err.fallbackError = fallbackErr; } catch {}
1744
1543
  throw err;
@@ -1748,7 +1547,7 @@ export class OpenAIOAuthProvider {
1748
1547
  }
1749
1548
  }
1750
1549
  async listModels() {
1751
- // Dynamic lookup via Codex /backend-api/codex/models. Cached 24h.
1550
+ // Dynamic lookup via /backend-api/codex/models. Cached 24h.
1752
1551
  // Endpoint returns rich metadata (context_window, reasoning levels,
1753
1552
  // visibility) that is more detailed than /v1/models.
1754
1553
  const cached = await _loadCodexModelCache();
@@ -1772,7 +1571,7 @@ export class OpenAIOAuthProvider {
1772
1571
  },
1773
1572
  dispatcher: getLlmDispatcher(),
1774
1573
  });
1775
- if (!res.ok) throw new Error(`codex list_models ${res.status}`);
1574
+ if (!res.ok) throw new Error(`openai-oauth list_models ${res.status}`);
1776
1575
  const data = await res.json();
1777
1576
  const items = Array.isArray(data?.models) ? data.models : [];
1778
1577
  const normalized = items.map(m => _normalizeCodexModel(m));
@@ -1785,7 +1584,7 @@ export class OpenAIOAuthProvider {
1785
1584
  _lastCodexListModelsError = err?.message || String(err);
1786
1585
  process.stderr.write(`[openai-oauth] listModels fetch failed (${_lastCodexListModelsError})\n`);
1787
1586
  // No fallback catalog — empty list signals the UI to show a
1788
- // "catalog unavailable, retry" state. Codex has no equivalent to
1587
+ // "catalog unavailable, retry" state. openai-oauth has no equivalent to
1789
1588
  // Anthropic's family tokens so there's no meaningful minimal list.
1790
1589
  return [];
1791
1590
  }
@@ -1857,7 +1656,64 @@ function _scrubOAuthLoginBody(text) {
1857
1656
  .replace(/[A-Za-z0-9_-]{32,}\.[A-Za-z0-9._-]+/g, '[REDACTED]');
1858
1657
  }
1859
1658
 
1860
- export async function loginOAuth() {
1659
+ function _parseOAuthCodeInput(input) {
1660
+ const value = String(input || '').trim();
1661
+ if (!value) return { code: '', state: '' };
1662
+ try {
1663
+ const url = new URL(value);
1664
+ const code = url.searchParams.get('code') || '';
1665
+ const state = url.searchParams.get('state') || '';
1666
+ if (code || state) return { code, state };
1667
+ } catch { /* not a URL */ }
1668
+ if (value.includes('#')) {
1669
+ const [code, state] = value.split('#', 2);
1670
+ return { code: String(code || '').trim(), state: String(state || '').trim() };
1671
+ }
1672
+ if (value.includes('code=')) {
1673
+ const params = new URLSearchParams(value.startsWith('?') ? value.slice(1) : value);
1674
+ return { code: params.get('code') || '', state: params.get('state') || '' };
1675
+ }
1676
+ return { code: value, state: '' };
1677
+ }
1678
+
1679
+ async function exchangeAuthorizationCode({ pkce, code }) {
1680
+ const cleanCode = String(code || '').trim();
1681
+ if (!cleanCode) throw new Error('[openai-oauth] authorization code is required');
1682
+ const tokenRes = await fetch(TOKEN_URL, {
1683
+ method: 'POST',
1684
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1685
+ body: new URLSearchParams({
1686
+ grant_type: 'authorization_code',
1687
+ code: cleanCode,
1688
+ redirect_uri: REDIRECT_URI,
1689
+ client_id: CLIENT_ID,
1690
+ code_verifier: pkce.verifier,
1691
+ }),
1692
+ redirect: 'error',
1693
+ signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
1694
+ });
1695
+ if (!tokenRes.ok) {
1696
+ const text = await tokenRes.text().catch(() => '');
1697
+ throw new Error(`[openai-oauth] token exchange ${tokenRes.status}: ${_scrubOAuthLoginBody(text).slice(0, 500)}`);
1698
+ }
1699
+ const json = await tokenRes.json();
1700
+ if (!json.access_token || !json.refresh_token) {
1701
+ throw new Error('[openai-oauth] token exchange response missing access_token or refresh_token');
1702
+ }
1703
+ const expiresAt = (typeof json.expires_in === 'number'
1704
+ ? Date.now() + json.expires_in * 1000
1705
+ : 0) || _expiryFromAccessToken(json.access_token);
1706
+ const tokens = {
1707
+ access_token: json.access_token,
1708
+ refresh_token: json.refresh_token,
1709
+ expires_at: expiresAt,
1710
+ account_id: extractAccountId(json.access_token),
1711
+ };
1712
+ saveTokens(tokens);
1713
+ return tokens;
1714
+ }
1715
+
1716
+ export async function beginOAuthLogin() {
1861
1717
  const pkce = generatePKCE();
1862
1718
  const state = randomBytes(16).toString('hex');
1863
1719
  const url = new URL(AUTHORIZE_URL);
@@ -1872,18 +1728,20 @@ export async function loginOAuth() {
1872
1728
  url.searchParams.set('state', state);
1873
1729
  url.searchParams.set('originator', CODEX_OAUTH_ORIGINATOR);
1874
1730
 
1875
- return new Promise((resolve, reject) => {
1731
+ let server = null;
1732
+ let timeout = null;
1733
+ let finish = null;
1734
+ const waitForCallback = new Promise((resolve, reject) => {
1876
1735
  let settled = false;
1877
- let timeout = null;
1878
- const settle = (value, error = null) => {
1736
+ finish = (value, error = null) => {
1879
1737
  if (settled) return;
1880
1738
  settled = true;
1881
1739
  if (timeout) clearTimeout(timeout);
1882
- try { server.close(); } catch { /* already closed */ }
1740
+ try { server?.close(); } catch { /* already closed */ }
1883
1741
  if (error) reject(error);
1884
1742
  else resolve(value);
1885
1743
  };
1886
- const server = createServer(async (req, res) => {
1744
+ server = createServer(async (req, res) => {
1887
1745
  const u = new URL(req.url || '/', `http://${CALLBACK_HOST}:${CALLBACK_PORT}`);
1888
1746
  if (u.pathname !== CALLBACK_PATH) {
1889
1747
  res.writeHead(404);
@@ -1894,53 +1752,21 @@ export async function loginOAuth() {
1894
1752
  if (!code || u.searchParams.get('state') !== state) {
1895
1753
  res.writeHead(400);
1896
1754
  res.end('Invalid');
1897
- settle(null);
1755
+ finish(null);
1898
1756
  return;
1899
1757
  }
1900
1758
  res.writeHead(200, { 'Content-Type': 'text/html' });
1901
- res.end('<html><body><h2>Codex login successful! You can close this tab.</h2></body></html>');
1759
+ res.end('<html><body><h2>OpenAI OAuth login successful! You can close this tab.</h2></body></html>');
1902
1760
  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);
1761
+ const tokens = await exchangeAuthorizationCode({ pkce, code });
1762
+ finish(tokens);
1937
1763
  } catch (err) {
1938
- settle(null, err instanceof Error ? err : new Error(String(err)));
1764
+ finish(null, err instanceof Error ? err : new Error(String(err)));
1939
1765
  }
1940
1766
  });
1941
- timeout = setTimeout(() => settle(null), LOGIN_TIMEOUT_MS);
1767
+ timeout = setTimeout(() => finish(null), LOGIN_TIMEOUT_MS);
1942
1768
  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`);
1769
+ process.stderr.write(`\n[openai-oauth] Open this URL to log in to ChatGPT (OpenAI OAuth):\n${url.toString()}\n\n`);
1944
1770
  try {
1945
1771
  const { openInBrowser } = await import('../../../shared/open-url.mjs');
1946
1772
  openInBrowser(url.toString());
@@ -1948,6 +1774,27 @@ export async function loginOAuth() {
1948
1774
  process.stderr.write(`[openai-oauth] browser open failed: ${String(err?.message || err).slice(0, 200)}\n`);
1949
1775
  }
1950
1776
  });
1951
- server.on('error', (err) => settle(null, new Error(`[openai-oauth] callback server failed on ${CALLBACK_HOST}:${CALLBACK_PORT}: ${err?.message || err}`)));
1777
+ server.on('error', (err) => finish(null, new Error(`[openai-oauth] callback server failed on ${CALLBACK_HOST}:${CALLBACK_PORT}: ${err?.message || err}`)));
1952
1778
  });
1779
+
1780
+ return {
1781
+ provider: 'openai-oauth',
1782
+ url: url.toString(),
1783
+ waitForCallback,
1784
+ completeCode: async (input) => {
1785
+ const parsed = _parseOAuthCodeInput(input);
1786
+ if (parsed.state && parsed.state !== state) throw new Error('[openai-oauth] OAuth state mismatch');
1787
+ const tokens = await exchangeAuthorizationCode({ pkce, code: parsed.code });
1788
+ finish?.(tokens);
1789
+ return tokens;
1790
+ },
1791
+ cancel: () => {
1792
+ finish?.(null);
1793
+ },
1794
+ };
1795
+ }
1796
+
1797
+ export async function loginOAuth() {
1798
+ const login = await beginOAuthLogin();
1799
+ return await login.waitForCallback;
1953
1800
  }