uxnan-bridge 0.0.1-alpha.20260621

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 (255) hide show
  1. package/README.md +150 -0
  2. package/dist/src/account-status.d.ts +13 -0
  3. package/dist/src/account-status.js +78 -0
  4. package/dist/src/account-status.js.map +1 -0
  5. package/dist/src/adapters/base-adapter.d.ts +18 -0
  6. package/dist/src/adapters/base-adapter.js +15 -0
  7. package/dist/src/adapters/base-adapter.js.map +1 -0
  8. package/dist/src/adapters/claude-adapter.d.ts +102 -0
  9. package/dist/src/adapters/claude-adapter.js +486 -0
  10. package/dist/src/adapters/claude-adapter.js.map +1 -0
  11. package/dist/src/adapters/claude-tools.d.ts +25 -0
  12. package/dist/src/adapters/claude-tools.js +146 -0
  13. package/dist/src/adapters/claude-tools.js.map +1 -0
  14. package/dist/src/adapters/codex-adapter.d.ts +116 -0
  15. package/dist/src/adapters/codex-adapter.js +912 -0
  16. package/dist/src/adapters/codex-adapter.js.map +1 -0
  17. package/dist/src/adapters/codex-app-server.d.ts +74 -0
  18. package/dist/src/adapters/codex-app-server.js +225 -0
  19. package/dist/src/adapters/codex-app-server.js.map +1 -0
  20. package/dist/src/adapters/codex-approval.d.ts +88 -0
  21. package/dist/src/adapters/codex-approval.js +160 -0
  22. package/dist/src/adapters/codex-approval.js.map +1 -0
  23. package/dist/src/adapters/codex-tools.d.ts +18 -0
  24. package/dist/src/adapters/codex-tools.js +106 -0
  25. package/dist/src/adapters/codex-tools.js.map +1 -0
  26. package/dist/src/adapters/content-blocks.d.ts +68 -0
  27. package/dist/src/adapters/content-blocks.js +205 -0
  28. package/dist/src/adapters/content-blocks.js.map +1 -0
  29. package/dist/src/adapters/echo-agent-adapter.d.ts +23 -0
  30. package/dist/src/adapters/echo-agent-adapter.js +72 -0
  31. package/dist/src/adapters/echo-agent-adapter.js.map +1 -0
  32. package/dist/src/adapters/gemini-adapter.d.ts +87 -0
  33. package/dist/src/adapters/gemini-adapter.js +594 -0
  34. package/dist/src/adapters/gemini-adapter.js.map +1 -0
  35. package/dist/src/adapters/gemini-tools.d.ts +4 -0
  36. package/dist/src/adapters/gemini-tools.js +48 -0
  37. package/dist/src/adapters/gemini-tools.js.map +1 -0
  38. package/dist/src/adapters/opencode-adapter.d.ts +74 -0
  39. package/dist/src/adapters/opencode-adapter.js +418 -0
  40. package/dist/src/adapters/opencode-adapter.js.map +1 -0
  41. package/dist/src/adapters/opencode-tools.d.ts +2 -0
  42. package/dist/src/adapters/opencode-tools.js +41 -0
  43. package/dist/src/adapters/opencode-tools.js.map +1 -0
  44. package/dist/src/adapters/pi-adapter.d.ts +92 -0
  45. package/dist/src/adapters/pi-adapter.js +467 -0
  46. package/dist/src/adapters/pi-adapter.js.map +1 -0
  47. package/dist/src/adapters/pi-tools.d.ts +10 -0
  48. package/dist/src/adapters/pi-tools.js +72 -0
  49. package/dist/src/adapters/pi-tools.js.map +1 -0
  50. package/dist/src/adapters/process-agent-adapter.d.ts +24 -0
  51. package/dist/src/adapters/process-agent-adapter.js +111 -0
  52. package/dist/src/adapters/process-agent-adapter.js.map +1 -0
  53. package/dist/src/adapters/resolve-claude.d.ts +13 -0
  54. package/dist/src/adapters/resolve-claude.js +57 -0
  55. package/dist/src/adapters/resolve-claude.js.map +1 -0
  56. package/dist/src/adapters/resolve-codex.d.ts +13 -0
  57. package/dist/src/adapters/resolve-codex.js +48 -0
  58. package/dist/src/adapters/resolve-codex.js.map +1 -0
  59. package/dist/src/adapters/resolve-gemini.d.ts +13 -0
  60. package/dist/src/adapters/resolve-gemini.js +47 -0
  61. package/dist/src/adapters/resolve-gemini.js.map +1 -0
  62. package/dist/src/adapters/resolve-opencode.d.ts +11 -0
  63. package/dist/src/adapters/resolve-opencode.js +49 -0
  64. package/dist/src/adapters/resolve-opencode.js.map +1 -0
  65. package/dist/src/adapters/resolve-pi.d.ts +13 -0
  66. package/dist/src/adapters/resolve-pi.js +46 -0
  67. package/dist/src/adapters/resolve-pi.js.map +1 -0
  68. package/dist/src/adapters/run-options.d.ts +22 -0
  69. package/dist/src/adapters/run-options.js +48 -0
  70. package/dist/src/adapters/run-options.js.map +1 -0
  71. package/dist/src/adapters/spawn.d.ts +20 -0
  72. package/dist/src/adapters/spawn.js +16 -0
  73. package/dist/src/adapters/spawn.js.map +1 -0
  74. package/dist/src/agents/agent-manager.d.ts +98 -0
  75. package/dist/src/agents/agent-manager.js +433 -0
  76. package/dist/src/agents/agent-manager.js.map +1 -0
  77. package/dist/src/agents/attachments.d.ts +28 -0
  78. package/dist/src/agents/attachments.js +121 -0
  79. package/dist/src/agents/attachments.js.map +1 -0
  80. package/dist/src/bridge-context.d.ts +45 -0
  81. package/dist/src/bridge-context.js +2 -0
  82. package/dist/src/bridge-context.js.map +1 -0
  83. package/dist/src/bridge-status.d.ts +12 -0
  84. package/dist/src/bridge-status.js +17 -0
  85. package/dist/src/bridge-status.js.map +1 -0
  86. package/dist/src/bridge.d.ts +37 -0
  87. package/dist/src/bridge.js +446 -0
  88. package/dist/src/bridge.js.map +1 -0
  89. package/dist/src/cli.d.ts +2 -0
  90. package/dist/src/cli.js +194 -0
  91. package/dist/src/cli.js.map +1 -0
  92. package/dist/src/conversation/session-history.d.ts +27 -0
  93. package/dist/src/conversation/session-history.js +1082 -0
  94. package/dist/src/conversation/session-history.js.map +1 -0
  95. package/dist/src/conversation/thread-store.d.ts +74 -0
  96. package/dist/src/conversation/thread-store.js +366 -0
  97. package/dist/src/conversation/thread-store.js.map +1 -0
  98. package/dist/src/daemon-config.d.ts +123 -0
  99. package/dist/src/daemon-config.js +64 -0
  100. package/dist/src/daemon-config.js.map +1 -0
  101. package/dist/src/daemon-state.d.ts +27 -0
  102. package/dist/src/daemon-state.js +76 -0
  103. package/dist/src/daemon-state.js.map +1 -0
  104. package/dist/src/git/git-runner.d.ts +24 -0
  105. package/dist/src/git/git-runner.js +63 -0
  106. package/dist/src/git/git-runner.js.map +1 -0
  107. package/dist/src/git/git-service.d.ts +76 -0
  108. package/dist/src/git/git-service.js +435 -0
  109. package/dist/src/git/git-service.js.map +1 -0
  110. package/dist/src/handler-router.d.ts +34 -0
  111. package/dist/src/handler-router.js +67 -0
  112. package/dist/src/handler-router.js.map +1 -0
  113. package/dist/src/handlers/account-handler.d.ts +4 -0
  114. package/dist/src/handlers/account-handler.js +27 -0
  115. package/dist/src/handlers/account-handler.js.map +1 -0
  116. package/dist/src/handlers/agent-handler.d.ts +2 -0
  117. package/dist/src/handlers/agent-handler.js +8 -0
  118. package/dist/src/handlers/agent-handler.js.map +1 -0
  119. package/dist/src/handlers/bridge-control-handler.d.ts +2 -0
  120. package/dist/src/handlers/bridge-control-handler.js +64 -0
  121. package/dist/src/handlers/bridge-control-handler.js.map +1 -0
  122. package/dist/src/handlers/desktop-handler.d.ts +12 -0
  123. package/dist/src/handlers/desktop-handler.js +5 -0
  124. package/dist/src/handlers/desktop-handler.js.map +1 -0
  125. package/dist/src/handlers/git-handler.d.ts +2 -0
  126. package/dist/src/handlers/git-handler.js +82 -0
  127. package/dist/src/handlers/git-handler.js.map +1 -0
  128. package/dist/src/handlers/index.d.ts +8 -0
  129. package/dist/src/handlers/index.js +22 -0
  130. package/dist/src/handlers/index.js.map +1 -0
  131. package/dist/src/handlers/not-implemented.d.ts +10 -0
  132. package/dist/src/handlers/not-implemented.js +21 -0
  133. package/dist/src/handlers/not-implemented.js.map +1 -0
  134. package/dist/src/handlers/notifications-handler.d.ts +2 -0
  135. package/dist/src/handlers/notifications-handler.js +62 -0
  136. package/dist/src/handlers/notifications-handler.js.map +1 -0
  137. package/dist/src/handlers/params.d.ts +11 -0
  138. package/dist/src/handlers/params.js +72 -0
  139. package/dist/src/handlers/params.js.map +1 -0
  140. package/dist/src/handlers/project-handler.d.ts +2 -0
  141. package/dist/src/handlers/project-handler.js +6 -0
  142. package/dist/src/handlers/project-handler.js.map +1 -0
  143. package/dist/src/handlers/thread-context-handler.d.ts +2 -0
  144. package/dist/src/handlers/thread-context-handler.js +211 -0
  145. package/dist/src/handlers/thread-context-handler.js.map +1 -0
  146. package/dist/src/handlers/workspace-handler.d.ts +2 -0
  147. package/dist/src/handlers/workspace-handler.js +101 -0
  148. package/dist/src/handlers/workspace-handler.js.map +1 -0
  149. package/dist/src/hooks/claude-approval-hook.d.ts +7 -0
  150. package/dist/src/hooks/claude-approval-hook.js +95 -0
  151. package/dist/src/hooks/claude-approval-hook.js.map +1 -0
  152. package/dist/src/hooks/gemini-approval-hook.d.ts +7 -0
  153. package/dist/src/hooks/gemini-approval-hook.js +113 -0
  154. package/dist/src/hooks/gemini-approval-hook.js.map +1 -0
  155. package/dist/src/index.d.ts +62 -0
  156. package/dist/src/index.js +65 -0
  157. package/dist/src/index.js.map +1 -0
  158. package/dist/src/keyring-secret-store.d.ts +36 -0
  159. package/dist/src/keyring-secret-store.js +70 -0
  160. package/dist/src/keyring-secret-store.js.map +1 -0
  161. package/dist/src/lock-file.d.ts +18 -0
  162. package/dist/src/lock-file.js +60 -0
  163. package/dist/src/lock-file.js.map +1 -0
  164. package/dist/src/logger.d.ts +28 -0
  165. package/dist/src/logger.js +99 -0
  166. package/dist/src/logger.js.map +1 -0
  167. package/dist/src/pairing/pairing-code-service.d.ts +45 -0
  168. package/dist/src/pairing/pairing-code-service.js +183 -0
  169. package/dist/src/pairing/pairing-code-service.js.map +1 -0
  170. package/dist/src/projects/project-registry.d.ts +14 -0
  171. package/dist/src/projects/project-registry.js +60 -0
  172. package/dist/src/projects/project-registry.js.map +1 -0
  173. package/dist/src/push/push-sender.d.ts +21 -0
  174. package/dist/src/push/push-sender.js +96 -0
  175. package/dist/src/push/push-sender.js.map +1 -0
  176. package/dist/src/push/push-service.d.ts +122 -0
  177. package/dist/src/push/push-service.js +260 -0
  178. package/dist/src/push/push-service.js.map +1 -0
  179. package/dist/src/qr.d.ts +17 -0
  180. package/dist/src/qr.js +31 -0
  181. package/dist/src/qr.js.map +1 -0
  182. package/dist/src/secret-store.d.ts +23 -0
  183. package/dist/src/secret-store.js +27 -0
  184. package/dist/src/secret-store.js.map +1 -0
  185. package/dist/src/secure-device-state.d.ts +16 -0
  186. package/dist/src/secure-device-state.js +63 -0
  187. package/dist/src/secure-device-state.js.map +1 -0
  188. package/dist/src/service-installer.d.ts +57 -0
  189. package/dist/src/service-installer.js +254 -0
  190. package/dist/src/service-installer.js.map +1 -0
  191. package/dist/src/session-state.d.ts +14 -0
  192. package/dist/src/session-state.js +19 -0
  193. package/dist/src/session-state.js.map +1 -0
  194. package/dist/src/transport/crypto.d.ts +43 -0
  195. package/dist/src/transport/crypto.js +78 -0
  196. package/dist/src/transport/crypto.js.map +1 -0
  197. package/dist/src/transport/lan-server.d.ts +33 -0
  198. package/dist/src/transport/lan-server.js +105 -0
  199. package/dist/src/transport/lan-server.js.map +1 -0
  200. package/dist/src/transport/local-hosts.d.ts +17 -0
  201. package/dist/src/transport/local-hosts.js +30 -0
  202. package/dist/src/transport/local-hosts.js.map +1 -0
  203. package/dist/src/transport/mdns-advertiser.d.ts +83 -0
  204. package/dist/src/transport/mdns-advertiser.js +282 -0
  205. package/dist/src/transport/mdns-advertiser.js.map +1 -0
  206. package/dist/src/transport/message-io.d.ts +33 -0
  207. package/dist/src/transport/message-io.js +87 -0
  208. package/dist/src/transport/message-io.js.map +1 -0
  209. package/dist/src/transport/outbound-log.d.ts +24 -0
  210. package/dist/src/transport/outbound-log.js +78 -0
  211. package/dist/src/transport/outbound-log.js.map +1 -0
  212. package/dist/src/transport/relay-client.d.ts +19 -0
  213. package/dist/src/transport/relay-client.js +27 -0
  214. package/dist/src/transport/relay-client.js.map +1 -0
  215. package/dist/src/transport/secure-channel.d.ts +33 -0
  216. package/dist/src/transport/secure-channel.js +81 -0
  217. package/dist/src/transport/secure-channel.js.map +1 -0
  218. package/dist/src/transport/server-handshake.d.ts +49 -0
  219. package/dist/src/transport/server-handshake.js +137 -0
  220. package/dist/src/transport/server-handshake.js.map +1 -0
  221. package/dist/src/transport/session-handler.d.ts +19 -0
  222. package/dist/src/transport/session-handler.js +134 -0
  223. package/dist/src/transport/session-handler.js.map +1 -0
  224. package/dist/src/transport/session-registry.d.ts +58 -0
  225. package/dist/src/transport/session-registry.js +91 -0
  226. package/dist/src/transport/session-registry.js.map +1 -0
  227. package/dist/src/transport/trust-store.d.ts +23 -0
  228. package/dist/src/transport/trust-store.js +33 -0
  229. package/dist/src/transport/trust-store.js.map +1 -0
  230. package/dist/src/transport/ws-adapter.d.ts +7 -0
  231. package/dist/src/transport/ws-adapter.js +16 -0
  232. package/dist/src/transport/ws-adapter.js.map +1 -0
  233. package/dist/src/version.d.ts +1 -0
  234. package/dist/src/version.js +13 -0
  235. package/dist/src/version.js.map +1 -0
  236. package/dist/src/workspace/browse-service.d.ts +10 -0
  237. package/dist/src/workspace/browse-service.js +97 -0
  238. package/dist/src/workspace/browse-service.js.map +1 -0
  239. package/dist/src/workspace/checkpoint-service.d.ts +21 -0
  240. package/dist/src/workspace/checkpoint-service.js +219 -0
  241. package/dist/src/workspace/checkpoint-service.js.map +1 -0
  242. package/dist/src/workspace/path-guard.d.ts +7 -0
  243. package/dist/src/workspace/path-guard.js +51 -0
  244. package/dist/src/workspace/path-guard.js.map +1 -0
  245. package/dist/src/workspace/workspace-service.d.ts +8 -0
  246. package/dist/src/workspace/workspace-service.js +111 -0
  247. package/dist/src/workspace/workspace-service.js.map +1 -0
  248. package/package.json +46 -0
  249. package/scripts/extract-gemini-hook.mjs +16 -0
  250. package/scripts/fake-approval-bridge.mjs +23 -0
  251. package/scripts/install-service-linux.sh +38 -0
  252. package/scripts/install-service-macos.sh +38 -0
  253. package/scripts/install-service-windows.ps1 +26 -0
  254. package/scripts/test-gemini-hook-e2e.mjs +168 -0
  255. package/scripts/write-gemini-settings.mjs +31 -0
@@ -0,0 +1,912 @@
1
+ /**
2
+ * OpenAI Codex CLI adapter (real agent) — v2 `app-server` turn protocol.
3
+ *
4
+ * ## Why app-server (refactor of the old `codex exec --json` adapter)
5
+ *
6
+ * `codex exec` is one-shot and non-interactive: it does not surface tool
7
+ * approvals, so the bridge couldn't actually gate sensitive tools — every
8
+ * sensitive call was either auto-approved (with `-s workspace-write`) or
9
+ * silently denied (with `-s read-only`). The `codex app-server` JSON-RPC
10
+ * protocol is the same one the desktop app uses; it is turn-based and
11
+ * surfaces the approval elicitations the bridge needs:
12
+ *
13
+ * - `item/commandExecution/requestApproval` (v2, current codex-cli 0.98+)
14
+ * - `item/fileChange/requestApproval` (v2)
15
+ * - `item/permissions/requestApproval` (v2)
16
+ * - `mcpServer/elicitation/request` (v2 MCP servers)
17
+ * - `item/tool/requestUserInput` (v2, EXPERIMENTAL)
18
+ * - `execCommandApproval` (v1, legacy)
19
+ * - `applyPatchApproval` (v1, legacy)
20
+ *
21
+ * All of these are routed to the bridge's existing `requestApproval` flow
22
+ * (the same `approval` content block the Claude `PreToolUse` hook uses), so
23
+ * the phone's approval card just works. See `codex-approval.ts` for the
24
+ * per-kind mapping.
25
+ *
26
+ * ## Process model
27
+ *
28
+ * One long-lived `codex app-server` process per adapter instance. It's
29
+ * spawned lazily on the first turn (or eagerly by `start()`), the bridge
30
+ * speaks JSON-RPC over its stdio, and the process is killed on `stop()`.
31
+ * Threads live inside the app-server, so multi-turn conversations are cheap
32
+ * (`turn/start` reuses the same `threadId`). The bridge persists each
33
+ * thread's `nativeSessionId` so a restart can `thread/resume` and the
34
+ * conversation history is preserved.
35
+ *
36
+ * Captured app-server JSON-RPC events (one JSON object per line):
37
+ * { "method":"turn/started", "params":{...} }
38
+ * { "method":"item/agentMessage/delta", "params":{ delta } }
39
+ * { "method":"item/reasoning/summaryTextDelta", "params":{ delta } }
40
+ * { "method":"item/commandExecution/outputDelta", "params":{ delta } }
41
+ * { "method":"item/completed", "params":{ item:{ type:'commandExecution'|'fileChange'|'agentMessage'|'reasoning'|... } } }
42
+ * { "method":"turn/completed", "params":{ turn:{ status, error?, tokenUsage? } } }
43
+ *
44
+ * Server requests that need a reply (handled by the bridge):
45
+ * { "id":N, "method":"item/commandExecution/requestApproval", "params":{...} }
46
+ * { "id":N, "method":"item/fileChange/requestApproval", "params":{...} }
47
+ * { "id":N, "method":"applyPatchApproval", "params":{...} }
48
+ * { "id":N, "method":"execCommandApproval", "params":{...} }
49
+ * { "id":N, "method":"item/permissions/requestApproval", "params":{...} }
50
+ * { "id":N, "method":"mcpServer/elicitation/request", "params":{...} }
51
+ * { "id":N, "method":"item/tool/requestUserInput", "params":{...} }
52
+ *
53
+ * See bridge/FOR-DEV.md (agent adapters) and bridge/docs/testing.md
54
+ * (validating adapters).
55
+ */
56
+ import { spawn } from 'node:child_process';
57
+ import { existsSync, readFileSync } from 'node:fs';
58
+ import { readFile } from 'node:fs/promises';
59
+ import { homedir } from 'node:os';
60
+ import { join, relative } from 'node:path';
61
+ import { runGit } from '../git/git-runner.js';
62
+ import { BaseAgentAdapter } from './base-adapter.js';
63
+ import { buildReplyResult, describeServerRequest, decisionToReply, } from './codex-approval.js';
64
+ import { CodexAppServerRpc, RpcError } from './codex-app-server.js';
65
+ import { codexReasoningText } from './codex-tools.js';
66
+ import { commandBlock, fileChangeBlock, toolBlock, unifiedDiffBlock, writeDiffBlock, } from './content-blocks.js';
67
+ import { effortValues, reasoningOption, reasoningValue, withOptions } from './run-options.js';
68
+ const CODEX_CAPABILITIES = {
69
+ planMode: true,
70
+ streaming: true,
71
+ approvals: true,
72
+ forking: true,
73
+ images: true,
74
+ reportsContextUsage: true,
75
+ };
76
+ /**
77
+ * Mapping of the bridge's {@link CodexPermissionMode} to the app-server
78
+ * `(approvalPolicy, sandbox)` pair sent to `thread/start`. The default
79
+ * switches to `interactive` so the bridge actually receives approvals (the
80
+ * whole point of the app-server refactor) — the previous `acceptEdits`
81
+ * default silently auto-approved everything.
82
+ */
83
+ function permissionToPolicies(mode) {
84
+ switch (mode) {
85
+ case 'default':
86
+ return { approvalPolicy: 'untrusted', sandbox: 'read-only' };
87
+ case 'acceptEdits':
88
+ // Back-compat: same effective behavior as the old `codex exec
89
+ // -s workspace-write` adapter (writes allowed, no prompts).
90
+ return { approvalPolicy: 'never', sandbox: 'workspace-write' };
91
+ case 'bypassPermissions':
92
+ return { approvalPolicy: 'never', sandbox: 'danger-full-access' };
93
+ case 'interactive':
94
+ // Workspace writes allowed; the bridge forwards every request
95
+ // approval to the phone so the user can decide.
96
+ return { approvalPolicy: 'on-request', sandbox: 'workspace-write' };
97
+ }
98
+ }
99
+ /** Hard cap on the app-server handshake before falling back to config.toml. */
100
+ const MODEL_LIST_TIMEOUT_MS = 8000;
101
+ /** Hard cap on the lifecycle of a single approval round-trip (matches Claude hook). */
102
+ const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000;
103
+ /**
104
+ * Reasoning-effort knob for Codex models discovered without an effort list
105
+ * (the `~/.codex/config.toml` fallback path). The app-server `model/list`
106
+ * reports the REAL per-model efforts (see `parseCodexReasoning`); this covers
107
+ * only the config-only fallback. Maps to `-c model_reasoning_effort=<level>`.
108
+ */
109
+ const CODEX_FALLBACK_REASONING = reasoningOption(effortValues(['low', 'medium', 'high', 'xhigh']));
110
+ /**
111
+ * Sum the context-occupying tokens from a Codex `turn.completed.usage` object
112
+ * (`{ input_tokens, cached_input_tokens, output_tokens, reasoning_output_tokens }`
113
+ * — `cached_input_tokens` is a subset of `input_tokens`, so it isn't added).
114
+ */
115
+ export function codexUsageTokens(usage) {
116
+ if (!isRecord(usage))
117
+ return undefined;
118
+ const count = (key) => typeof usage[key] === 'number' ? usage[key] : 0;
119
+ const total = count('input_tokens') + count('output_tokens') + count('reasoning_output_tokens');
120
+ return total > 0 ? total : undefined;
121
+ }
122
+ /**
123
+ * The default `spawnAppServer` impl: spawns the resolved Codex binary with
124
+ * `app-server` appended, pipes stdin/stdout, returns the streams. Used by
125
+ * production; tests inject a `spawnAppServer` that wires a fake app-server
126
+ * (NDJSON over a PassThrough) so the JSON-RPC client can be exercised.
127
+ */
128
+ function defaultSpawnAppServer(binaryPath, prependArgs) {
129
+ return () => {
130
+ const child = spawn(binaryPath, [...prependArgs, 'app-server'], {
131
+ stdio: ['pipe', 'pipe', 'pipe'],
132
+ windowsHide: true,
133
+ shell: false,
134
+ });
135
+ if (!child.stdout || !child.stdin) {
136
+ throw new Error('codex app-server: failed to acquire stdio streams');
137
+ }
138
+ return {
139
+ stdin: child.stdin,
140
+ stdout: child.stdout,
141
+ onClose: (cb) => {
142
+ child.on('close', cb);
143
+ },
144
+ kill: () => {
145
+ child.kill();
146
+ },
147
+ };
148
+ };
149
+ }
150
+ export class CodexAdapter extends BaseAgentAdapter {
151
+ agentId = 'codex';
152
+ capabilities = CODEX_CAPABILITIES;
153
+ #binaryPath;
154
+ #prependArgs;
155
+ #defaultModel;
156
+ #permissionMode;
157
+ #onApprovalRequest;
158
+ #spawnAppServer;
159
+ /** threadId (bridge) → Codex app-server threadId, for `thread/resume` continuity. */
160
+ #threadByBridgeThread = new Map();
161
+ /** turnId (bridge) → in-flight run, for cancellation. */
162
+ #active = new Map();
163
+ /** model id → context-window tokens, from `~/.codex/models_cache.json`. */
164
+ #contextWindowByModel = new Map();
165
+ #windowsLoaded = false;
166
+ #defaultCwd = process.cwd();
167
+ /** Long-lived app-server connection. Spawned lazily on first use. */
168
+ #rpc = null;
169
+ #appServerInit = null;
170
+ /** Pending approvals keyed by the bridge's `approvalId`; the server request id
171
+ * is captured so the reply is shaped with the right `ReviewDecision` kind. */
172
+ #pendingApprovals = new Map();
173
+ #approvalSeq = 0;
174
+ /** Native Codex thread id for a thread (on-disk history-fallback locator). */
175
+ nativeSessionId(threadId) {
176
+ return this.#threadByBridgeThread.get(threadId);
177
+ }
178
+ constructor(options = {}) {
179
+ super();
180
+ this.#binaryPath = options.binaryPath ?? 'codex';
181
+ this.#prependArgs = options.prependArgs ?? [];
182
+ this.#defaultModel = options.defaultModel;
183
+ this.#permissionMode = options.permissionMode ?? 'interactive';
184
+ this.#onApprovalRequest = options.onApprovalRequest;
185
+ this.#spawnAppServer =
186
+ options.spawnAppServer ?? defaultSpawnAppServer(this.#binaryPath, this.#prependArgs);
187
+ }
188
+ get defaultModel() {
189
+ return this.#defaultModel;
190
+ }
191
+ start(config) {
192
+ if (config.cwd)
193
+ this.#defaultCwd = config.cwd;
194
+ return Promise.resolve();
195
+ }
196
+ async stop() {
197
+ for (const run of this.#active.values()) {
198
+ try {
199
+ await this.#interruptTurn(run);
200
+ }
201
+ catch {
202
+ /* best-effort */
203
+ }
204
+ }
205
+ this.#active.clear();
206
+ if (this.#rpc) {
207
+ this.#rpc.close();
208
+ this.#rpc = null;
209
+ this.#appServerInit = null;
210
+ }
211
+ }
212
+ async sendTurn(options) {
213
+ const { threadId, turnId, text } = options;
214
+ const cwd = options.cwd ?? this.#defaultCwd;
215
+ const model = options.service ?? this.#defaultModel;
216
+ const effort = reasoningValue(options);
217
+ const { approvalPolicy, sandbox } = permissionToPolicies(this.#permissionMode);
218
+ // Spawn or reuse the app-server. We await the initialization so a slow
219
+ // first turn surfaces a clear error rather than racing the `turn/start`.
220
+ let rpc;
221
+ try {
222
+ rpc = await this.#ensureAppServer();
223
+ }
224
+ catch (err) {
225
+ this.emit({
226
+ type: 'turn_error',
227
+ threadId,
228
+ turnId,
229
+ data: { text: `failed to start codex app-server: ${errorMessage(err)}` },
230
+ });
231
+ return;
232
+ }
233
+ // Resolve the Codex thread id: re-use a previously persisted one (after a
234
+ // bridge restart) or start a fresh one.
235
+ let codexThreadId = this.#threadByBridgeThread.get(threadId);
236
+ if (!codexThreadId) {
237
+ try {
238
+ const started = await rpc.request('thread/start', {
239
+ model,
240
+ cwd,
241
+ approvalPolicy,
242
+ sandbox,
243
+ ...(typeof effort === 'string' ? { effort } : {}),
244
+ });
245
+ codexThreadId = started.thread.id;
246
+ this.#threadByBridgeThread.set(threadId, codexThreadId);
247
+ }
248
+ catch (err) {
249
+ this.emit({
250
+ type: 'turn_error',
251
+ threadId,
252
+ turnId,
253
+ data: { text: `codex thread/start failed: ${errorMessage(err)}` },
254
+ });
255
+ return;
256
+ }
257
+ }
258
+ // Persist the native session id early so the on-disk history fallback
259
+ // works after a crash mid-turn.
260
+ this.#active.set(turnId, {
261
+ bridgeTurnId: turnId,
262
+ codexTurnId: null,
263
+ threadId,
264
+ lastAgentText: '',
265
+ ...(typeof model === 'string' ? { model } : {}),
266
+ });
267
+ // Warm the per-model context-window cache (once) so completion can emit a
268
+ // window → a percentage on the phone.
269
+ void this.#loadContextWindows();
270
+ this.emit({ type: 'turn_started', threadId, turnId });
271
+ try {
272
+ const response = await rpc.request('turn/start', {
273
+ threadId: codexThreadId,
274
+ input: [{ type: 'text', text }],
275
+ ...(typeof model === 'string' ? { model } : {}),
276
+ ...(typeof effort === 'string' ? { effort } : {}),
277
+ });
278
+ const run = this.#active.get(turnId);
279
+ if (run)
280
+ run.codexTurnId = response.turn.id;
281
+ }
282
+ catch (err) {
283
+ this.#active.delete(turnId);
284
+ this.emit({
285
+ type: 'turn_error',
286
+ threadId,
287
+ turnId,
288
+ data: { text: `codex turn/start failed: ${errorMessage(err)}` },
289
+ });
290
+ }
291
+ }
292
+ async cancelTurn(_threadId, turnId) {
293
+ const run = this.#active.get(turnId);
294
+ if (!run)
295
+ return;
296
+ await this.#interruptTurn(run);
297
+ }
298
+ async #interruptTurn(run) {
299
+ this.#active.delete(run.bridgeTurnId);
300
+ if (!this.#rpc)
301
+ return;
302
+ if (!run.codexTurnId) {
303
+ // Turn never produced an id; the app-server hasn't seen it yet. We
304
+ // can't interrupt what doesn't exist — emit the abort now so the
305
+ // phone doesn't keep waiting.
306
+ this.emit({ type: 'turn_aborted', threadId: run.threadId, turnId: run.bridgeTurnId });
307
+ return;
308
+ }
309
+ const codexThreadId = this.#threadByBridgeThread.get(run.threadId);
310
+ try {
311
+ await this.#rpc.request('turn/interrupt', {
312
+ threadId: codexThreadId,
313
+ turnId: run.codexTurnId,
314
+ });
315
+ }
316
+ catch {
317
+ /* process may have died — the close handler will surface it */
318
+ }
319
+ this.emit({ type: 'turn_aborted', threadId: run.threadId, turnId: run.bridgeTurnId });
320
+ }
321
+ /** Lazy app-server lifecycle: spawn → initialize → return the RPC client. */
322
+ #ensureAppServer() {
323
+ if (this.#appServerInit)
324
+ return this.#appServerInit;
325
+ this.#appServerInit = (async () => {
326
+ const streams = this.#spawnAppServer();
327
+ const rpc = new CodexAppServerRpc({
328
+ stdin: streams.stdin,
329
+ stdout: streams.stdout,
330
+ onClose: () => this.#handleAppServerClose(),
331
+ }, {
332
+ onNotification: (method, params) => this.#onNotification(method, params),
333
+ onServerRequest: (method, params) => this.#onServerRequest(method, params),
334
+ });
335
+ streams.onClose((code) => {
336
+ rpc.onProcessClose(code);
337
+ });
338
+ try {
339
+ await rpc.request('initialize', {
340
+ clientInfo: { name: 'uxnan-bridge', title: null, version: '1.0.0' },
341
+ });
342
+ }
343
+ catch (err) {
344
+ rpc.close();
345
+ streams.kill();
346
+ throw err;
347
+ }
348
+ this.#rpc = rpc;
349
+ return rpc;
350
+ })().catch((err) => {
351
+ this.#appServerInit = null;
352
+ throw err;
353
+ });
354
+ return this.#appServerInit;
355
+ }
356
+ /** Handle an unexpected app-server exit: drop state, fail in-flight turns. */
357
+ #handleAppServerClose() {
358
+ this.#rpc = null;
359
+ this.#appServerInit = null;
360
+ for (const run of this.#active.values()) {
361
+ this.emit({
362
+ type: 'turn_error',
363
+ threadId: run.threadId,
364
+ turnId: run.bridgeTurnId,
365
+ data: { text: 'codex app-server process exited unexpectedly' },
366
+ });
367
+ }
368
+ this.#active.clear();
369
+ for (const approvalId of [...this.#pendingApprovals.keys()]) {
370
+ // Drop local state; the bridge's 5-min timer covers the round-trip.
371
+ this.#pendingApprovals.delete(approvalId);
372
+ }
373
+ }
374
+ /**
375
+ * Map one app-server notification to zero or more bridge events. Runs in
376
+ * the context of the JSON-RPC client's stdout reader.
377
+ */
378
+ async #onNotification(method, params) {
379
+ const p = isRecord(params) ? params : {};
380
+ switch (method) {
381
+ case 'turn/started':
382
+ // The bridge already emits `turn_started` immediately when we
383
+ // receive the `turn/start` response; the app-server's notification
384
+ // is a duplicate we ignore.
385
+ return;
386
+ case 'item/agentMessage/delta': {
387
+ const delta = typeof p['delta'] === 'string' ? p['delta'] : '';
388
+ if (delta)
389
+ this.#emitDelta(p, delta);
390
+ return;
391
+ }
392
+ case 'item/reasoning/summaryTextDelta':
393
+ case 'item/reasoning/textDelta': {
394
+ const delta = typeof p['delta'] === 'string' ? p['delta'] : '';
395
+ if (delta)
396
+ this.#emitThinking(p, delta);
397
+ return;
398
+ }
399
+ case 'item/commandExecution/outputDelta':
400
+ // Streaming command output is folded into the `command_execution`
401
+ // block we emit on `item/completed`; skip per-chunk updates to avoid
402
+ // spamming the phone with intermediate state.
403
+ return;
404
+ case 'item/started':
405
+ // Item begin — we don't need it (the relevant state arrives on
406
+ // `item/completed`); ignore for now.
407
+ return;
408
+ case 'item/completed': {
409
+ const item = isRecord(p['item']) ? p['item'] : undefined;
410
+ if (item)
411
+ await this.#onItemCompleted(item);
412
+ return;
413
+ }
414
+ case 'turn/completed': {
415
+ const turn = isRecord(p['turn']) ? p['turn'] : undefined;
416
+ if (turn)
417
+ await this.#onTurnCompleted(turn);
418
+ return;
419
+ }
420
+ case 'turn/diff/updated':
421
+ // The unified diff the app-server has accumulated so far. We could
422
+ // surface this as a `turn/diff` block but it duplicates the
423
+ // `fileChange` items; the phone already gets structured diffs from
424
+ // those. Ignore.
425
+ return;
426
+ case 'error': {
427
+ // An error notification is rare but the app-server uses it for
428
+ // catastrophic failures (e.g. context overflow). Surface to the
429
+ // current in-flight turn.
430
+ const message = typeof p['message'] === 'string' ? p['message'] : 'codex app-server error';
431
+ this.#emitTurnErrorForActive(message);
432
+ return;
433
+ }
434
+ default:
435
+ // Unknown notifications are tolerated (the protocol is large and
436
+ // version-dependent); we just don't react.
437
+ return;
438
+ }
439
+ }
440
+ /** Handle an item completion: route to the right bridge event. */
441
+ async #onItemCompleted(item) {
442
+ const run = this.#activeRun();
443
+ if (!run)
444
+ return;
445
+ const itype = item['type'];
446
+ switch (itype) {
447
+ case 'agentMessage': {
448
+ // Final assembled text arrives here; deltas already streamed, so we
449
+ // record it for `turn/completed` to fall back to.
450
+ const text = typeof item['text'] === 'string' ? item['text'] : '';
451
+ run.lastAgentText = text;
452
+ return;
453
+ }
454
+ case 'reasoning': {
455
+ // Some Codex versions emit the full reasoning body as a `text` field
456
+ // (others only via deltas). Surface anything we haven't already
457
+ // streamed.
458
+ const text = codexReasoningText(item);
459
+ if (text)
460
+ this.emit({
461
+ type: 'thinking',
462
+ threadId: run.threadId,
463
+ turnId: run.bridgeTurnId,
464
+ data: { text },
465
+ });
466
+ return;
467
+ }
468
+ case 'commandExecution': {
469
+ const exit = item['exitCode'];
470
+ const isError = item['status'] === 'failed' || (typeof exit === 'number' && exit !== 0);
471
+ const output = typeof item['aggregatedOutput'] === 'string' ? item['aggregatedOutput'] : '';
472
+ const command = typeof item['command'] === 'string' ? item['command'] : '';
473
+ if (command) {
474
+ this.emit({
475
+ type: 'block',
476
+ threadId: run.threadId,
477
+ turnId: run.bridgeTurnId,
478
+ data: { content: commandBlock(command, output, isError) },
479
+ });
480
+ }
481
+ return;
482
+ }
483
+ case 'fileChange': {
484
+ const changes = Array.isArray(item['changes'])
485
+ ? item['changes'].map((c) => ({
486
+ path: typeof c['path'] === 'string' ? c['path'] : '',
487
+ kind: typeof c['kind'] === 'string' ? c['kind'] : '',
488
+ diff: typeof c['diff'] === 'string' ? c['diff'] : '',
489
+ }))
490
+ : [];
491
+ for (const change of changes) {
492
+ const name = isAbsolutePath(change.path)
493
+ ? relative(this.#defaultCwd, change.path) || change.path
494
+ : change.path;
495
+ // The app-server already attaches the unified diff (unlike the
496
+ // `exec --json` path which only carried the path); use it directly
497
+ // when present, else fall back to reading the file.
498
+ let content;
499
+ if (change.kind !== 'delete' && change.diff && change.diff.length > 0) {
500
+ content = unifiedDiffBlock(name, change.diff);
501
+ }
502
+ else if (change.kind !== 'delete') {
503
+ try {
504
+ const { stdout } = await runGit(this.#defaultCwd, [
505
+ 'diff',
506
+ 'HEAD',
507
+ '--',
508
+ change.path,
509
+ ]);
510
+ if (stdout.trim().length > 0)
511
+ content = unifiedDiffBlock(name, stdout);
512
+ }
513
+ catch {
514
+ /* not a git repo / no HEAD */
515
+ }
516
+ if (!content) {
517
+ try {
518
+ content = writeDiffBlock(name, await readFile(change.path, 'utf-8'));
519
+ }
520
+ catch {
521
+ /* unreadable */
522
+ }
523
+ }
524
+ }
525
+ content ??= fileChangeBlock(change.path);
526
+ this.emit({
527
+ type: 'block',
528
+ threadId: run.threadId,
529
+ turnId: run.bridgeTurnId,
530
+ data: { content },
531
+ });
532
+ }
533
+ return;
534
+ }
535
+ case 'mcpToolCall': {
536
+ const name = typeof item['tool'] === 'string' ? item['tool'] : 'tool';
537
+ const output = typeof item['result'] === 'string' ? item['result'] : '';
538
+ this.emit({
539
+ type: 'block',
540
+ threadId: run.threadId,
541
+ turnId: run.bridgeTurnId,
542
+ data: {
543
+ content: toolBlock(name, typeof item['id'] === 'string' ? item['id'] : '', {}, output, item['status'] === 'failed'),
544
+ },
545
+ });
546
+ return;
547
+ }
548
+ case 'webSearch':
549
+ case 'contextCompaction':
550
+ case 'plan':
551
+ case 'userMessage':
552
+ case 'enteredReviewMode':
553
+ case 'exitedReviewMode':
554
+ // Known item types we currently render as plain text on the phone;
555
+ // no structured block is needed. The full history-fallback path
556
+ // (session-history.ts) will still surface them via the on-disk
557
+ // rollout reader.
558
+ return;
559
+ default:
560
+ return;
561
+ }
562
+ }
563
+ /** Finalize a turn once the app-server's `turn/completed` arrives. */
564
+ async #onTurnCompleted(turn) {
565
+ const run = this.#activeRun();
566
+ if (!run)
567
+ return;
568
+ const status = typeof turn['status'] === 'string' ? turn['status'] : 'completed';
569
+ const error = isRecord(turn['error']) ? turn['error'] : undefined;
570
+ this.#active.delete(run.bridgeTurnId);
571
+ if (status === 'failed' || error) {
572
+ const message = error && typeof error['message'] === 'string'
573
+ ? error['message']
574
+ : 'codex turn failed';
575
+ this.emit({
576
+ type: 'turn_error',
577
+ threadId: run.threadId,
578
+ turnId: run.bridgeTurnId,
579
+ data: { text: message },
580
+ });
581
+ return;
582
+ }
583
+ const usage = isRecord(turn['tokenUsage']) ? turn['tokenUsage'] : undefined;
584
+ const tokens = codexUsageTokens(usage);
585
+ const contextWindow = run.model !== undefined ? this.#contextWindowByModel.get(run.model) : undefined;
586
+ this.emit({
587
+ type: 'turn_completed',
588
+ threadId: run.threadId,
589
+ turnId: run.bridgeTurnId,
590
+ data: {
591
+ text: run.lastAgentText,
592
+ ...(tokens !== undefined
593
+ ? { usage: { tokens, ...(contextWindow !== undefined ? { contextWindow } : {}) } }
594
+ : {}),
595
+ },
596
+ });
597
+ }
598
+ /**
599
+ * Return the current in-flight run (mutable reference) so item-completed
600
+ * handlers can accumulate per-run state (`lastAgentText`, etc.) directly on
601
+ * the stored object. Returns `null` when no turn is active.
602
+ */
603
+ #activeRun() {
604
+ for (const run of this.#active.values())
605
+ return run;
606
+ return null;
607
+ }
608
+ /** Helper: locate the current in-flight run keyed by bridge turnId. */
609
+ #currentRun() {
610
+ for (const run of this.#active.values()) {
611
+ // There should be exactly one in-flight run for a single adapter; the
612
+ // bridge serializes turns per thread, so this picks the first one.
613
+ return {
614
+ turnId: run.bridgeTurnId,
615
+ threadId: run.threadId,
616
+ cwd: this.#defaultCwd,
617
+ lastAgentText: run.lastAgentText,
618
+ };
619
+ }
620
+ return null;
621
+ }
622
+ #emitDelta(_p, delta) {
623
+ const run = this.#currentRun();
624
+ if (!run)
625
+ return;
626
+ this.emit({ type: 'delta', threadId: run.threadId, turnId: run.turnId, data: { text: delta } });
627
+ }
628
+ #emitThinking(_p, delta) {
629
+ const run = this.#currentRun();
630
+ if (!run)
631
+ return;
632
+ this.emit({
633
+ type: 'thinking',
634
+ threadId: run.threadId,
635
+ turnId: run.turnId,
636
+ data: { text: delta },
637
+ });
638
+ }
639
+ #emitTurnErrorForActive(message) {
640
+ const run = this.#currentRun();
641
+ if (!run)
642
+ return;
643
+ this.emit({
644
+ type: 'turn_error',
645
+ threadId: run.threadId,
646
+ turnId: run.turnId,
647
+ data: { text: message },
648
+ });
649
+ }
650
+ /**
651
+ * Handle a server-initiated request. Approval-shaped requests (see
652
+ * {@link describeServerRequest}) are routed to the bridge's approval
653
+ * round-trip; the rest are auto-rejected with a clear error so the
654
+ * app-server doesn't hang.
655
+ */
656
+ async #onServerRequest(method, params) {
657
+ const approval = describeServerRequest(method, params, -1);
658
+ if (!approval) {
659
+ // Unknown / unsupported elicitation: auto-reject so the app-server
660
+ // doesn't block waiting on a response we'd never send.
661
+ throw new RpcError(-32000, `codex: unhandled server request '${method}' (auto-rejected)`);
662
+ }
663
+ return this.#routeApproval(approval);
664
+ }
665
+ /** Run a Codex approval through the bridge's approval round-trip. */
666
+ async #routeApproval(draft) {
667
+ if (!this.#onApprovalRequest) {
668
+ // No bridge callback wired (unit test, or a caller that didn't pass
669
+ // `onApprovalRequest`): default to denying to fail safe.
670
+ return buildReplyResult(draft.kind, decisionToReply('reject'));
671
+ }
672
+ const run = this.#currentRun();
673
+ if (!run) {
674
+ return buildReplyResult(draft.kind, decisionToReply('reject'));
675
+ }
676
+ const approvalId = `codex-${run.turnId}-${(this.#approvalSeq += 1)}`;
677
+ this.#pendingApprovals.set(approvalId, {
678
+ kind: draft.kind,
679
+ serverRequestId: draft.serverRequestId,
680
+ });
681
+ try {
682
+ const decision = await Promise.race([
683
+ this.#onApprovalRequest(run.threadId, draft.descriptor),
684
+ new Promise((resolve) => setTimeout(() => resolve('reject'), APPROVAL_TIMEOUT_MS)),
685
+ ]);
686
+ return buildReplyResult(draft.kind, decisionToReply(decision));
687
+ }
688
+ finally {
689
+ this.#pendingApprovals.delete(approvalId);
690
+ }
691
+ }
692
+ /**
693
+ * List the models the account can use, account-aware (free vs paid changes
694
+ * the set). The app-server has no enumerate command in a turn session, so we
695
+ * drive a short-lived `codex app-server` process just to run the
696
+ * `initialize` → `model/list` JSON-RPC handshake (the desktop app's source).
697
+ * Falls back to `~/.codex/config.toml` (`model` + the
698
+ * `[tui.model_availability_nux]` table) if the app-server is unavailable.
699
+ *
700
+ * The short-lived process is independent of the long-lived one used for
701
+ * turns (so model listing always works even if a turn crashed the main
702
+ * process).
703
+ */
704
+ listModels() {
705
+ return new Promise((resolve) => {
706
+ let settled = false;
707
+ let timer;
708
+ const finish = (models) => {
709
+ if (settled)
710
+ return;
711
+ settled = true;
712
+ if (timer)
713
+ clearTimeout(timer);
714
+ try {
715
+ streams.kill();
716
+ }
717
+ catch {
718
+ /* already gone */
719
+ }
720
+ // parseCodexModelList already attaches each model's REAL per-model
721
+ // reasoning efforts; the config fallback gets a generic effort knob.
722
+ resolve(models.length > 0 ? models : this.#modelsFromConfig());
723
+ };
724
+ let streams;
725
+ try {
726
+ streams = this.#spawnAppServer();
727
+ }
728
+ catch {
729
+ resolve(this.#modelsFromConfig());
730
+ return;
731
+ }
732
+ const rpc = new CodexAppServerRpc({ stdin: streams.stdin, stdout: streams.stdout }, { onNotification: () => undefined, onServerRequest: () => null }, { requestTimeoutMs: MODEL_LIST_TIMEOUT_MS });
733
+ streams.onClose(() => rpc.onProcessClose(0));
734
+ timer = setTimeout(() => finish([]), MODEL_LIST_TIMEOUT_MS);
735
+ rpc
736
+ .request('initialize', {
737
+ clientInfo: { name: 'uxnan-bridge', title: null, version: '1.0.0' },
738
+ })
739
+ .then(() => rpc.request('model/list', {}).then((res) => {
740
+ finish(parseCodexModelList(res.data));
741
+ }))
742
+ .catch(() => finish([]));
743
+ });
744
+ }
745
+ /** Fallback model list read straight from `~/.codex/config.toml`. */
746
+ #modelsFromConfig() {
747
+ try {
748
+ const path = join(homedir(), '.codex', 'config.toml');
749
+ if (!existsSync(path))
750
+ return [];
751
+ return withOptions(parseCodexConfigModels(readFileSync(path, 'utf-8')), [
752
+ CODEX_FALLBACK_REASONING,
753
+ ]);
754
+ }
755
+ catch {
756
+ return [];
757
+ }
758
+ }
759
+ /**
760
+ * Populate the per-model context-window cache from Codex's own metadata cache
761
+ * (`~/.codex/models_cache.json`, refreshed by the codex CLI), keyed by model
762
+ * slug (e.g. `gpt-5.5` → 272000). Runs once; best-effort — a missing/unreadable
763
+ * file leaves usage count-only. The app-server `model/list` does not reliably
764
+ * carry a window, so this file is the authoritative source.
765
+ */
766
+ #loadContextWindows() {
767
+ if (this.#windowsLoaded)
768
+ return Promise.resolve();
769
+ this.#windowsLoaded = true;
770
+ try {
771
+ const path = join(homedir(), '.codex', 'models_cache.json');
772
+ if (existsSync(path)) {
773
+ for (const [slug, win] of parseCodexModelWindows(readFileSync(path, 'utf-8'))) {
774
+ this.#contextWindowByModel.set(slug, win);
775
+ }
776
+ }
777
+ }
778
+ catch {
779
+ /* leave the cache empty; usage stays count-only */
780
+ }
781
+ return Promise.resolve();
782
+ }
783
+ }
784
+ /**
785
+ * Parse `~/.codex/models_cache.json` into a model-slug → context-window map.
786
+ * The file is `{ models: [{ slug, context_window, … }] }`; entries without a
787
+ * positive `context_window` are skipped.
788
+ */
789
+ export function parseCodexModelWindows(raw) {
790
+ const windows = new Map();
791
+ let parsed;
792
+ try {
793
+ parsed = JSON.parse(raw);
794
+ }
795
+ catch {
796
+ return windows;
797
+ }
798
+ const models = isRecord(parsed) && Array.isArray(parsed['models']) ? parsed['models'] : [];
799
+ for (const entry of models) {
800
+ if (!isRecord(entry))
801
+ continue;
802
+ const slug = typeof entry['slug'] === 'string' ? entry['slug'] : undefined;
803
+ const window = typeof entry['context_window'] === 'number' ? entry['context_window'] : undefined;
804
+ if (slug && window !== undefined && window > 0)
805
+ windows.set(slug, window);
806
+ }
807
+ return windows;
808
+ }
809
+ function isRecord(value) {
810
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
811
+ }
812
+ function isAbsolutePath(p) {
813
+ return /^[a-zA-Z]:[\\\/]/.test(p) || p.startsWith('/');
814
+ }
815
+ /**
816
+ * Map the app-server `model/list` → `result.data` array into {@link AgentModel}s,
817
+ * skipping models hidden from the default picker.
818
+ */
819
+ export function parseCodexModelList(data) {
820
+ if (!Array.isArray(data))
821
+ return [];
822
+ const out = [];
823
+ for (const entry of data) {
824
+ if (!isRecord(entry))
825
+ continue;
826
+ if (entry['hidden'] === true)
827
+ continue;
828
+ const id = typeof entry['id'] === 'string'
829
+ ? entry['id']
830
+ : typeof entry['model'] === 'string'
831
+ ? entry['model']
832
+ : undefined;
833
+ if (!id)
834
+ continue;
835
+ const displayName = typeof entry['displayName'] === 'string' && entry['displayName'].length > 0
836
+ ? entry['displayName']
837
+ : id;
838
+ const description = typeof entry['description'] === 'string' && entry['description'].length > 0
839
+ ? entry['description']
840
+ : undefined;
841
+ const options = parseCodexReasoning(entry['supportedReasoningEfforts'], entry['defaultReasoningEffort']);
842
+ out.push({
843
+ id,
844
+ displayName,
845
+ ...(description !== undefined ? { description } : {}),
846
+ isDefault: entry['isDefault'] === true,
847
+ ...(options.length > 0 ? { options } : {}),
848
+ });
849
+ }
850
+ return out;
851
+ }
852
+ /**
853
+ * Build the per-model reasoning knob from the app-server's
854
+ * `supportedReasoningEfforts` (`[{ reasoningEffort, description }]`) and
855
+ * `defaultReasoningEffort`. Returns `[]` when the model reports no efforts.
856
+ */
857
+ export function parseCodexReasoning(raw, defaultEffort) {
858
+ if (!Array.isArray(raw))
859
+ return [];
860
+ const levels = [];
861
+ for (const entry of raw) {
862
+ const level = isRecord(entry) && typeof entry['reasoningEffort'] === 'string'
863
+ ? entry['reasoningEffort']
864
+ : undefined;
865
+ if (level && !levels.includes(level))
866
+ levels.push(level);
867
+ }
868
+ if (levels.length === 0)
869
+ return [];
870
+ const def = typeof defaultEffort === 'string' && levels.includes(defaultEffort) ? defaultEffort : undefined;
871
+ return [reasoningOption(effortValues(levels), def)];
872
+ }
873
+ /**
874
+ * Fallback parse of `~/.codex/config.toml`: the top-level `model` plus the keys
875
+ * of the `[tui.model_availability_nux]` table (models the account has seen).
876
+ * The configured `model` is flagged `isDefault`. Minimal hand-rolled scan — no
877
+ * TOML dependency — tolerant of comments and quoting.
878
+ */
879
+ export function parseCodexConfigModels(toml) {
880
+ let section = '';
881
+ let configuredModel;
882
+ const ids = new Set();
883
+ for (const raw of toml.split(/\r?\n/)) {
884
+ const line = raw.replace(/#.*$/, '').trim();
885
+ if (!line)
886
+ continue;
887
+ const header = /^\[([^\]]+)\]$/.exec(line);
888
+ if (header?.[1]) {
889
+ section = header[1].trim();
890
+ continue;
891
+ }
892
+ const kv = /^("?)([^"=]+?)\1\s*=\s*(.+)$/.exec(line);
893
+ const key = kv?.[2]?.trim();
894
+ if (!key)
895
+ continue;
896
+ if (section === '' && key === 'model') {
897
+ const value = (kv?.[3] ?? '').trim().replace(/^["']|["']$/g, '');
898
+ if (value) {
899
+ configuredModel = value;
900
+ ids.add(value);
901
+ }
902
+ }
903
+ else if (section === 'tui.model_availability_nux') {
904
+ ids.add(key);
905
+ }
906
+ }
907
+ return [...ids].map((id) => ({ id, displayName: id, isDefault: id === configuredModel }));
908
+ }
909
+ function errorMessage(err) {
910
+ return err instanceof Error ? err.message : String(err);
911
+ }
912
+ //# sourceMappingURL=codex-adapter.js.map